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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 07:45:20 -04:00
co-authored by Claude Opus 5.5
parent ba4a7a6d42
commit 1d8b1ecb02
4 changed files with 81 additions and 16 deletions
+59 -7
View File
@@ -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<u8>, 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");
+9
View File
@@ -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);
+4
View File
@@ -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]
+9 -9
View File
@@ -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"