Follow symbolic links without canonicalize

The target is found by reading links one hop at a time, resolving
relative links against the link's folder. A dangling link writes the
file it names and stays a link, and a loop of links is an error.
This commit is contained in:
aiongg authored and skylar taylor-barrick committed 2026-10-12 20:23:17 +02:00
1 parent fb26a9f90f
commit 658ca37c8e
2 files changed
+50 -7

No files matched your search

+22 -6
View File
@@ -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<PathBuf> {
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`.
+28 -1
View File
@@ -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);
}