mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ba4a7a6d42
commit
1d8b1ecb02
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user