M1: a hybrid update without /Prev is rebuilt by the renderer as by cos (core.repair-broken-files)

The renderer (hayro-syntax (44)) counted the sections its chain reached, and
a hybrid section's /XRefStm stream (ISO 32000-2 §7.5.8.4) counted as one, so
a hybrid update without /Prev was rebuilt by cos but not by the renderer. It
now asks what cos asks: whether the newest section names an earlier one
through /Prev. The /XRefStm stream belongs to the same update.

Tests: a synthetic hybrid fixture (pdfcraft-testkit), with and without
/Prev, in cos and render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410
2026-11-07 22:29:49 +08:00
co-authored by Claude Opus 5.5
parent 6847b1ea7e
commit 7de85d520c
6 changed files with 101 additions and 36 deletions
+12
View File
@@ -1957,3 +1957,15 @@ fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
assert_eq!(linked.revisions().len(), 2);
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
}
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_too() {
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
assert_eq!(page_tree_pages(&doc).len(), 3);
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
}
+16
View File
@@ -461,4 +461,20 @@ mod unlinked_update_tests {
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
assert_eq!(linked.links.len(), 1);
}
/// A hybrid update's `/XRefStm` stream is part of that update, not an earlier section: without
/// `/Prev` the file is rebuilt as the document model rebuilds it, and with it, it isn't.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it() {
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false));
let info = inspect(bytes.clone(), None).expect("the unlinked hybrid file opens");
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
assert!(pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true));
let linked = inspect(bytes.clone(), None).expect("the linked hybrid file opens");
assert_eq!(linked.pages.len(), 3);
assert!(linked.warnings.is_empty(), "{:?}", linked.warnings);
assert!(!pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update());
}
}
+58 -22
View File
@@ -140,11 +140,63 @@ pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec<u8> {
/// chain (ISO 32000-2 §7.5.6, §7.5.8.2) finds no page tree, and a scan for page dictionaries
/// outside object streams finds only the rewritten page.
pub fn update_without_prev(linked: bool) -> Vec<u8> {
fn row(out: &mut Vec<u8>, kind: u8, field: usize, index: u16) {
out.push(kind);
out.extend_from_slice(&u32::try_from(field).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(&index.to_be_bytes());
}
let (mut out, eight) = unlinked_original();
// The update: the catalog and the first page again, and a section for them and itself.
let (catalog, first) = unlinked_rewrites(&mut out);
let nine = out.len();
let mut rows = Vec::new();
xref_row(&mut rows, 1, catalog, 0);
xref_row(&mut rows, 1, first, 0);
xref_row(&mut rows, 1, nine, 0);
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
out.extend_from_slice(
format!("9 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /Index [1 1 3 1 9 1] /W [1 4 2]{prev} /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{nine}\n%%EOF\n").as_bytes());
out
}
/// [`update_without_prev`] with the update written as a hybrid-reference section (ISO 32000-2
/// §7.5.8.4): a classic table whose trailer names a cross-reference stream with `/XRefStm`. That
/// stream belongs to the same update; it is not an earlier section, so without `/Prev` the page
/// tree is cut off just the same.
pub fn hybrid_update_without_prev(linked: bool) -> Vec<u8> {
let (mut out, eight) = unlinked_original();
let (catalog, first) = unlinked_rewrites(&mut out);
// The update's cross-reference stream, locating only itself.
let nine = out.len();
let mut rows = Vec::new();
xref_row(&mut rows, 1, nine, 0);
out.extend_from_slice(format!("9 0 obj\n<< /Type /XRef /Size 11 /Index [9 1] /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
out.extend_from_slice(&rows);
out.extend_from_slice(b"\nendstream\nendobj\n");
let table = out.len();
out.extend_from_slice(format!("xref\n1 1\n{catalog:010} 00000 n \n3 1\n{first:010} 00000 n \n").as_bytes());
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
out.extend_from_slice(format!("trailer\n<< /Size 11 /Root 1 0 R /XRefStm {nine}{prev} >>\nstartxref\n{table}\n%%EOF\n").as_bytes());
out
}
/// One row of a cross-reference stream with `/W [1 4 2]`.
fn xref_row(out: &mut Vec<u8>, kind: u8, field: usize, index: u16) {
out.push(kind);
out.extend_from_slice(&u32::try_from(field).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(&index.to_be_bytes());
}
/// The update both fixtures append: the catalog and the first page again. Their offsets.
fn unlinked_rewrites(out: &mut Vec<u8>) -> (usize, usize) {
let catalog = out.len();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Lang (en) >>\nendobj\n");
let first = out.len();
out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [] >>\nendobj\n");
(catalog, first)
}
/// The first revision of [`update_without_prev`], and the offset of its cross-reference stream.
fn unlinked_original() -> (Vec<u8>, usize) {
let row = xref_row;
let page = |width: u32, extra: &str| format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} 200] /Contents 4 0 R{extra} >>");
let mut out = b"%PDF-1.7\n".to_vec();
let one = out.len();
@@ -190,23 +242,7 @@ pub fn update_without_prev(linked: bool) -> Vec<u8> {
out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", rows.len()).as_bytes());
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{eight}\n%%EOF\n").as_bytes());
// The update: the catalog and the first page again, and a section for them and itself.
let catalog = out.len();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Lang (en) >>\nendobj\n");
let first = out.len();
out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [] >>\nendobj\n");
let nine = out.len();
let mut rows = Vec::new();
row(&mut rows, 1, catalog, 0);
row(&mut rows, 1, first, 0);
row(&mut rows, 1, nine, 0);
let prev = if linked { format!(" /Prev {eight}") } else { String::new() };
out.extend_from_slice(
format!("9 0 obj\n<< /Type /XRef /Size 11 /Root 1 0 R /Index [1 1 3 1 9 1] /W [1 4 2]{prev} /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{nine}\n%%EOF\n").as_bytes());
out
(out, eight)
}
#[cfg(test)]
+2 -2
View File
@@ -46,8 +46,8 @@ milestone = "M1"
status = "shipped"
commands = ["file.open"]
tools = ["doc_open"]
evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0", "crates/cos/src/document/tests.rs::an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note", "crates/render/src/inspect/core.rs::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt"]
notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged. A file whose last update has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2), so that its page tree is cut off the chain, is rebuilt from its object headers by the document model and the renderer alike, with a repair note and an open warning (the signed PDFBOX-2720 and PDFBOX-2942 files)."
evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0", "crates/cos/src/document/tests.rs::an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note", "crates/render/src/inspect/core.rs::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt", "crates/cos/src/document/tests.rs::a_hybrid_update_without_prev_is_rebuilt_too", "crates/render/src/inspect/core.rs::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it"]
notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged. A file whose last update has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2), so that its page tree is cut off the chain, (a hybrid section's /XRefStm stream counts as part of the same update) is rebuilt from its object headers by the document model and the renderer alike, with a repair note and an open warning (the signed PDFBOX-2720 and PDFBOX-2942 files)."
[[feature]]
id = "core.malformed-input-safety"
+1 -1
View File
File diff suppressed because one or more lines are too long
+12 -11
View File
@@ -56,10 +56,14 @@ pub(crate) enum XRefError {
pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<(XRef, bool), XRefError> {
let mut xref_map = FxHashMap::default();
let xref_pos = find_last_xref_pos(data.as_ref()).ok_or(XRefError::Unknown)?;
// PdfCraft patch: (hayro-syntax (44)) the sections the chain reached, for the check below.
let mut visited = BTreeSet::new();
let trailer = populate_xref_impl_inner(data.as_ref(), xref_pos, &mut xref_map, &mut visited)
.ok_or(XRefError::Unknown)?;
let trailer =
populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?;
// PdfCraft patch: (hayro-syntax (44)) whether the newest section names an earlier one. Its
// `/XRefStm` (a hybrid file's stream, §7.5.8.4) belongs to the same update and doesn't count,
// as `pdfcraft-cos` doesn't count it.
let linked = Reader::new(trailer)
.read_with_context::<Dict<'_>>(&ReaderContext::dummy())
.is_some_and(|newest| prev_offset(data.as_ref(), &newest, &XrefMap::default()).is_some());
let xref = XRef::new(
data.clone(),
@@ -72,10 +76,10 @@ pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<(XRef, bool),
// §7.5.6, §7.5.8.2) cuts off the revisions before it, and with them the page tree its catalog
// names (the signed files of PDFBOX-2720 and PDFBOX-2942). The table is rebuilt by scanning
// the file, as for a broken one, so the page tree is found rather than whatever page
// dictionaries `Pages::new_brute_force` turns up. Only when the chain reached that one
// section, the file holds another `startxref`, and the page tree can't be read: the
// conditions `pdfcraft-cos` rebuilds under, so the two agree on the pages.
let cut_off = visited.len() == 1
// dictionaries `Pages::new_brute_force` turns up. Only when that section names no earlier
// one, the file holds another `startxref`, and the page tree can't be read: the conditions
// `pdfcraft-cos` rebuilds under, so the two agree on the pages.
let cut_off = !linked
&& xref
.get::<Dict<'_>>(xref.trailer_data().pages_ref)
.is_none()
@@ -1012,9 +1016,6 @@ impl XRefEntry {
}
}
// PdfCraft patch: (hayro-syntax (44)) `root_xref` reads the chain itself now; the tests still
// call this.
#[cfg(test)]
fn populate_xref_impl<'a>(data: &'a [u8], pos: usize, xref_map: &mut XrefMap) -> Option<&'a [u8]> {
let mut visited = BTreeSet::new();
populate_xref_impl_inner(data, pos, xref_map, &mut visited)