From 2c94b7fa1f4de14522993110ad4286bb952ce2b4 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 5 Oct 2026 23:51:43 -0700 Subject: [PATCH 1/2] Export: protect originals imported through differently named symlinks Co-Authored-By: OpenAI Codex --- crates/engine/src/originals.rs | 11 +++++++++++ crates/engine/src/tests_export.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 59a5f6a2..d20d2fe8 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -58,6 +58,17 @@ impl OriginalGuard { } } } + // An imported symlink can have a different name from its original. A write to + // that original would change what the library reads through the link. Only do + // this fallback for existing targets that the name index did not protect. + for (p, what) in self.by_name.values().flatten() { + if std::fs::read_link(p).is_ok() && same_file(p, target) { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + } Ok(()) } } diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index dcd74cb4..42260133 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -323,3 +323,29 @@ fn a_failed_export_write_keeps_the_previous_file() { assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn export_protects_the_target_of_an_imported_symlink() { + use crate::export::{Conflict, Destination}; + let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original"); + drop(original_session); + let alias = dir.join("alias.jpg"); + std::os::unix::fs::symlink(&src, &alias).unwrap(); + let sidecar = dir.join("metadata.xmp"); + std::fs::write(&sidecar, b"").unwrap(); + std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap(); + let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() }; + let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) }; + let result = disk_batch(&mut s, id, &o, &to); + assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err()); + assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(&sidecar).unwrap(), b""); + let _ = std::fs::remove_dir_all(&dir); +} From 51f0ef25ae7a2b5958e7ad83afcfabb646226f07 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 05:41:28 -0400 Subject: [PATCH 2/2] Export: look up the library's symlinked originals once per guard The symlink fallback ran read_link on every library entry for each existing target; a large library made every Overwrite export check do thousands of syscalls. The guard now finds the links on first need and keeps them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/originals.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 130d22a2..b3a140a2 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source}; pub struct OriginalGuard { /// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …) by_name: HashMap>, + /// The entries of `by_name` that are symlinks, found on the first check that needs them. + links: std::sync::OnceLock>, } impl OriginalGuard { @@ -32,6 +34,7 @@ impl OriginalGuard { /// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`). pub fn add(&mut self, path: &Path, label: &str) { let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return }; + self.links = std::sync::OnceLock::new(); let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what)); put(name.clone(), path.to_path_buf(), format!("the original of {label}")); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); @@ -60,9 +63,11 @@ impl OriginalGuard { } // An imported symlink can have a different name from its original. A write to // that original would change what the library reads through the link. Only do - // this fallback for existing targets that the name index did not protect. - for (p, what) in self.by_name.values().flatten() { - if std::fs::read_link(p).is_ok() && same_file(p, target) { + // this fallback for existing targets that the name index did not protect; the links are + // looked up once per guard, not once per target. + let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect()); + for (p, what) in links { + if same_file(p, target) { return Err(format!( "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", target.display()