Merge pull request #152 from storybored379/fix/symlink-original-guard

Export: protect originals imported through differently named symlinks
This commit is contained in:
Brandon Thomas 2026-10-07 06:01:18 -04:00 committed by GitHub
commit 9ef83ae3ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 42 additions and 0 deletions

View File

@ -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<String, Vec<(PathBuf, String)>>,
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
}
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();
@ -58,6 +61,19 @@ 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; 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()
));
}
}
Ok(())
}
}

View File

@ -324,6 +324,32 @@ fn a_failed_export_write_keeps_the_previous_file() {
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"<x/>").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"<x/>");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #134: exports can always be made again, so they are written atomically but not synced to
/// disk file by file; an edit copy that becomes a library photo still is.
#[test]