mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Issue #93: Export: never overwrite an original; atomic writes
- lightcraft_catalog::safe_file: one tested helper for crash-safe writes (temp file in the same folder, sync, rename, folder sync; temp removed on failure), write_new/write_new_unique that never replace a file and read the result back, same_file, and a per-thread fault injection for tests. - engine::originals::OriginalGuard: refuses a write whose target is a catalogued photo's original or XMP sidecar (by file identity), whatever the conflict policy or exact path. Used by run_batch (export, Export with Previous, control/MCP exact paths), ui.render/ui.screenshot paths, merge previewPath, MCP render_photo/screenshot paths (new query command export.checkTarget) and lightcraft-cli render. - The conflict policy now covers an export's sidecars together with the file (Unique gives both the same free name, Skip skips when either is taken). - export::write_file replaces the plain std::fs::write in the desktop app, CLI, MCP and engine writers; XMP sidecar writes use the shared helper. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e99aa61fc4
commit
46732cb0fe
19 files changed
+731
-81
No files matched your search
@@ -533,11 +533,17 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?;
|
||||
for (ext, bytes) in &e.sidecars {
|
||||
let sc = Path::new(&output).with_extension(ext);
|
||||
std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?;
|
||||
eprintln!("lightcraft-cli: wrote {}", sc.display());
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect();
|
||||
let guard = s.original_guard();
|
||||
for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) {
|
||||
guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?;
|
||||
}
|
||||
lightcraft_engine::export::write_file(&output, &e.bytes)?;
|
||||
for (sc, bytes) in &sidecars {
|
||||
lightcraft_engine::export::write_file(sc, bytes)?;
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
Ok(())
|
||||
@@ -593,18 +599,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let services = lightcraft_ui_egui::Services {
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
|
||||
})),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| format!("{p}: {e}"))
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
|
||||
@@ -86,6 +86,24 @@ fn render_subcommand() {
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
|
||||
}
|
||||
|
||||
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
|
||||
#[test]
|
||||
fn render_refuses_to_overwrite_its_input() {
|
||||
let input = tmp("self-in.png");
|
||||
gradient_png(&input);
|
||||
let before = std::fs::read(&input).unwrap();
|
||||
let dir = input.parent().unwrap();
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let other_spelling = dir.join("sub/../self-in.png");
|
||||
for out in [&input, &other_spelling] {
|
||||
let o =
|
||||
Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
assert_eq!(std::fs::read(&input).unwrap(), before, "the input is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_export_options() {
|
||||
let input = tmp("o-in.png");
|
||||
|
||||
Reference in new issue
Block a user