From b9640ef1d58e17e96feb2323daa106c6f07f2c21 Mon Sep 17 00:00:00 2001 From: GSibille <263344878+G6bill@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:35:53 +0200 Subject: [PATCH 1/2] #1944: keyword.export refuses library originals and writes durably Export Keywords wrote with std::fs::write to any path, replacing a library original or its XMP sidecar. It now goes through check_write_target and write_file_durable like preset.export and curve.exportPresets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/engine/src/cmd/keywords.rs | 3 ++- crates/engine/src/tests_export.rs | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index fe09a92f9..7858a19a8 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -445,7 +445,8 @@ pub fn specs() -> Vec { let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable}); match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) { Some(path) => { - std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + s.check_write_target(path).map_err(|e| bad("keyword.export", e))?; + crate::export::write_file_durable(path, file.text.as_bytes()).map_err(crate::EngineError::Other)?; r["path"] = json!(path); } None => r["text"] = json!(file.text), diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 2f1a05116..0a577b883 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -276,6 +276,30 @@ fn curve_preset_export_never_overwrites_originals_or_sidecars() { preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard"); } +/// #1944: Export Keywords went straight to `std::fs::write`, replacing a library original. +#[test] +fn keyword_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("keyword.export", json!({}), "keyword-export-guard"); +} + +#[test] +fn keyword_export_preserves_the_previous_file_on_write_failure() { + let dir = temp_dir("keyword-export-failure"); + let mut s = Session::with_demo(); + let path = dir.join("keywords.txt"); + let params = json!({"path": path.to_string_lossy()}); + s.execute("keyword.export", ¶ms).unwrap(); + let previous = std::fs::read(&path).unwrap(); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(4); + assert!(s.execute("keyword.export", ¶ms).is_err(), "keyword.export must report the failed write"); + } + assert_eq!(std::fs::read(&path).unwrap(), previous, "the prior export is kept"); + s.execute("keyword.export", ¶ms).unwrap(); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no temporary files remain"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn preset_exports_preserve_previous_files_on_write_failure() { let dir = temp_dir("preset-export-failure"); From 3734c22fae0396da04cde4ff15e2b8f27db91344 Mon Sep 17 00:00:00 2001 From: GSibille <263344878+G6bill@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:16:15 +0200 Subject: [PATCH 2/2] keyword.export: fail on a missing folder instead of creating it write_file_durable creates parent folders, which broke the ui-egui test exporting_keywords_says_why_it_failed. Write atomically with safe_file::write_atomic so a missing folder is still an error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/engine/src/cmd/keywords.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 7858a19a8..676979c76 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -446,7 +446,9 @@ pub fn specs() -> Vec { match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) { Some(path) => { s.check_write_target(path).map_err(|e| bad("keyword.export", e))?; - crate::export::write_file_durable(path, file.text.as_bytes()).map_err(crate::EngineError::Other)?; + // A folder that isn't there is an error, not created. + lightcraft_catalog::safe_file::write_atomic(std::path::Path::new(path), file.text.as_bytes()) + .map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; r["path"] = json!(path); } None => r["text"] = json!(file.text),