From 1d8b1ecb0288d97bd9d58f089e87d9e0ba2ddc91 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 07:45:20 -0400 Subject: [PATCH] M1/M4: Hybrid-reference files: /XRefStm entries replace the table's free entries; signed documents refuse edits that would force a full rewrite Co-Authored-By: Claude Opus 5.5 --- crates/cos/src/document.rs | 66 ++++++++++++++++++++++++++++++++---- crates/engine/src/lib.rs | 9 +++++ crates/engine/src/tests.rs | 4 +++ parity/acrobat-features.toml | 18 +++++----- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c66f2b493..94e1315a5 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -653,11 +653,14 @@ impl Document { log.push(format!("cross-reference chain loops at offset {off}; stopped")); break; } - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut entries) { + // This section's own entries; older sections only fill what newer ones left out. + let mut sect = BTreeMap::new(); + let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect) { Ok(v) => v, Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); - self.read_section(off as usize + self.header_offset, &mut entries)? + sect.clear(); + self.read_section(off as usize + self.header_offset, &mut sect)? } Err(e) => { if revisions.is_empty() { @@ -668,11 +671,23 @@ impl Document { } }; revisions.push(Revision { xref_offset: off, is_stream }); - // Hybrid files: the /XRefStm entries supplement the table (§7.5.8.4). - if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) - && let Err(e) = self.read_section(x as usize, &mut entries) - { - log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); + // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the + // free entries it gives objects only a cross-reference stream can locate. + if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + let mut stm = BTreeMap::new(); + match self.read_section(x as usize, &mut stm) { + Ok(_) => { + for (num, e) in stm { + if sect.get(&num).is_none_or(|old| matches!(old, XrefEntry::Free { .. })) { + sect.insert(num, e); + } + } + } + Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")), + } + } + for (num, e) in sect { + entries.entry(num).or_insert(e); } next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); if trailer.is_none() { @@ -956,6 +971,43 @@ mod tests { assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// A hybrid-reference file: the classic table leaves object 3 out; the `/XRefStm` stream + /// says it lives in object stream 4. + #[test] + fn hybrid_reference_files_read_the_xref_stream_too() { + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 6]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let inner = b"3 0 << /Hidden (yes) >>"; + let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n", inner.len()).into_bytes(); + stm.extend_from_slice(inner); + stm.extend_from_slice(b"\nendstream"); + obj(&mut out, 4, &stm); + let data = [2u8, 0, 4, 0]; + let mut xs = format!("<< /Type /XRef /Size 6 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); + xs.extend_from_slice(&data); + xs.extend_from_slice(b"\nendstream"); + obj(&mut out, 5, &xs); + let table = out.len(); + out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for (n, off) in offs.iter().enumerate().skip(1) { + let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; + out.extend_from_slice(line.as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + let hidden = doc.get(ObjRef::new(3, 0)); + assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden").and_then(|h| h.as_string().map(|s| s.to_text()))), Some("yes".into())); + } + #[test] fn revision_ends_split_incremental_updates() { let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a1377515..d24f01493 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1451,6 +1451,8 @@ pub enum EditError { Sign(String), #[error("{0}")] Optimize(String), + #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] + SignedRewrite(String), #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] Signed, } @@ -1606,12 +1608,19 @@ impl Session { let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; if let Some(p) = editor.cos.permissions() { check_permission(&edit, &p)?; } let mut next = editor.cos.clone(); run_edit(&mut next, &edit, &mut cx)?; + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + let rewrites = |c: &printcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && rewrites(&next) && !rewrites(&editor.cos) { + return Err(EditError::SignedRewrite(edit.label())); + } let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bc9b4103c..37576779c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1086,6 +1086,10 @@ fn signing_saving_trusting_and_commenting_afterwards() { let saved = s.save_bytes(id).unwrap(); s.mark_saved(id, saved, None).unwrap(); assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + // Edits that would need a full rewrite are refused, and change nothing. + let before = s.get(id).unwrap().can_undo().map(str::to_owned); + assert!(matches!(s.apply(id, Edit::Sanitize), Err(EditError::SignedRewrite(_)))); + assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ee585d0d4..076540939 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -79,11 +79,11 @@ title = "Hybrid-reference files (/XRefStm) and quirky xref subsections" area = "A" tier = "P0" milestone = "M1" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "M1.4" +tools = ["doc_open"] +evidence = ["crates/cos/src/document.rs::hybrid_reference_files_read_the_xref_stream_too"] +notes = "/XRefStm entries supplement the classic table and replace its free entries for objects in object streams; each section is merged as a unit so older revisions only fill gaps" [[feature]] id = "core.revision-chain" @@ -631,11 +631,11 @@ title = "Force incremental saves for signed documents" area = "A" tier = "P0" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "M4.3; needs signature detection" +status = "shipped" +commands = ["file.save"] +tools = ["doc_save"] +evidence = ["crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"] +notes = "saves of signed documents are always incremental: full rewrites are refused, and edits that would need one (applying redactions, changing security, sanitizing) are refused before they change anything" [[feature]] id = "core.drag-drop-open"