From 582416f4f2671365ae7c28b74433afcdb67aa820 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:00:47 -0800 Subject: [PATCH] EPS Save: every file of a multi-artboard save is checked against the file read from A save with Use Artboards writes a file per artboard beside the path. Each of them is now checked against the file the document was read from, before the first file is written, so a sibling cannot replace that file without acknowledgeLoss. The interface checks the files beside the path the same way. --- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 29 ++++++++++++++++++++++++-- crates/engine/src/cmd/fileio/save.rs | 10 ++++++++- crates/engine/src/cmd/recovery.rs | 1 + crates/engine/src/tests/save.rs | 24 +++++++++++++++++++++ crates/ui-egui/src/io/save.rs | 6 ++++-- crates/ui-egui/src/tests/save.rs | 31 ++++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6478cc81b8..25718292ae 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -69,7 +69,6 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; pub use export::{export_doc, export_source}; -pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; @@ -80,6 +79,7 @@ pub use load::{ Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image, read_for_open, texture_image, }; +pub(crate) use load::{check_files_not_lossy_overwrite, check_not_lossy_overwrite, lossy_source}; pub use load::{losses_summary, overwrite_losses}; pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; pub(crate) use mesh3d::{mesh_file_names, mesh_files}; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..cc3de2a529 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -993,13 +993,38 @@ fn is_loss(note: &str) -> bool { /// Another name keeps the original file, with all it has. pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> { let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) }; + Err(lossy_overwrite(target, losses, cmd)) +} + +/// The refusal to write `target`, the file the document was read from, when reading it left out `losses`. +fn lossy_overwrite(target: &str, losses: &[String], cmd: &str) -> EngineError { let what = losses_summary(losses); - Err(bad( + bad( cmd, format!( "{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway" ), - )) + ) +} + +/// The file the document was read from and what reading it left out, when a write to that file +/// needs `acknowledgeLoss` and `p` does not have it ([`overwrite_losses`]). +pub(crate) fn lossy_source(st: &crate::DocState, p: &Value) -> Option<(String, Vec)> { + let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?; + (!bool_or(p, "acknowledgeLoss", false)).then(|| (source.to_string(), st.import_losses.clone())) +} + +/// None of the files `targets` (every file a save writes, before any is written) is the +/// `source` file that reading left `losses` out of ([`lossy_source`]). +pub(crate) fn check_files_not_lossy_overwrite<'a>( + source: &(String, Vec), + targets: impl IntoIterator, + cmd: &str, +) -> Result<()> { + match targets.into_iter().find(|t| same_file(&source.0, t)) { + Some(target) => Err(lossy_overwrite(target, &source.1, cmd)), + None => Ok(()), + } } /// What writing the file `target` for document `st` would lose for good: the notes of what reading diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 301a7c79fe..a0cb4c75fa 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -99,6 +99,10 @@ pub struct SavePlan { /// `preview`, `precision`) given beside the path, and each key of `options`, that the format /// doesn't read, naming the file the save writes. pub notes: Vec, + /// The file the document was read from and what reading it left out, when the save needs + /// `acknowledgeLoss` to write over it and was not given it: every file the save writes is + /// checked against it before the first is written. + pub lossy_source: Option<(String, Vec)>, } impl SavePlan { @@ -308,7 +312,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { .chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}"))) .map(|k| format!("`{k}` was not used: {} files don't read it", written.label)) .collect(); - Ok(SavePlan { mode, path, format, options, name, folder, modified, notes }) + let lossy_source = super::lossy_source(st, p); + Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source }) } /// The native save options, which a save also takes beside the path. @@ -899,6 +904,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(source) = &plan.lossy_source { + super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?; + } for (p, bytes) in &files { write(p, bytes)?; } diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index b75066ec6a..d7561e4e10 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -905,6 +905,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec = std::fs::read_dir(&d) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("Art_"))) + .collect(); + siblings.sort(); + assert_eq!(siblings.len(), 2, "{siblings:?}"); + let sibling = siblings[0].to_string_lossy().to_string(); + let saved = std::fs::read(&sibling).unwrap(); + std::fs::remove_file(&path).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some(sibling.clone()); + st.import_losses = vec!["hidden type".into()]; + rect(&mut app); + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(&mut app); + assert_eq!(std::fs::read(&sibling).unwrap(), saved); + assert!(!std::path::Path::new(&path).exists(), "no file of the set is written when one is refused"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn native_saves_stream_into_the_file() { let d = dir("stream");