//! Anchored no-follow filesystem primitives for npm storage. //! //! All descents start from an open directory handle and never follow //! symlinks; regular files are confirmed with `fstat` on the opened file. use std::io::{self, Read, Write}; use std::path::{Component, Path}; use std::sync::atomic::{AtomicU64, Ordering}; use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt}; #[cfg(unix)] use cap_std::fs::Permissions; use cap_std::fs::{Dir, File, OpenOptions}; // The configured anchor may contain system symlinks (e.g. macOS /tmp). // Below that anchor, all traversal must use no-follow handles. pub fn open_root(path: &Path) -> io::Result { if !path.is_absolute() { return Err(io::Error::other("npm path must be absolute and canonical")); } if path .components() .any(|part| matches!(part, Component::CurDir | Component::ParentDir)) { return Err(io::Error::other("npm path must be absolute and canonical")); } Dir::open_ambient_dir(path, cap_std::ambient_authority()) } /// Descend normal components without following symlinks. pub fn walk(root: &Dir, rel: &Path) -> io::Result { let mut directory = root.try_clone()?; for part in rel.components() { let Component::Normal(name) = part else { return Err(io::Error::other("npm path escapes its anchor")); }; directory = directory.open_dir_nofollow(name)?; } Ok(directory) } /// Descend, creating missing directories. Present non-directories fail. pub fn mkdirs(root: &Dir, rel: &Path) -> io::Result { let mut directory = root.try_clone()?; for part in rel.components() { let Component::Normal(name) = part else { return Err(io::Error::other("npm path escapes its anchor")); }; directory = match directory.open_dir_nofollow(name) { Ok(child) => child, Err(error) if error.kind() == io::ErrorKind::NotFound => { match directory.create_dir(name) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } directory.open_dir_nofollow(name)? } Err(error) => return Err(error), }; } Ok(directory) } fn single_component(name: &str) -> io::Result<()> { if Path::new(name).components().count() != 1 || !matches!( Path::new(name).components().next(), Some(Component::Normal(_)) ) { return Err(io::Error::other( "npm file name must be one normal component", )); } Ok(()) } /// Open one file for reading without following symlinks. Non-blocking mode /// keeps FIFOs from hanging the caller; [`read_bounded`] rejects them. pub fn open_file(directory: &Dir, name: &str) -> io::Result { single_component(name)?; let mut options = OpenOptions::new(); options.read(true).follow(FollowSymlinks::No).nonblock(true); directory.open_with(name, &options) } /// Read at most `max + 1` bytes after confirming a regular file via `fstat`. pub fn read_bounded(file: &File, max: u64, what: &str) -> io::Result> { let metadata = file.metadata()?; if !metadata.is_file() { return Err(io::Error::other(format!("{what} is not a regular file"))); } // Bounded readers otherwise grow from tiny buffers, even for known-size files. let capacity = usize::try_from(metadata.len().min(max)).unwrap_or(0); let mut bytes = Vec::with_capacity(capacity); file.take(max + 1).read_to_end(&mut bytes)?; if bytes.len() as u64 > max { return Err(io::Error::other(format!("{what} is too large"))); } Ok(bytes) } /// Create a new file, failing if anything already exists there. pub fn write_new(directory: &Dir, name: &str, bytes: &[u8]) -> io::Result { let mut options = OpenOptions::new(); options .write(true) .create_new(true) .follow(FollowSymlinks::No); let mut file = directory.open_with(name, &options)?; file.write_all(bytes)?; Ok(file) } /// Atomically replace or create a file via a staged sibling plus rename. pub fn write_atomic(directory: &Dir, name: &str, bytes: &[u8]) -> io::Result<()> { for _ in 0..8 { let staging = unique_tmp(); let staged = (|| -> io::Result<()> { let file = write_new(directory, &staging, bytes)?; set_readable(&file)?; file.sync_all()?; directory.rename(&staging, directory, name) })(); match staged { Ok(()) => return Ok(()), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => { remove_all(directory, &staging); return Err(error); } } } Err(io::Error::other("npm staging collided repeatedly")) } /// Best-effort staging cleanup. pub fn remove_all(directory: &Dir, name: &str) { let _ = directory.remove_dir_all(name); let _ = directory.remove_file(name); } #[cfg(unix)] pub fn set_readable(file: &File) -> io::Result<()> { use std::os::unix::fs::PermissionsExt; file.set_permissions(Permissions::from_std(std::fs::Permissions::from_mode( 0o644, ))) } #[cfg(not(unix))] pub fn set_readable(_file: &File) -> io::Result<()> { Ok(()) } /// Random staging name; callers use create-new semantics and retry, so a /// collision (or stale entry after PID reuse) fails closed instead of /// overwriting. pub fn unique_tmp() -> String { let mut buf = [0u8; 8]; if getrandom::fill(&mut buf).is_ok() { return format!(".tmp.{:016x}", u64::from_ne_bytes(buf)); } static COUNTER: AtomicU64 = AtomicU64::new(0); let id = COUNTER.fetch_add(1, Ordering::Relaxed); format!(".tmp.{}-{id}", std::process::id()) } /// Advisory coordination only; published content still needs verification. pub struct Guard { _file: std::fs::File, } /// Open (creating) a guard file without following symlinks and lock it. /// Non-blocking open plus a regular-file check keeps FIFOs from hanging. pub fn lock_file(directory: &Dir, name: &str) -> io::Result { single_component(name)?; let mut options = OpenOptions::new(); options .write(true) .create(true) .follow(FollowSymlinks::No) .nonblock(true); let file = directory.open_with(name, &options)?; if !file.metadata()?.is_file() { return Err(io::Error::other("npm lock is not a regular file")); } let std_file = file.into_std(); std_file.lock()?; Ok(Guard { _file: std_file }) } #[cfg(test)] mod tests { use super::*; #[test] fn bounded_reads_preserve_bytes_and_enforce_limits() { let temp = tempfile::tempdir().unwrap(); let root = open_root(temp.path()).unwrap(); for length in [0usize, 1, 32, 4096, 65537] { let expected: Vec = (0..length).map(|i| (i % 251) as u8).collect(); std::fs::write(temp.path().join("data"), &expected).unwrap(); for max in [0, length.saturating_sub(1), length, length + 1] { let file = open_file(&root, "data").unwrap(); let result = read_bounded(&file, max as u64, "data"); if length <= max { assert_eq!(result.unwrap(), expected); } else { assert!(result.unwrap_err().to_string().contains("too large")); } } } let sparse = std::fs::File::create(temp.path().join("sparse")).unwrap(); sparse.set_len(1 << 30).unwrap(); let file = open_file(&root, "sparse").unwrap(); assert!(read_bounded(&file, 64, "sparse").is_err()); } }