From 7ebbb17a8252291229386a4e1d31214467a10c10 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:31:09 -0700 Subject: [PATCH] Originals: on Windows a hard link to an original counts as that original safe_file::same_file compared length and canonical paths on every platform other than Unix. On Windows canonicalize returns a hard link's own path, so a hard link to a library original under another name was not the same file, and pin_write_target accepted it as a screenshot destination. For this reason the engine test write_targets_pin_absent_paths_and_recheck_the_current_library failed on Windows. On Windows same_file now first compares the volume serial number and file index through the same-file crate (already in Cargo.lock through walkdir), then falls back to the resolved-path comparison. The catalog crate takes same-file as a Windows-only dependency. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/catalog/Cargo.toml | 3 +++ crates/catalog/src/safe_file.rs | 26 +++++++++++++++++++++++++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b69533958..455758fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ dependencies = [ "lightcraft-raw", "log", "proptest", + "same-file", "serde", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 5c0418d30..fc2d90d2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" +same-file = "1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 27273d5c4..e9df63166 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -20,6 +20,9 @@ log = { workspace = true } chrono = { workspace = true } web-time = { workspace = true } +[target.'cfg(windows)'.dependencies] +same-file = { workspace = true } + [dev-dependencies] lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index fa067de0f..83139756c 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -23,6 +23,7 @@ //! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard //! links, an exclusive create that claims the name, then a rename over that placeholder). //! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix, +//! volume serial number + file index on Windows, //! resolved paths elsewhere), for "is this target an original?" checks. use std::cell::Cell; @@ -267,7 +268,8 @@ fn same_content(path: &Path, data: &[u8]) -> io::Result { } } -/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)? +/// Do `a` and `b` name the same existing file (through hard links, symlinks, `..`, +/// case-insensitive names)? pub fn same_file(a: &Path, b: &Path) -> bool { let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; #[cfg(unix)] @@ -277,6 +279,14 @@ pub fn same_file(a: &Path, b: &Path) -> bool { } #[cfg(not(unix))] { + // A hard link has its own path but shares the volume serial number and file index with + // its original. A volume that does not report them is compared by resolved path below. + #[cfg(windows)] + { + if ::same_file::is_same_file(a, b).unwrap_or(false) { + return true; + } + } if ma.len() != mb.len() { return false; } @@ -397,6 +407,20 @@ mod tests { let _ = fs::remove_dir_all(&d); } + #[test] + fn same_file_sees_a_hard_link_under_another_name() { + let d = temp("same-link"); + let p = d.join("IMG_1.jpg"); + fs::write(&p, b"original").unwrap(); + let linked = d.join("linked.png"); + fs::hard_link(&p, &linked).unwrap(); + fs::write(d.join("copy.png"), b"original").unwrap(); + assert!(same_file(&p, &linked), "a hard link is the same file"); + assert!(same_file(&linked, &p)); + assert!(!same_file(&p, &d.join("copy.png")), "a copy with the same content and length is another file"); + let _ = fs::remove_dir_all(&d); + } + #[test] fn same_file_sees_through_other_spellings() { let d = temp("same");