mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
* docs: design for where the npm proxy keeps cached registry content * feat(npm-proxy): keep package files on disk and in the object store * fix(npm-proxy): degrade when the cache is unwritable, stream and bound it * fix(npm-proxy): keep the happy path off the heap and isolate pull scratch * fix(npm-proxy): bound the upload, verify pulled trees, keep oversized manifests * fix(npm-proxy): protect live scratch, bound uploads by parts, refuse traversals * fix: let the blocking unpack own the scratch it writes into * fix: replace a cache directory that is not a package instead of deferring to it * fix: evict by moving a package off the live path, not by deleting it in place * fix: leave a package the sweep cannot move rather than deleting it in place * fix: take one registry snapshot through a cache miss * fix: stamp a pulled package as used so the sweep does not evict it first
783 lines
33 KiB
Rust
783 lines
33 KiB
Rust
//! Where the proxy keeps what it reads out of a package archive.
|
|
//!
|
|
//! A published version's archive never changes, so the files read out of it are cacheable
|
|
//! indefinitely and are worth keeping somewhere other than the API process's heap. They go
|
|
//! to disk, and to the instance object store when one is configured, following the tiering
|
|
//! `windmill-worker`'s dependency cache already uses: local disk first, object store on a
|
|
//! local miss (which then populates disk), the registry on a miss in both.
|
|
//!
|
|
//! Only the manifest and the `.d.ts` files are kept, because those are what the endpoints
|
|
//! serve. Everything else stays reachable by walking the archive on demand.
|
|
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
|
use std::time::SystemTime;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use windmill_common::error::{Error, Result};
|
|
use windmill_common::worker::ROOT_CACHE_DIR;
|
|
|
|
/// Bound on the whole cache directory. A sweep runs after a package is written and removes
|
|
/// least-recently-used package directories until the total is back under it. Overrunning
|
|
/// the disk of a small API container would be a worse failure than the memory this
|
|
/// replaces, so this is a real bound rather than a hint.
|
|
const DISK_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
|
/// Walking the tree costs a `stat` per file, so the sweep is rate limited rather than run
|
|
/// on every write. Bytes written since the last one force it early, or a burst of large
|
|
/// packages could overshoot the cap by the whole interval's worth of writes.
|
|
const SWEEP_INTERVAL_SECS: i64 = 600;
|
|
const SWEEP_AFTER_WRITTEN: u64 = 256 * 1024 * 1024;
|
|
/// Upload parts allowed in flight at once. The unit is parts, not bytes, so this times the
|
|
/// chunk size is what an upload can hold.
|
|
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
|
const UPLOAD_PARTS_IN_FLIGHT: usize = 2;
|
|
/// How long a scratch directory has to sit untouched before the sweep calls it abandoned.
|
|
/// A live writer's scratch is seconds old; deleting one mid-write would publish a package
|
|
/// missing whatever it had already written.
|
|
const SCRATCH_STALE_SECS: u64 = 3600;
|
|
|
|
static LAST_SWEEP: AtomicI64 = AtomicI64::new(0);
|
|
static WRITTEN_SINCE_SWEEP: AtomicU64 = AtomicU64::new(0);
|
|
|
|
const MANIFEST_FILE: &str = "manifest.json";
|
|
const FILES_DIR: &str = "files";
|
|
|
|
/// What `/filetree` answers with, written once beside the files it describes.
|
|
#[derive(Serialize, Deserialize)]
|
|
pub(crate) struct Manifest {
|
|
/// In archive order, `/`-prefixed.
|
|
pub names: Vec<String>,
|
|
/// The package's entry point, from its packaged `package.json`.
|
|
pub main: String,
|
|
}
|
|
|
|
/// Cache root for one package version. Registry-scoped: the `npmrc` setting can be pointed
|
|
/// at a different registry that serves a different artifact under the same name and
|
|
/// version, and the cached files must not outlive that change.
|
|
pub(crate) fn package_dir(registry_url: &str, package: &str, version: &str) -> PathBuf {
|
|
PathBuf::from(&*ROOT_CACHE_DIR)
|
|
.join("npm_proxy")
|
|
.join(registry_key(registry_url))
|
|
// A scoped package carries a `/`, and a version is registry-supplied: neither may
|
|
// introduce a path segment of its own.
|
|
.join(escape_segment(package))
|
|
.join(escape_segment(version))
|
|
}
|
|
|
|
/// Key for the same package version in the object store, which is shared across replicas
|
|
/// and so has to be scoped exactly as the local path is.
|
|
pub(crate) fn object_key(registry_url: &str, package: &str, version: &str) -> String {
|
|
format!(
|
|
"npm_proxy/{}/{}/{}.tar",
|
|
registry_key(registry_url),
|
|
escape_segment(package),
|
|
escape_segment(version)
|
|
)
|
|
}
|
|
|
|
fn registry_key(registry_url: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(registry_url.as_bytes());
|
|
hex::encode(&hasher.finalize()[..8])
|
|
}
|
|
|
|
fn escape_segment(segment: &str) -> String {
|
|
let escaped = segment.replace(['/', '\\'], "%2F");
|
|
// A segment of only dots is a traversal, not a name: `..` would resolve the package
|
|
// directory to its own parent, which the comment above claims cannot happen.
|
|
if escaped.chars().all(|c| c == '.') {
|
|
return escaped.replace('.', "%2E");
|
|
}
|
|
escaped
|
|
}
|
|
|
|
/// Reject anything that would land outside the package directory. Archive entry paths are
|
|
/// registry-supplied, so this is the boundary between "a file in a tarball" and "a path on
|
|
/// our disk".
|
|
fn safe_relative_path(dir: &Path, relative: &str) -> Option<PathBuf> {
|
|
if !is_safe_relative(relative) {
|
|
return None;
|
|
}
|
|
Some(dir.join(FILES_DIR).join(relative))
|
|
}
|
|
|
|
/// Whether an archive entry's path is one we would ever write. Checked before either sink,
|
|
/// not inside one: an archive carrying a traversal is malicious rather than a cache that
|
|
/// happens to be unwritable, and the two must not degrade the same way.
|
|
pub(crate) fn is_safe_relative(relative: &str) -> bool {
|
|
Path::new(relative)
|
|
.components()
|
|
.all(|c| matches!(c, Component::Normal(_)))
|
|
}
|
|
|
|
/// Whether a package directory is complete. The manifest is written last, so its presence
|
|
/// is what distinguishes a published package from anything else at that path.
|
|
pub(crate) async fn has_manifest(dir: &Path) -> bool {
|
|
tokio::fs::metadata(dir.join(MANIFEST_FILE)).await.is_ok()
|
|
}
|
|
|
|
pub(crate) async fn read_manifest(dir: &Path) -> Option<Manifest> {
|
|
let raw = tokio::fs::read(dir.join(MANIFEST_FILE)).await.ok()?;
|
|
serde_json::from_slice(&raw).ok()
|
|
}
|
|
|
|
/// Mark a package as used, so the sweep evicts what is genuinely cold. Best effort: a
|
|
/// cache that cannot be written is still a cache that can be read.
|
|
pub(crate) fn touch(dir: &Path) {
|
|
let manifest = dir.join(MANIFEST_FILE);
|
|
tokio::task::spawn_blocking(move || {
|
|
if let Ok(file) = std::fs::OpenOptions::new().append(true).open(&manifest) {
|
|
let _ = file.set_modified(SystemTime::now());
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(crate) async fn read_file(dir: &Path, relative: &str) -> Option<Vec<u8>> {
|
|
tokio::fs::read(safe_relative_path(dir, relative)?).await.ok()
|
|
}
|
|
|
|
/// A temp directory beside its destination, removed on drop unless it was published. A
|
|
/// failed extraction that left its directory behind would accumulate against the disk cap
|
|
/// without ever being served.
|
|
pub(crate) struct Scratch {
|
|
pub path: PathBuf,
|
|
published: bool,
|
|
}
|
|
|
|
impl Scratch {
|
|
pub(crate) fn beside(dir: &Path) -> Self {
|
|
Self {
|
|
path: PathBuf::from(format!("{}.tmp.{}", dir.display(), uuid::Uuid::new_v4())),
|
|
published: false,
|
|
}
|
|
}
|
|
|
|
fn into_published(mut self) -> PathBuf {
|
|
self.published = true;
|
|
self.path.clone()
|
|
}
|
|
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
if !self.published {
|
|
let _ = std::fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A package version being written. Files land in a sibling temp directory and the whole
|
|
/// thing is published with a rename, so a concurrent reader never sees a half-written
|
|
/// package and a killed process leaves no directory that looks complete.
|
|
///
|
|
/// Synchronous throughout: it is filled from the archive walk, which already runs on a
|
|
/// blocking thread.
|
|
pub(crate) struct PendingPackage {
|
|
scratch: Scratch,
|
|
final_dir: PathBuf,
|
|
}
|
|
|
|
impl PendingPackage {
|
|
pub(crate) fn new(dir: &Path) -> Result<Self> {
|
|
let scratch = Scratch::beside(dir);
|
|
std::fs::create_dir_all(scratch.path.join(FILES_DIR)).map_err(|e| {
|
|
Error::InternalErr(format!("Failed to create {:?}: {e}", scratch.path))
|
|
})?;
|
|
Ok(Self { scratch, final_dir: dir.to_path_buf() })
|
|
}
|
|
|
|
pub(crate) fn write_file(&self, relative: &str, content: &[u8]) -> Result<()> {
|
|
let path = safe_relative_path(&self.scratch.path, relative).ok_or_else(|| {
|
|
Error::BadRequest(format!("Package archive holds an unsafe path: {relative}"))
|
|
})?;
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|e| Error::InternalErr(format!("Failed to create {parent:?}: {e}")))?;
|
|
}
|
|
std::fs::write(&path, content)
|
|
.map_err(|e| Error::InternalErr(format!("Failed to write {path:?}: {e}")))
|
|
}
|
|
|
|
/// Returns the published directory and the manifest's size, which the caller adds to
|
|
/// its write accounting: a package of mostly non-retained entries writes a large file
|
|
/// list and would otherwise advance the sweep trigger by almost nothing.
|
|
pub(crate) fn publish(self, manifest: &Manifest) -> Result<(PathBuf, u64)> {
|
|
let raw = serde_json::to_vec(manifest)
|
|
.map_err(|e| Error::InternalErr(format!("Failed to encode manifest: {e}")))?;
|
|
let manifest_bytes = raw.len() as u64;
|
|
std::fs::write(self.scratch.path.join(MANIFEST_FILE), raw)
|
|
.map_err(|e| Error::InternalErr(format!("Failed to write manifest: {e}")))?;
|
|
|
|
let final_dir = self.final_dir.clone();
|
|
let tmp = self.scratch.into_published();
|
|
publish_dir(&tmp, &final_dir)?;
|
|
Ok((final_dir, manifest_bytes))
|
|
}
|
|
}
|
|
|
|
/// Take a directory off the live cache path in one step, returning a guard that removes it
|
|
/// on drop. `None` means the path could not be moved and still stands.
|
|
///
|
|
/// Deleting a package directory where it stands is not safe to interrupt: `remove_dir_all`
|
|
/// unlinks in readdir order, so a process that stops partway can leave `manifest.json`
|
|
/// standing over files that are already gone. Reads take that for a complete package
|
|
/// forever after, and since the manifest short-circuits the lookup, the object store is
|
|
/// never consulted to repair it. A rename is atomic, so the live path only ever holds a
|
|
/// whole package or nothing, and the detached copy carries the scratch name: if this
|
|
/// process dies before the guard runs, the sweep accounts for it and reclaims it.
|
|
fn detach(dir: &Path) -> Option<Scratch> {
|
|
let aside = Scratch::beside(dir);
|
|
std::fs::rename(dir, &aside.path).ok().map(|()| aside)
|
|
}
|
|
|
|
/// Move a finished tree onto its final path.
|
|
///
|
|
/// A destination that already holds a manifest is another writer publishing the same
|
|
/// immutable content, so theirs wins and this tree is dropped. A destination *without* one
|
|
/// is debris, left by an eviction or a writer killed between the two, and it has to be
|
|
/// replaced rather than deferred to: `rename` refuses a non-empty directory, so treating
|
|
/// the failure as a concurrent publish would discard this valid tree and leave the path
|
|
/// permanently unreadable, with nothing on the read side able to repair it.
|
|
///
|
|
/// The debris is detached rather than deleted in place so the final path is never absent
|
|
/// for longer than a rename.
|
|
fn publish_dir(tmp: &Path, final_dir: &Path) -> Result<()> {
|
|
if let Some(parent) = final_dir.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if std::fs::rename(tmp, final_dir).is_ok() {
|
|
return Ok(());
|
|
}
|
|
if final_dir.join(MANIFEST_FILE).exists() {
|
|
let _ = std::fs::remove_dir_all(tmp);
|
|
return Ok(());
|
|
}
|
|
|
|
let _debris = detach(final_dir);
|
|
match std::fs::rename(tmp, final_dir) {
|
|
Ok(()) => Ok(()),
|
|
// Another writer got there in the window above, which is still the same content.
|
|
Err(_) if final_dir.join(MANIFEST_FILE).exists() => {
|
|
let _ = std::fs::remove_dir_all(tmp);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
let _ = std::fs::remove_dir_all(tmp);
|
|
Err(Error::InternalErr(format!(
|
|
"Failed to publish {final_dir:?}: {e}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pull a package version out of the instance object store into the local cache. `Ok(None)`
|
|
/// means "not there", which is a miss rather than a failure.
|
|
///
|
|
/// The object streams to a temp file and is unpacked from it on a blocking thread: holding
|
|
/// a package's worth of bytes per concurrent miss would put back the request-scaled heap
|
|
/// this module exists to remove, and unpacking is synchronous work whatever its signature
|
|
/// says.
|
|
pub(crate) async fn pull_from_object_store(_dir: &Path, _key: &str) -> Result<Option<u64>> {
|
|
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
|
{
|
|
use futures::StreamExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
let Some(os) = windmill_object_store::get_object_store().await else {
|
|
return Ok(None);
|
|
};
|
|
let path = windmill_object_store::object_store_reexports::Path::from(_key);
|
|
let Ok(result) = os.get(&path).await else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// The temp file is a sibling of the package directory, so its parent has to exist
|
|
// before anything is written: a wiped cache has no tree at all.
|
|
if let Some(parent) = _dir.parent() {
|
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
|
Error::InternalErr(format!("Failed to create {parent:?}: {e}"))
|
|
})?;
|
|
}
|
|
// Both the download and the unpacked tree live inside the scratch directory, so a
|
|
// concurrent pull cannot collide with this one and dropping it takes the download
|
|
// with it. `with_extension` would have replaced the uuid, giving every pull the
|
|
// same path.
|
|
let scratch = Scratch::beside(_dir);
|
|
tokio::fs::create_dir_all(&scratch.path).await.map_err(|e| {
|
|
Error::InternalErr(format!("Failed to create {:?}: {e}", scratch.path))
|
|
})?;
|
|
let tar_path = scratch.path.join("archive.tar");
|
|
let mut file = tokio::fs::File::create(&tar_path)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to create {tar_path:?}: {e}")))?;
|
|
let mut stream = result.into_stream();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk =
|
|
chunk.map_err(|e| Error::InternalErr(format!("Failed to read {_key}: {e}")))?;
|
|
file.write_all(&chunk)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to write {tar_path:?}: {e}")))?;
|
|
}
|
|
file.flush()
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to flush {tar_path:?}: {e}")))?;
|
|
drop(file);
|
|
|
|
// The scratch guard moves into the blocking task: leaving it in the request future
|
|
// means cancelling the request drops it, recursively removing the directory while
|
|
// this is still unpacking into it or renaming out of it.
|
|
let destination = _dir.to_path_buf();
|
|
let published = tokio::task::spawn_blocking(move || -> Result<Option<u64>> {
|
|
let unpack_to = scratch.path.join("content");
|
|
let file = std::fs::File::open(&tar_path)
|
|
.map_err(|e| Error::InternalErr(format!("Failed to open {tar_path:?}: {e}")))?;
|
|
tar::Archive::new(file)
|
|
.unpack(&unpack_to)
|
|
.map_err(|e| Error::InternalErr(format!("Failed to unpack: {e}")))?;
|
|
|
|
// An object that unpacks without a manifest is not a package. Publishing it
|
|
// would make every later pull short-circuit the registry against a tree nothing
|
|
// can read.
|
|
if !unpack_to.join(MANIFEST_FILE).exists() {
|
|
return Ok(None);
|
|
}
|
|
// `unpack` restores the mtimes the archive was built with, so a pulled tree
|
|
// carries the time it was first filled somewhere else. The sweep reads the
|
|
// newest mtime in a package as its recency, which would make a package pulled
|
|
// because something just asked for it the first candidate for eviction.
|
|
if let Ok(file) = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(unpack_to.join(MANIFEST_FILE))
|
|
{
|
|
let _ = file.set_modified(SystemTime::now());
|
|
}
|
|
let unpacked = measure(&unpack_to).map(|(bytes, _)| bytes).unwrap_or(0);
|
|
publish_dir(&unpack_to, &destination)?;
|
|
// The download is still in the scratch directory, which goes with this drop.
|
|
drop(scratch);
|
|
// A miss rather than a hit if the destination is somehow still not a package:
|
|
// reporting a hit here hands the caller a directory whose manifest cannot be
|
|
// read, which is a failed request every time instead of one registry fetch.
|
|
if !destination.join(MANIFEST_FILE).exists() {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(unpacked))
|
|
})
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to unpack {_key}: {e}")))??;
|
|
|
|
if published.is_none() {
|
|
tracing::warn!("{_key} unpacked without a manifest, ignoring it");
|
|
}
|
|
return Ok(published);
|
|
}
|
|
#[allow(unreachable_code)]
|
|
Ok(None)
|
|
}
|
|
|
|
/// Publish a freshly extracted package version to the instance object store so the other
|
|
/// replicas do not each have to fetch it. Best effort throughout: the request it came from
|
|
/// has already been served.
|
|
///
|
|
/// The tar is built into a temp file and uploaded in parts, for the same reason the pull
|
|
/// streams: a package's worth of bytes per concurrent upload is the heap usage this module
|
|
/// exists to remove. One upload at a time, since none of this is on a request's path.
|
|
pub(crate) fn push_to_object_store(_dir: PathBuf, _key: String) {
|
|
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
|
tokio::spawn(async move {
|
|
static UPLOADS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
|
|
let _permit = UPLOADS.acquire().await;
|
|
|
|
if let Err(e) = push_inner(&_dir, &_key).await {
|
|
tracing::warn!("failed to push {_key} to the object store: {e:?}");
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
|
async fn push_inner(dir: &Path, key: &str) -> Result<()> {
|
|
use tokio::io::AsyncReadExt;
|
|
use windmill_object_store::object_store_reexports::WriteMultipart;
|
|
|
|
let Some(os) = windmill_object_store::get_object_store().await else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Inside a scratch directory rather than beside the package: a sibling file survives
|
|
// both the early returns below and the sweep, which only ever walks directories.
|
|
let scratch = Scratch::beside(dir);
|
|
tokio::fs::create_dir_all(&scratch.path)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to create {:?}: {e}", scratch.path)))?;
|
|
let tar_path = scratch.path.join("archive.tar");
|
|
let source = dir.to_path_buf();
|
|
let build_at = tar_path.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let file = std::fs::File::create(&build_at)?;
|
|
let mut tar = tar::Builder::new(file);
|
|
tar.append_dir_all(".", &source)?;
|
|
tar.into_inner().map(|_| ())
|
|
})
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to tar {key}: {e}")))?
|
|
.map_err(|e| Error::InternalErr(format!("Failed to tar {key}: {e}")))?;
|
|
|
|
const CHUNK: usize = 8 * 1024 * 1024;
|
|
let mut file = tokio::fs::File::open(&tar_path)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to open {tar_path:?}: {e}")))?;
|
|
let path = windmill_object_store::object_store_reexports::Path::from(key);
|
|
let mut writer = WriteMultipart::new(
|
|
os.put_multipart(&path)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to start upload of {key}: {e}")))?,
|
|
);
|
|
let mut chunk = vec![0u8; CHUNK];
|
|
loop {
|
|
let read = file
|
|
.read(&mut chunk)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to read {tar_path:?}: {e}")))?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
// Parts in flight, not bytes: `write` queues without waiting, so a slow store
|
|
// would otherwise leave the whole tar outstanding in upload tasks.
|
|
writer
|
|
.wait_for_capacity(UPLOAD_PARTS_IN_FLIGHT)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to upload {key}: {e}")))?;
|
|
writer.write(&chunk[..read]);
|
|
}
|
|
writer
|
|
.finish()
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("Failed to upload {key}: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove least-recently-used package directories until the cache is back under its bound.
|
|
/// Best effort throughout: a cache that cannot be pruned is a reason to log, not to fail a
|
|
/// request that has already been served.
|
|
pub(crate) fn sweep_if_due(written: u64) {
|
|
let pending = WRITTEN_SINCE_SWEEP.fetch_add(written, Ordering::Relaxed) + written;
|
|
let now = chrono::Utc::now().timestamp();
|
|
let last = LAST_SWEEP.load(Ordering::Relaxed);
|
|
if now - last < SWEEP_INTERVAL_SECS && pending < SWEEP_AFTER_WRITTEN {
|
|
return;
|
|
}
|
|
if LAST_SWEEP
|
|
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
|
|
WRITTEN_SINCE_SWEEP.store(0, Ordering::Relaxed);
|
|
tokio::task::spawn_blocking(|| {
|
|
let root = PathBuf::from(&*ROOT_CACHE_DIR).join("npm_proxy");
|
|
if let Err(e) = sweep(&root, DISK_BYTES) {
|
|
tracing::warn!("npm proxy cache sweep failed: {e}");
|
|
}
|
|
});
|
|
}
|
|
|
|
fn sweep(root: &Path, budget: u64) -> std::io::Result<()> {
|
|
if !root.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
// registry/package/version, so package directories sit three levels down. A scratch
|
|
// directory left by a write that died mid-flight will never be read, and counts against
|
|
// the cap until something removes it.
|
|
let mut packages: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
|
|
let mut total = 0u64;
|
|
for registry in child_dirs(root)? {
|
|
for package in child_dirs(®istry)? {
|
|
for version in child_dirs(&package)? {
|
|
// A scratch directory is either a live write, which must not be deleted
|
|
// underneath its writer, or abandoned debris. Never an eviction candidate,
|
|
// but its bytes are on the disk either way and have to count.
|
|
if is_scratch(&version) {
|
|
if is_stale(&version) {
|
|
let _ = std::fs::remove_dir_all(&version);
|
|
} else {
|
|
total += measure(&version).map(|(size, _)| size).unwrap_or(0);
|
|
}
|
|
continue;
|
|
}
|
|
let (size, accessed) = measure(&version)?;
|
|
total += size;
|
|
packages.push((accessed, size, version));
|
|
}
|
|
}
|
|
}
|
|
|
|
if total <= budget {
|
|
return Ok(());
|
|
}
|
|
|
|
packages.sort_by_key(|(accessed, _, _)| *accessed);
|
|
for (_, size, dir) in packages {
|
|
if total <= budget {
|
|
break;
|
|
}
|
|
// Detached, not deleted in place: an eviction is exactly the long unlink that gets
|
|
// interrupted, and one that stops after the declarations but before the manifest
|
|
// leaves a directory every later read trusts and nothing repairs. A candidate that
|
|
// cannot be moved is left whole and skipped, since the cap is a bound this rechecks
|
|
// on a later sweep while a half-deleted package is permanent.
|
|
if let Some(_aside) = detach(&dir) {
|
|
// The guard removes it, off the live path.
|
|
total = total.saturating_sub(size);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Publishing renames, so a directory still carrying the scratch marker is either a write
|
|
/// in flight or one that died. Age is what separates them: concurrent cold fills are
|
|
/// exactly when the sweep is most likely to run, and a live scratch must survive it.
|
|
fn is_scratch(dir: &Path) -> bool {
|
|
// The exact shape `Scratch` writes, not any name containing the marker: a version
|
|
// string is registry-supplied, and one like `1.0.0-alpha.tmp.1` would otherwise be
|
|
// re-fetched forever and never counted against the cap.
|
|
dir.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.and_then(|n| n.rsplit_once(".tmp."))
|
|
.is_some_and(|(_, suffix)| uuid::Uuid::parse_str(suffix).is_ok())
|
|
}
|
|
|
|
fn is_stale(dir: &Path) -> bool {
|
|
std::fs::metadata(dir)
|
|
.and_then(|m| m.modified())
|
|
.map(|at| {
|
|
at.elapsed()
|
|
.map(|since| since.as_secs() > SCRATCH_STALE_SECS)
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn child_dirs(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
|
|
let mut out = Vec::new();
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
if entry.file_type()?.is_dir() {
|
|
out.push(entry.path());
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Space a package directory occupies, and when it was last used. Modification time, not
|
|
/// access time: cache volumes are mounted `relatime` by default, so atime does not move on
|
|
/// a read and an LRU built on it would silently be a FIFO. `touch` is what marks a hit.
|
|
///
|
|
/// Allocated blocks rather than file lengths, because a package of many tiny declarations
|
|
/// costs a block each: `@types/lodash` reports a fraction of what it takes on disk if you
|
|
/// believe its file sizes.
|
|
fn measure(dir: &Path) -> std::io::Result<(u64, SystemTime)> {
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let mut size = 0;
|
|
let mut accessed = SystemTime::UNIX_EPOCH;
|
|
let mut stack = vec![dir.to_path_buf()];
|
|
while let Some(current) = stack.pop() {
|
|
for entry in std::fs::read_dir(¤t)? {
|
|
let entry = entry?;
|
|
let meta = entry.metadata()?;
|
|
#[cfg(unix)]
|
|
{
|
|
size += meta.blocks() * 512;
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
size += meta.len();
|
|
}
|
|
if meta.is_dir() {
|
|
stack.push(entry.path());
|
|
} else if let Ok(at) = meta.modified() {
|
|
accessed = accessed.max(at);
|
|
}
|
|
}
|
|
}
|
|
Ok((size, accessed))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_path_may_not_escape_its_package_directory() {
|
|
let dir = Path::new("/cache/pkg/1.0.0");
|
|
|
|
// the guard the extraction consults before either sink
|
|
assert!(is_safe_relative("types/index.d.ts"));
|
|
assert!(!is_safe_relative("../../../etc/passwd"));
|
|
assert!(!is_safe_relative("/etc/passwd"));
|
|
assert!(!is_safe_relative("types/../../escape.d.ts"));
|
|
|
|
assert!(safe_relative_path(dir, "types/index.d.ts").is_some());
|
|
assert_eq!(safe_relative_path(dir, "../../../etc/passwd"), None);
|
|
assert_eq!(safe_relative_path(dir, "/etc/passwd"), None);
|
|
assert_eq!(safe_relative_path(dir, "types/../../escape.d.ts"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_scoped_package_stays_one_directory() {
|
|
let scoped = package_dir("https://registry.example.com", "@types/node", "20.0.0");
|
|
assert!(scoped.ends_with("@types%2Fnode/20.0.0"));
|
|
}
|
|
|
|
/// A registry that answered for a package called `..` would otherwise resolve the
|
|
/// package directory to its own parent.
|
|
#[test]
|
|
fn a_traversal_cannot_be_a_package_name() {
|
|
let root = package_dir("https://registry.example.com", "lodash", "1.0.0");
|
|
let traversal = package_dir("https://registry.example.com", "..", "..");
|
|
|
|
assert!(traversal.starts_with(root.parent().unwrap().parent().unwrap()));
|
|
assert!(traversal.ends_with("%2E%2E/%2E%2E"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_registry_scopes_the_cache() {
|
|
let one = package_dir("https://one.example.com", "lodash", "4.17.21");
|
|
let two = package_dir("https://two.example.com", "lodash", "4.17.21");
|
|
assert_ne!(one, two);
|
|
assert_ne!(
|
|
object_key("https://one.example.com", "lodash", "4.17.21"),
|
|
object_key("https://two.example.com", "lodash", "4.17.21")
|
|
);
|
|
}
|
|
|
|
/// The cap is what keeps a cache directory from outgrowing a small container disk, so
|
|
/// it has to survive both abandoned writes and a set that is merely over budget.
|
|
#[test]
|
|
fn the_sweep_drops_debris_and_the_least_recently_used() {
|
|
let root = std::env::temp_dir().join(format!("npm-proxy-sweep-{}", uuid::Uuid::new_v4()));
|
|
let make = |package: &str, version: &str, bytes: usize| {
|
|
let dir = root.join("registry").join(package).join(version).join(FILES_DIR);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("index.d.ts"), vec![b'x'; bytes]).unwrap();
|
|
dir
|
|
};
|
|
|
|
// a writer that died mid-flight long enough ago to be abandoned, and two packages
|
|
let debris = format!("1.0.0.tmp.{}", uuid::Uuid::new_v4());
|
|
make("ghost", &debris, 1024);
|
|
let ghost = root.join("registry").join("ghost").join(&debris);
|
|
let long_ago = SystemTime::now() - std::time::Duration::from_secs(SCRATCH_STALE_SECS * 2);
|
|
std::fs::File::open(&ghost)
|
|
.and_then(|f| f.set_modified(long_ago))
|
|
.unwrap();
|
|
let old = make("old", "1.0.0", 64 * 1024);
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
let fresh = make("fresh", "1.0.0", 64 * 1024);
|
|
|
|
sweep(&root, 96 * 1024).unwrap();
|
|
|
|
assert!(!ghost.exists(), "an abandoned scratch directory should be removed");
|
|
// a version is registry-supplied: one shaped like debris is still a package
|
|
assert!(!is_scratch(&root.join("registry").join("pkg").join("1.0.0-alpha.tmp.1")));
|
|
assert!(fresh.exists(), "the more recently used package should survive");
|
|
assert!(!old.exists(), "the least recently used should go first");
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// Concurrent cold fills are exactly when a sweep is most likely to run, and deleting
|
|
/// a scratch directory mid-write would publish a package missing what it had written.
|
|
#[test]
|
|
fn a_scratch_directory_still_being_written_survives_a_sweep() {
|
|
let root = std::env::temp_dir().join(format!("npm-proxy-live-{}", uuid::Uuid::new_v4()));
|
|
let dir = root.join("registry").join("pkg").join("1.0.0");
|
|
let pending = PendingPackage::new(&dir).unwrap();
|
|
pending.write_file("index.d.ts", b"declare const x: number").unwrap();
|
|
|
|
sweep(&root, 0).unwrap();
|
|
|
|
assert!(pending.scratch.path.exists(), "a live writer's scratch must survive");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// The live path holds a whole package or nothing. A directory recursively deleted where
|
|
/// it stands passes through states that are neither, and the one where the manifest
|
|
/// outlives the files it describes is read as valid for as long as the cache exists.
|
|
#[test]
|
|
fn a_package_leaves_the_live_path_in_one_step() {
|
|
let root = std::env::temp_dir().join(format!("npm-proxy-{}", uuid::Uuid::new_v4()));
|
|
let dir = root.join("pkg").join("1.0.0");
|
|
std::fs::create_dir_all(dir.join(FILES_DIR)).unwrap();
|
|
std::fs::write(dir.join(FILES_DIR).join("index.d.ts"), b"declare const x: number").unwrap();
|
|
std::fs::write(dir.join(MANIFEST_FILE), b"{}").unwrap();
|
|
|
|
let aside = detach(&dir).expect("a package directory can be detached");
|
|
|
|
assert!(!dir.exists(), "the live path is gone the moment it is detached");
|
|
assert!(aside.path.join(MANIFEST_FILE).exists(), "the whole tree went with it");
|
|
assert!(is_scratch(&aside.path), "and dying here leaves debris the sweep reclaims");
|
|
let detached = aside.path.clone();
|
|
drop(aside);
|
|
assert!(!detached.exists(), "the guard removes it");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// An eviction killed partway through leaves a directory that is not a package. Reads
|
|
/// skip it, so only a publish can clear it, and `rename` refuses a non-empty
|
|
/// destination: deferring to it instead would discard every tree published afterwards
|
|
/// and leave the path unreadable for good.
|
|
#[test]
|
|
fn a_destination_left_without_a_manifest_is_replaced() {
|
|
let root = std::env::temp_dir().join(format!("npm-proxy-{}", uuid::Uuid::new_v4()));
|
|
let dir = root.join("pkg").join("1.0.0");
|
|
std::fs::create_dir_all(dir.join(FILES_DIR)).unwrap();
|
|
std::fs::write(dir.join(FILES_DIR).join("half-deleted.d.ts"), b"stale").unwrap();
|
|
|
|
let pending = PendingPackage::new(&dir).unwrap();
|
|
pending.write_file("index.d.ts", b"declare const x: number").unwrap();
|
|
pending
|
|
.publish(&Manifest { names: vec!["/index.d.ts".into()], main: "index.js".into() })
|
|
.unwrap();
|
|
|
|
assert!(dir.join(MANIFEST_FILE).exists(), "the valid tree must win");
|
|
assert!(dir.join(FILES_DIR).join("index.d.ts").exists());
|
|
assert!(!dir.join(FILES_DIR).join("half-deleted.d.ts").exists(), "debris must go");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn a_package_is_published_whole_or_not_at_all() {
|
|
let root = std::env::temp_dir().join(format!("npm-proxy-{}", uuid::Uuid::new_v4()));
|
|
let dir = root.join("pkg").join("1.0.0");
|
|
|
|
let pending = PendingPackage::new(&dir).unwrap();
|
|
pending.write_file("types/index.d.ts", b"declare const x: number").unwrap();
|
|
// nothing is visible at the final path until publish
|
|
assert!(!dir.exists());
|
|
|
|
pending
|
|
.publish(&Manifest { names: vec!["/types/index.d.ts".into()], main: "index.js".into() })
|
|
.unwrap();
|
|
assert!(dir.join(MANIFEST_FILE).exists());
|
|
assert!(dir.join(FILES_DIR).join("types/index.d.ts").exists());
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsafe_path_is_refused_rather_than_written() {
|
|
let root = std::env::temp_dir().join(format!("npm-proxy-{}", uuid::Uuid::new_v4()));
|
|
let pending = PendingPackage::new(&root.join("pkg").join("1.0.0")).unwrap();
|
|
|
|
assert!(pending.write_file("../../escape.d.ts", b"x").is_err());
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
}
|