diff --git a/crates/atomic/src/lib.rs b/crates/atomic/src/lib.rs index 79f8aec8..7506d58f 100644 --- a/crates/atomic/src/lib.rs +++ b/crates/atomic/src/lib.rs @@ -154,7 +154,7 @@ pub fn atomic_write_custom(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, b /// The replace path behind every `atomic_write*` function: stage the new content beside the /// target, then rename it over the target. fn replace(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { - let target = resolve_symlink(path); + let target = resolve_links(path)?; let (dir, name) = split(&target, path)?; let existed = target.exists(); @@ -236,7 +236,7 @@ pub fn write_new_custom(fs: &dyn AtomicFs, path: &Path, bytes: &[u8]) -> io::Res /// then give it the target's name with a hard link, which fails rather than replace a file that /// appeared in the meantime. Volumes without hard links get a copy into a newly created file. fn create(fs: &dyn AtomicFs, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { - let target = resolve_symlink(path); + let target = resolve_links(path)?; let (dir, name) = split(&target, path)?; if target.exists() { return Err(write_error(io::Error::from(io::ErrorKind::AlreadyExists), path, &dir, true)); @@ -296,11 +296,27 @@ fn sleep_ms(ms: u64) { #[cfg(target_arch = "wasm32")] fn sleep_ms(_ms: u64) {} -fn resolve_symlink(path: &Path) -> PathBuf { - match std::fs::symlink_metadata(path) { - Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), - _ => path.to_path_buf(), +/// Symbolic links followed before giving up (a loop, or an absurd chain). +const MAX_LINKS: usize = 40; + +/// The file a chain of symbolic links ends at (`path` itself when it is not a link), so the link +/// is kept and the file it names is written. A dangling link resolves to the missing file it +/// names, which is then created. +fn resolve_links(path: &Path) -> io::Result { + let mut p = path.to_path_buf(); + for _ in 0..MAX_LINKS { + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_symlink() => { + let to = std::fs::read_link(&p).map_err(|e| io::Error::new(e.kind(), format!("cannot save {}: {e}", path.display())))?; + p = match p.parent() { + Some(dir) if to.is_relative() => dir.join(to), + _ => to, + }; + } + _ => return Ok(p), + } } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("cannot save {}: too many levels of symbolic links", path.display()))) } /// Generates a unique, hidden temporary file name beside `file`. diff --git a/crates/atomic/tests/atomic_tests.rs b/crates/atomic/tests/atomic_tests.rs index 0fcdf17e..8f9f3323 100644 --- a/crates/atomic/tests/atomic_tests.rs +++ b/crates/atomic/tests/atomic_tests.rs @@ -1,4 +1,4 @@ -use craft_atomic::{atomic_write, atomic_write_with, or_plain_fsync, write_new_with}; +use craft_atomic::{atomic_write, atomic_write_with, or_plain_fsync, write_new, write_new_with}; use std::cell::Cell; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -125,3 +125,30 @@ fn mode_and_symlink_preservation() { assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false)); assert_eq!(std::fs::read(&real).ok(), Some(b"through symlink".to_vec())); } + +#[cfg(unix)] +#[test] +fn dangling_relative_symlink_stays_a_link() { + let d = TempDir::new("dangling"); + std::fs::create_dir(d.0.join("real")).ok(); + let link = d.0.join("link.txt"); + std::os::unix::fs::symlink("real/doc.txt", &link).ok(); + + assert!(atomic_write(&link, b"created through the link").is_ok()); + assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false)); + assert_eq!(std::fs::read(d.0.join("real/doc.txt")).ok(), Some(b"created through the link".to_vec())); + assert_no_temp(&d.0); + assert_no_temp(&d.0.join("real")); +} + +#[cfg(unix)] +#[test] +fn symlink_loop_is_an_error() { + let d = TempDir::new("loop"); + std::os::unix::fs::symlink("b", d.0.join("a")).ok(); + std::os::unix::fs::symlink("a", d.0.join("b")).ok(); + assert!(atomic_write(&d.0.join("a"), b"x").is_err_and(|e| e.to_string().starts_with("cannot save") && e.to_string().contains("symbolic links"))); + assert!(write_new(&d.0.join("a"), b"x").is_err()); + assert!(std::fs::symlink_metadata(d.0.join("a")).map(|m| m.file_type().is_symlink()).unwrap_or(false)); + assert_no_temp(&d.0); +}