Merge #6561: M1: a file whose last update has no /Prev opens with all its pages and a repair note (core.repair-broken-files)

# Conflicts:
#	parity/acrobat-features.toml
#	vendor/README.md
#	vendor/hayro-syntax/src/pdf.rs
This commit is contained in:
bflatastic
2026-11-09 08:11:01 +07:00
13 changed files with 335 additions and 13 deletions
Generated
+1
View File
@@ -4699,6 +4699,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
+2
View File
@@ -48,6 +48,8 @@ pub struct Document {
/// `fill_xref_gaps`.
located: Arc<BTreeMap<u32, (u64, u16)>>,
repair_log: Arc<Vec<String>>,
/// See [`Self::rebuilt_unlinked_update`].
unlinked_update: bool,
/// What loading each file object gave, a failure as well: a damaged object is otherwise
/// loaded again on every read, and a member of an object stream that can't be decoded
/// decodes the stream again (`Document::load`).
+7
View File
@@ -90,6 +90,13 @@ impl Document {
&self.repair_log
}
/// Whether the file was rebuilt from its object headers because its last update has no `/Prev`
/// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The
/// renderer rebuilds such a file under the same conditions, so the two read the same objects.
pub fn rebuilt_unlinked_update(&self) -> bool {
self.unlinked_update
}
/// Objects of the file that could not be read, lowest number first, as lines such as
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
+40 -4
View File
@@ -21,6 +21,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -109,6 +110,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -134,6 +136,8 @@ impl Document {
}
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
// The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`).
let mut cut_off = false;
let ok = match parsed {
Ok((entries, trailer, revisions, oddities)) => {
doc.entries = Arc::new(entries);
@@ -147,17 +151,22 @@ impl Document {
doc.authenticate(&enc, &credentials)?;
authenticated = true;
}
doc.root_is_catalog()
let catalog = doc.root_is_catalog();
cut_off = catalog && doc.update_cuts_off_the_pages();
catalog && !cut_off
}
Err(e) => {
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
false
}
};
if cut_off {
log.push(UNLINKED_UPDATE.into());
doc.unlinked_update = true;
} else if !ok && !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
if !ok {
if !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
doc.reconstruct(&mut log)?;
}
// Header generations are plaintext, including in encrypted files. Inspect only the
@@ -182,6 +191,24 @@ impl Document {
Ok(doc)
}
/// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one
/// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages`
/// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require
/// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file
/// is rebuilt from its object headers as a damaged one is (the newest definition of each
/// object wins), and the repair log says why. (A file whose `/Root` itself is missing is
/// rebuilt anyway.)
fn update_cuts_off_the_pages(&self) -> bool {
if self.revisions.len() != 1 {
return false;
}
let Some(root) = self.root() else { return false };
let Ok(catalog) = self.try_get_reference(root) else { return false };
let Object::Dict(catalog) = &*catalog else { return false };
// The page tree first: the scan for an earlier `startxref` reads the whole file.
!catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data)
}
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
// Authentication can change how the same input is decoded.
self.source_identity = Arc::new(());
@@ -411,3 +438,12 @@ impl Document {
}
}
}
/// The repair-log line for a file rebuilt because its last update has no `/Prev`.
const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers";
/// Whether `data` has more than one `startxref`: an update was appended to it, or it is
/// linearized (whose first-page section links to the main one).
fn earlier_sections(data: &[u8]) -> bool {
find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some()
}
+63
View File
@@ -2194,6 +2194,69 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
}
/// The width (`/MediaBox` x1) of each of `pages`, in order.
fn page_widths(pages: &[Dict]) -> Vec<Option<i64>> {
pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect()
}
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
let catalog = doc.get(doc.root().expect("root"));
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
}
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
/// the repair log says why; a save rewrites it in full, with every page.
#[test]
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
let pages = page_tree_pages(&reopened);
assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order");
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}");
let catalog = reopened.get(reopened.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved");
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
// The same file with `/Prev` reads through its chain, as before.
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
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");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
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());
assert_eq!(linked.revisions().len(), 2);
}
#[test]
fn find_agrees_with_a_plain_window_search() {
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
+1
View File
@@ -31,5 +31,6 @@ bytemuck = { version = "1" }
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
pdfcraft-testkit = { workspace = true }
# Builds the decompression-bomb streams in the raster tests.
flate2 = { workspace = true }
+48
View File
@@ -80,6 +80,9 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
Ok(p) => info.pages = p,
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
}
// Never silent: the pages of a file whose last update has no `/Prev` were found by rebuilding
// its cross-reference data (`pdfcraft-cos` rebuilds it under the same conditions and logs it).
let unlinked = pdf.rebuilt_unlinked_update();
let options = load_options(password);
// Geometry no longer needs the renderer parser. Do not overlap its allocations with
// structural inspection, including the compatibility fallback for damaged files.
@@ -110,9 +113,17 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
if unlinked {
info.warnings.push(UNLINKED_UPDATE.into());
}
Ok(info)
}
/// The warning for a file whose pages were found by rebuilding its cross-reference data because its
/// last update has no `/Prev` (ISO 32000-2 §7.5.6, §7.5.8.2).
const UNLINKED_UPDATE: &str =
"This file's last update has no link (/Prev) to its earlier revisions; its pages were found by rebuilding the cross-reference data.";
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
let structure = catch_unwind(AssertUnwindSafe(|| {
@@ -434,3 +445,40 @@ mod open_action_javascript_tests {
assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
}
}
#[cfg(test)]
mod unlinked_update_tests {
use super::*;
/// An update without `/Prev` cuts the page tree off the chain; the pages come from the
/// rebuilt table, the page tree's pages in its order, and the file says it was rebuilt.
#[test]
fn an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt() {
let info = inspect(Arc::new(pdfcraft_testkit::update_without_prev(false)), None).expect("the unlinked file opens");
assert_eq!(info.pages.len(), 3, "{:?}", info.pages);
assert_eq!(info.pages.iter().map(|p| p.width).collect::<Vec<_>>(), [200.0, 300.0, 400.0]);
assert_eq!(info.warnings, [UNLINKED_UPDATE]);
// The structure is read from the rebuilt table as well: the second page's link.
assert_eq!(info.links.len(), 1, "{:?}", info.links);
let linked = inspect(Arc::new(pdfcraft_testkit::update_without_prev(true)), None).expect("the linked file opens");
assert_eq!(linked.pages.len(), 3);
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());
}
}
+3 -1
View File
@@ -144,7 +144,9 @@ pub(crate) struct LazyStructure {
impl LazyStructure {
pub(crate) fn new(bytes: Arc<Vec<u8>>, password: Option<&str>) -> std::result::Result<Self, String> {
let source = Document::open_with_stream_limit(bytes, password, 256 << 20).map_err(|e| e.to_string())?;
if !source.repair_log().is_empty() {
// A file rebuilt because its last update has no `/Prev` is read here as rebuilt: the
// renderer rebuilds it too, and the fallback reader would follow the cut-off chain.
if !source.repair_log().is_empty() && !source.rebuilt_unlinked_update() {
return Err("the structure requires compatibility repair".into());
}
let trailer = convert_dict(source.trailer(), 0).map_err(|e| e.to_string())?;
+112
View File
@@ -133,6 +133,118 @@ pub fn stream_obj(dict: &str, data: impl AsRef<[u8]>) -> Vec<u8> {
out
}
/// A three-page file (200, 300 and 400 points wide, in that order, the second with a link to
/// `https://example.com/`) whose page tree sits in an object stream of its first revision, plus an update that rewrites the catalog and the first
/// page in a cross-reference stream: with `/Prev` when `linked`, without it otherwise (the shape
/// of the signed PDFBOX-2720 and PDFBOX-2942 files). Without `/Prev` a reader that follows the
/// 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> {
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();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let four = out.len();
out.extend_from_slice(b"4 0 obj\n<< /Length 14 >>\nstream\n0 0 50 50 re f\nendstream\nendobj\n");
// Objects 2 (the page tree), 3, 6 and 7 (the pages) and 10 (the link) in object stream 5.
let members = [
(2, "<< /Type /Pages /Kids [3 0 R 6 0 R 7 0 R] /Count 3 >>".to_string()),
(3, page(200, "")),
(6, page(300, " /Annots [10 0 R]")),
(7, page(400, "")),
(10, "<< /Type /Annot /Subtype /Link /Rect [10 10 60 60] /A << /S /URI /URI (https://example.com/) >> >>".to_string()),
];
let (mut header, mut body) = (String::new(), String::new());
for (num, member) in &members {
header.push_str(&format!("{num} {} ", body.len()));
body.push_str(member);
body.push(' ');
}
let five = out.len();
out.extend_from_slice(
format!(
"5 0 obj\n<< /Type /ObjStm /N 5 /First {} /Length {} >>\nstream\n{header}{body}\nendstream\nendobj\n",
header.len(),
header.len() + body.len()
)
.as_bytes(),
);
let eight = out.len();
let mut rows = Vec::new();
row(&mut rows, 0, 0, 65535);
row(&mut rows, 1, one, 0);
row(&mut rows, 2, 5, 0);
row(&mut rows, 2, 5, 1);
row(&mut rows, 1, four, 0);
row(&mut rows, 1, five, 0);
row(&mut rows, 2, 5, 2);
row(&mut rows, 2, 5, 3);
row(&mut rows, 1, eight, 0);
row(&mut rows, 0, 0, 0);
row(&mut rows, 2, 5, 4);
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());
(out, eight)
}
#[cfg(test)]
mod tests {
use super::*;
+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/render/src/raster/tests.rs::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them"]
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 last update cut off before its trailer (objects after a last `startxref` more than 4 KiB from the end) is rebuilt from object headers by the renderer as by the document model, so both show its latest objects."
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/render/src/raster/tests.rs::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them", "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 last update cut off before its trailer (objects after a last `startxref` more than 4 KiB from the end) is rebuilt from object headers by the renderer as by the document model, so both show its latest objects. 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
+16 -2
View File
@@ -17,6 +17,8 @@ pub struct Pdf {
header_version: PdfVersion,
pages: CachedPages,
data: PdfData,
/// PdfCraft patch: (hayro-syntax (45)) see [`Pdf::rebuilt_unlinked_update`].
rebuilt_unlinked_update: bool,
}
/// An error that occurred while loading a PDF file.
@@ -59,7 +61,10 @@ impl Pdf {
if let Err(XRefError::Encryption(e)) = root {
return Err(LoadPdfError::Decryption(e));
}
let root_encrypted = root.as_ref().ok().map(XRef::is_encrypted);
let root_encrypted = root.as_ref().ok().map(|(x, _)| x.is_encrypted());
// PdfCraft patch: (hayro-syntax (45)) said of the file whichever table is kept, as
// `pdfcraft-cos` says it under the same conditions.
let rebuilt_unlinked_update = root.as_ref().is_ok_and(|(_, rebuilt)| *rebuilt);
if cut_off_update_follows(data.as_ref())
&& let Some(xref) = fallback(data.clone(), password)
.filter(|x| root_encrypted.is_none_or(|e| e == x.is_encrypted()))
@@ -73,11 +78,12 @@ impl Pdf {
header_version: version,
pages,
data,
rebuilt_unlinked_update,
});
}
}
let xref = match root {
Ok(x) => x,
Ok((x, _)) => x,
Err(e) => match e {
XRefError::Unknown => {
fallback(data.clone(), password).ok_or(LoadPdfError::Invalid)?
@@ -94,9 +100,17 @@ impl Pdf {
header_version: version,
pages,
data,
rebuilt_unlinked_update,
})
}
/// PdfCraft patch: (hayro-syntax (45)) whether the newest cross-reference section has no
/// `/Prev`, so the page tree in the revisions before it could only be found by rebuilding the
/// table from the file's objects.
pub fn rebuilt_unlinked_update(&self) -> bool {
self.rebuilt_unlinked_update
}
/// Return the number of objects present in the PDF file.
pub fn len(&self) -> usize {
self.xref.len()
+39 -3
View File
@@ -50,19 +50,55 @@ pub(crate) enum XRefError {
}
/// Parse the "root" xref from the PDF.
pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<XRef, XRefError> {
///
/// PdfCraft patch: (hayro-syntax (45)) also whether the table was rebuilt because the newest
/// section cuts off the page tree (see below).
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)?;
let trailer =
populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?;
// PdfCraft patch: (hayro-syntax (45)) 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());
XRef::new(
let xref = XRef::new(
data.clone(),
xref_map,
XRefInput::TrailerDictData(trailer),
false,
password,
)
)?;
// PdfCraft patch: (hayro-syntax (45)) an update whose section has no `/Prev` (ISO 32000-2
// §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 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()
&& more_than_one_startxref(data.as_ref());
if cut_off {
warn!(
"the newest xref section has no Prev and the page tree is unreachable; rebuilding the xref table"
);
xref.repair();
}
Ok((xref, cut_off))
}
/// PdfCraft patch: (hayro-syntax (45)) whether `data` holds an earlier `startxref` than its last.
fn more_than_one_startxref(data: &[u8]) -> bool {
let needle = b"startxref";
findr_needle(data, needle)
.and_then(|last| data.get(..last))
.is_some_and(|before| crate::util::find_needle(before, needle).is_some())
}
/// Try to manually parse the PDF to build an xref table and trailer dictionary.