From 3489c3b22df500c55f5fc37069837e6bcf5a7a8b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:55:52 +0300 Subject: [PATCH] Format: report damaged embedded media and open such files as a repaired copy load() ignored every failed media read, so a .deckcraft with a missing, oversized or bad-CRC media member opened silently with empty pictures, and the next Save overwrote the original with that loss. load_checked() now lists each unreadable member (linked media saved without bytes is not damage). file.open/openBytes return damagedMedia, show a message, and open the deck as an untitled "(Repaired)" copy so Save asks for a new name and the original file stays intact. Co-authored-by: Cursor --- crates/engine/src/cmd/file.rs | 44 ++++++++++++++++++++++---- crates/engine/src/tests.rs | 32 +++++++++++++++++++ crates/format/src/lib.rs | 58 ++++++++++++++++++++++++++++++++--- 3 files changed, 124 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6424f7c..4adc5182 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -65,9 +65,18 @@ fn new(s: &mut Session, p: &Value) -> Result { /// Recognise and read presentation bytes (native, PPTX). pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { + open_checked(name, bytes).map(|(p, _)| p) +} + +/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged. +pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> { if deckcraft_format::sniff(bytes) { - return deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string())); + return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string())); } + open_other(name, bytes).map(|p| (p, vec![])) +} + +fn open_other(name: &str, bytes: &[u8]) -> Result { if deckcraft_pptx::sniff(bytes) { let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?; deckcraft_format::repair(&mut p); @@ -98,11 +107,15 @@ pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presenta fn open(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let doc = open_presentation(path, &bytes)?; + let (doc, damaged) = open_checked(path, &bytes)?; let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); - let i = add_opened(s, path, native.then(|| path.to_string()), doc); + // A damaged file opens as an untitled repaired copy, so Save can't overwrite the original + // with the empty media. + let keep_path = native && damaged.is_empty(); + let i = add_opened(s, path, keep_path.then(|| path.to_string()), doc); + report_damage(s, &damaged)?; let st = s.doc()?; - Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts})) + Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged})) } #[cfg(target_arch = "wasm32")] fn open(_s: &mut Session, _p: &Value) -> Result { @@ -112,9 +125,28 @@ fn open(_s: &mut Session, _p: &Value) -> Result { fn open_bytes(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").unwrap_or("Presentation"); let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?; - let doc = open_presentation(name, &data)?; + let (doc, damaged) = open_checked(name, &data)?; let i = add_opened(s, name, None, doc); - Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts})) + report_damage(s, &damaged)?; + Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged})) +} + +/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired. +fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> { + if damaged.is_empty() { + return Ok(()); + } + let st = s.doc_mut()?; + st.title = format!("{} (Repaired)", st.title); + let n = damaged.len(); + let text = format!( + "{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.", + if n == 1 { "" } else { "s" }, + if n == 1 { "" } else { "s" }, + if n == 1 { "is" } else { "are" }, + ); + s.ui_requests.push(crate::UiRequest::Message { text }); + Ok(()) } /// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..d0b8655c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,35 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// #392: a `.deckcraft` whose embedded picture is damaged opens as an untitled "(Repaired)" +/// copy with a message, so Save can't overwrite the original with the empty media. +#[test] +fn damaged_native_file_opens_as_repaired_copy() { + let mut p = deckcraft_model::Presentation::default(); + p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()); + let good = deckcraft_format::save(&p).unwrap(); + // PNGs are stored as they are, so flipping a byte breaks the entry's CRC. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let dir = std::env::temp_dir().join(format!("deckcraft-392-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, bytes, damaged) in [("good.deckcraft", &good, false), ("bad.deckcraft", &bad, true)] { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + let mut s = Session::new(); + s.ui_requests.clear(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(r["damagedMedia"].as_array().map(|a| a.len()), Some(usize::from(damaged)), "{r}"); + assert_eq!(st.path.is_none(), damaged, "{name}"); + assert_eq!(st.title.ends_with("(Repaired)"), damaged); + assert_eq!(s.ui_requests.iter().any(|u| matches!(u, crate::UiRequest::Message { .. })), damaged); + // Bytes route too. + let r = s.execute("file.openBytes", &json!({"name": name, "data": crate::cmd::base64_encode(bytes)})).unwrap(); + assert_eq!(r["damagedMedia"].as_array().map(|a| a.len()), Some(usize::from(damaged))); + } + assert_eq!(std::fs::read(dir.join("bad.deckcraft")).unwrap(), bad, "original untouched"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..52d75281 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -96,8 +96,15 @@ fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result Result { + load_checked(bytes).map(|(p, _)| p) +} + +/// Read a presentation and list the embedded media that couldn't be read (missing, too large +/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage. +pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; @@ -105,13 +112,18 @@ pub fn load(bytes: &[u8]) -> Result { return Err(FormatError::NotOurs(m.format)); } let mut p = m.presentation; + let mut damaged = vec![]; for item in &mut p.media { - if let Ok(data) = read_entry(&mut z, &format!("media/{}", item.id.0)) { - item.data = Arc::new(data); + let name = format!("media/{}", item.id.0); + let linked_only = item.link.is_some() && z.index_for_name(&name).is_none(); + match read_entry(&mut z, &name) { + Ok(data) => item.data = Arc::new(data), + Err(_) if linked_only => {} + Err(e) => damaged.push(e.to_string()), } } repair(&mut p); - Ok(p) + Ok((p, damaged)) } /// Make a loaded presentation safe to edit: a master exists, every slide's layout exists, @@ -218,6 +230,44 @@ pub fn slides_to_outline(p: &Presentation) -> String { mod tests { use super::*; + /// The same package without the entries `drop` names. + fn without(bytes: &[u8], drop: &str) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != drop { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() + } + + #[test] + fn missing_or_corrupt_media_is_reported() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()); + let good = save(&p).unwrap(); + assert!(load_checked(&good).unwrap().1.is_empty()); + // Missing member. + let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + assert!(q.media(id).is_some_and(|m| m.data.is_empty())); + // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let (_, damaged) = load_checked(&bad).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + // Linked media saved without its bytes isn't damage. + let mut l = Presentation::default(); + let lid = l.add_media("v.mp4", "video/mp4", vec![]); + if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { + m.link = Some("file:///movies/v.mp4".into()); + } + assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); + } + #[test] fn roundtrip_with_media() { let mut p = Presentation::default();