use std::io::{Read, Write};
use std::path::Path;
pub const MAX_CONFIG_ANCESTOR_DEPTH: usize = 64;
pub fn config_ancestors(start_dir: &Path) -> impl Iterator<Item = &Path> {
std::iter::successors(Some(start_dir), |d| d.parent()).take(MAX_CONFIG_ANCESTOR_DEPTH)
}
#[cfg(any(test, feature = "test-util"))]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(any(test, feature = "test-util"))]
static STAT_COUNT: AtomicUsize = AtomicUsize::new(0);
#[cfg(any(test, feature = "test-util"))]
static READ_COUNT: AtomicUsize = AtomicUsize::new(0);
pub fn metadata(path: &Path) -> std::io::Result<std::fs::Metadata> {
#[cfg(any(test, feature = "test-util"))]
STAT_COUNT.fetch_add(1, Ordering::Relaxed);
std::fs::metadata(path)
}
fn read_capped(file: std::fs::File, max_bytes: u64) -> std::io::Result<Option<String>> {
let mut buf = Vec::new();
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut buf)?;
if buf.len() as u64 > max_bytes {
return Ok(None);
}
String::from_utf8(buf)
.map(Some)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.utf8_error()))
}
pub fn read_to_string_capped(path: &Path, max_bytes: u64) -> std::io::Result<Option<String>> {
#[cfg(any(test, feature = "test-util"))]
READ_COUNT.fetch_add(1, Ordering::Relaxed);
let file = std::fs::File::open(path)?;
read_capped(file, max_bytes)
}
pub fn read_to_string_capped_no_follow(
path: &Path,
max_bytes: u64,
) -> std::io::Result<Option<String>> {
#[cfg(any(test, feature = "test-util"))]
READ_COUNT.fetch_add(1, Ordering::Relaxed);
let file = open_no_follow(path)?;
read_capped(file, max_bytes)
}
fn symlink_refused_error(path: &Path) -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to read through a symlinked path: {}",
path.display()
),
)
}
#[cfg(unix)]
fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|error| {
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
symlink_refused_error(path)
} else {
error
}
})
}
#[cfg(not(unix))]
fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(symlink_refused_error(path));
}
std::fs::File::open(path)
}
pub fn write_atomic(path: &Path, content: &str) -> std::io::Result<()> {
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to write through a symlinked manifest path: {}",
path.display()
),
));
}
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"manifest path has no file name",
)
})?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let tmp_path = dir.join(format!(
"{}.deps-cli-{}-{nanos}.tmp",
file_name.to_string_lossy(),
std::process::id(),
));
let mut open_options = std::fs::OpenOptions::new();
open_options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open_options.mode(0o600);
}
let mut file = open_options.open(&tmp_path)?;
let write_result = write_atomic_content(&mut file, path, content);
drop(file);
match write_result {
Ok(()) => {
if let Err(error) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
Err(error)
} else {
Ok(())
}
}
Err(error) => {
let _ = std::fs::remove_file(&tmp_path);
Err(error)
}
}
}
fn write_atomic_content(
file: &mut std::fs::File,
original_path: &Path,
content: &str,
) -> std::io::Result<()> {
#[cfg(unix)]
if let Ok(original_meta) = std::fs::metadata(original_path) {
file.set_permissions(original_meta.permissions())?;
}
#[cfg(not(unix))]
let _ = original_path;
file.write_all(content.as_bytes())?;
file.sync_all()
}
#[must_use]
pub fn is_file(path: &Path) -> bool {
metadata(path).is_ok_and(|m| m.is_file())
}
#[must_use]
pub fn exists(path: &Path) -> bool {
metadata(path).is_ok()
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn snapshot() -> (usize, usize) {
(
STAT_COUNT.load(Ordering::Relaxed),
READ_COUNT.load(Ordering::Relaxed),
)
}
#[cfg(any(test, feature = "test-util"))]
static SNAPSHOT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(any(test, feature = "test-util"))]
#[must_use = "dropping the guard immediately releases it, guarding nothing — bind it to a \
named variable (e.g. `let _guard = snapshot_guard();`) held for the whole \
measured section"]
pub fn snapshot_guard() -> tokio::sync::MutexGuard<'static, ()> {
SNAPSHOT_LOCK.blocking_lock()
}
#[cfg(any(test, feature = "test-util"))]
#[must_use = "dropping the guard immediately releases it, guarding nothing — bind it to a \
named variable (e.g. `let _guard = snapshot_guard_async().await;`) held for the \
whole measured section"]
pub async fn snapshot_guard_async() -> tokio::sync::MutexGuard<'static, ()> {
SNAPSHOT_LOCK.lock().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_to_string_capped_returns_content_under_cap() {
let _guard = snapshot_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small.txt");
std::fs::write(&path, "hello").unwrap();
assert_eq!(
read_to_string_capped(&path, 1024).unwrap().as_deref(),
Some("hello")
);
}
#[test]
fn read_to_string_capped_accepts_content_exactly_at_cap() {
let _guard = snapshot_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("exact.txt");
std::fs::write(&path, "abcde").unwrap();
assert_eq!(
read_to_string_capped(&path, 5).unwrap().as_deref(),
Some("abcde")
);
}
#[test]
fn read_to_string_capped_rejects_content_over_cap() {
let _guard = snapshot_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("over.txt");
std::fs::write(&path, "abcdef").unwrap();
assert_eq!(read_to_string_capped(&path, 5).unwrap(), None);
}
#[test]
fn read_to_string_capped_missing_path_errors() {
let _guard = snapshot_guard();
assert!(read_to_string_capped(Path::new("/nonexistent/path/file.txt"), 1024).is_err());
}
#[test]
fn write_atomic_replaces_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "old content").unwrap();
write_atomic(&path, "new content").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name() != "Cargo.toml")
.collect();
assert!(
leftover.is_empty(),
"temp file must be renamed away, not left behind"
);
}
#[test]
fn write_atomic_creates_new_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
write_atomic(&path, "content").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
}
#[test]
fn read_to_string_capped_no_follow_reads_a_plain_file() {
let _guard = snapshot_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "content").unwrap();
assert_eq!(
read_to_string_capped_no_follow(&path, 1000).unwrap(),
Some("content".to_string())
);
}
#[cfg(unix)]
#[test]
fn read_to_string_capped_no_follow_refuses_a_symlinked_path() {
use std::os::unix::fs::symlink;
let _guard = snapshot_guard();
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real.toml");
std::fs::write(&target, "real content").unwrap();
let link = dir.path().join("Cargo.toml");
symlink(&target, &link).unwrap();
let result = read_to_string_capped_no_follow(&link, 1000);
assert!(result.is_err(), "a symlinked path must be refused");
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
#[cfg(unix)]
#[test]
fn write_atomic_refuses_symlinked_manifest_path() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real.toml");
std::fs::write(&target, "real content").unwrap();
let link = dir.path().join("Cargo.toml");
symlink(&target, &link).unwrap();
let result = write_atomic(&link, "attacker content");
assert!(result.is_err(), "a symlinked manifest path must be refused");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "real content");
assert!(
std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink(),
"the symlink itself must be left untouched"
);
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name();
name != "real.toml" && name != "Cargo.toml"
})
.collect();
assert!(
leftover.is_empty(),
"no temp file must be created before refusal"
);
}
#[cfg(unix)]
#[test]
fn write_atomic_preserves_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "old content").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&path, "new content").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"the original file's mode must survive the atomic rewrite"
);
}
#[cfg(unix)]
#[test]
fn write_atomic_widens_permissions_when_original_is_wider_than_temp_default() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "old content").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
write_atomic(&path, "new content").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o644,
"the original file's wider mode must be restored, not left at the temp file's \
narrower creation-time mode"
);
}
#[cfg(unix)]
#[test]
fn write_atomic_original_content_untouched_on_temp_create_failure() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "original content").unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let result = write_atomic(&path, "new content");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "original content");
}
#[cfg(unix)]
#[test]
fn fs_rename_replaces_a_target_side_symlink_rather_than_its_target() {
let dir = tempfile::tempdir().unwrap();
let real_target = dir.path().join("real_target.txt");
std::fs::write(&real_target, "target content").unwrap();
let symlink_path = dir.path().join("symlink.txt");
std::os::unix::fs::symlink(&real_target, &symlink_path).unwrap();
let new_path = dir.path().join("new_content.txt");
std::fs::write(&new_path, "new content").unwrap();
std::fs::rename(&new_path, &symlink_path).unwrap();
assert!(
!std::fs::symlink_metadata(&symlink_path)
.unwrap()
.file_type()
.is_symlink(),
"the symlink itself must be replaced by a regular file"
);
assert_eq!(
std::fs::read_to_string(&symlink_path).unwrap(),
"new content"
);
assert_eq!(
std::fs::read_to_string(&real_target).unwrap(),
"target content",
"the symlink's original target must never be written through"
);
}
}