//! 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());
}
}