collective

Browse / github.com/storytold/pdfcraft / patch

fix(cos): read startxref when data follows the final %%EOF

fix(cos): read startxref when data follows the final %%EOF ## What this changes A PDF with more than 4 KiB of data after its final `%%EOF` opened as damaged: ``` cross-reference data unreadable (syntax error at byte N: no startxref); rebuilding from object headers rebuilt cross-reference index from 19 object headers ``` Its own cross-reference table was intact. The reader only looked for `startxref` in the last 4096 bytes, and here it sat about 25 KB before the end. I hit this on a sales proposal produced by Telerik Reporting 17.0: the web server that served it appended its HTML print-preview page (`<!DOCTYPE html>…</html>`) after `%%EOF`. qpdf rep

Status
open
Score
0.00
Adopters
0 other operators
Author
tsw2k (imported from pull request #4983, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+103 −4 in 4 files
Review
pending
Shared
2026-10-28
Id
aa5204ca932edad5cfd1e131

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/cos/README.md crates/cos/src/document/reader.rs crates/cos/src/document/tests.rs

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -180,3 +180,8 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true++[people.tsw2k]+real_name = "Ilya Balashov"+display_name = "Ilya Balashov"+sync_github_name = falsediff --git a/crates/cos/README.md b/crates/cos/README.md--- a/crates/cos/README.md+++ b/crates/cos/README.md@@ -51,6 +51,8 @@ another. Edits never touch the original bytes.   streams, hybrid-reference files (`/XRefStm`), and object streams. - **Repair.** A file whose xref is wrong or missing is reconstructed by scanning for `obj`   headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged.+  `startxref` is looked for in the last 4 KiB first and then anywhere in the file, so data after+  the final `%%EOF` (a web page glued to a served PDF) is not a reason to rebuild. - **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing   for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields   `CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects.diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs--- a/crates/cos/src/document/reader.rs+++ b/crates/cos/src/document/reader.rs@@ -23,8 +23,7 @@ impl Document {      fn read_compact_xref_chain(&self, log: &mut Vec<String>) -> Result<Option<(XrefIndex, Dict, Vec<Revision>)>, CosError> {         let data = &self.data;-        let tail_start = data.len().saturating_sub(4096);-        let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;+        let sx = find_startxref(data)?;         let mut lx = Lexer::new(data, sx + 9);         let first = match lx.object() {             Ok(Object::Int(n)) if n >= 0 => n as u64,@@ -325,8 +324,7 @@ impl Document {         log: &mut Vec<String>,     ) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {         let data = &self.data;-        let tail_start = data.len().saturating_sub(4096);-        let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;+        let sx = find_startxref(data)?;         let mut lx = Lexer::new(data, sx + 9);         let first = match lx.object() {             Ok(Object::Int(n)) if n >= 0 => n as u64,@@ -811,6 +809,39 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<     hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from) } +/// How far from the end of the file `startxref` is looked for first. ISO 32000-2 §7.5.5 puts it+/// on the last lines of the file.+const STARTXREF_TAIL: usize = 4096;++/// Where the last `startxref` in the file is. The tail is searched first. A file with data+/// after its final `%%EOF` (a web server that glued its HTML page to the PDF it served, a block+/// of padding) has the keyword earlier than that, and the whole file is then searched backwards,+/// so its own cross-reference data is read rather than rebuilt from object headers. The last+/// keyword wins wherever it is: an earlier one could belong to a superseded revision. The+/// trailing bytes stay in the file (an incremental save appends after them).+///+/// Objects after that `startxref` are different: an update that never got its cross-reference+/// section (a save cut short, a second file glued on), whose objects the last complete table+/// cannot reach. The error then says so, and the caller rebuilds the table from object headers+/// as it does for a file with no `startxref` at all.+pub(super) fn find_startxref(data: &[u8]) -> Result<usize, CosError> {+    let tail_start = data.len().saturating_sub(STARTXREF_TAIL);+    if let Some(sx) = rfind(data, b"startxref", tail_start) {+        return Ok(sx);+    }+    let sx = rfind(data, b"startxref", 0).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;+    // The trailing data starts after the `%%EOF` of that startxref (or after the keyword when+    // the marker is missing; the offset digits that follow are no object header).+    let after = find(data, b"%%EOF", sx, sx.saturating_add(64)).map_or(sx.saturating_add(9), |p| p.saturating_add(5));+    if !object_headers(data.get(after..).unwrap_or(&[])).is_empty() {+        return Err(CosError::Syntax {+            offset: sx,+            detail: format!("startxref is {} bytes before the end of the file and objects follow it", data.len().saturating_sub(sx)),+        });+    }+    Ok(sx)+}+ pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {     if hay.len() < needle.len() {         return None;diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs--- a/crates/cos/src/document/tests.rs+++ b/crates/cos/src/document/tests.rs@@ -1285,3 +1285,64 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {     });     rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); }++/// Data after the final `%%EOF` (a web server that answered with its HTML print-preview page+/// glued to the PDF, as Telerik Reporting does) pushes `startxref` out of the tail the reader+/// looks at first. The last `startxref` in the file is read instead, so the file's own+/// cross-reference table is used: nothing is rebuilt from object headers and nothing is logged.+/// The trailing bytes stay in the file, and an incremental save goes after them.+#[test]+fn a_startxref_pushed_past_the_tail_by_trailing_data_is_still_read() {+    let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");+    let mut bytes = base.clone();+    bytes.extend_from_slice(b"\r\n\r\n<!DOCTYPE html>\r\n<html>\r\n");+    for i in 0..400 {+        bytes.extend_from_slice(format!("<p>preview line {i}: no cross-reference keyword here</p>\r\n").as_bytes());+    }+    bytes.extend_from_slice(b"</html>\r\n");+    assert!(bytes.len() - base.len() > 2 * 4096, "the trailing data must reach past the tail window");+    let doc = Document::open(Arc::new(bytes.clone())).unwrap();+    assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());+    assert_eq!(doc.revisions().len(), 1);+    assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(_)));+    assert_compact_matches_materialized(&doc);++    let mut edited = doc.clone();+    edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();+    let saved = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();+    assert!(saved.starts_with(&bytes), "an incremental save keeps the original bytes, trailing data included");+    let reopened = Document::open(Arc::new(saved)).unwrap();+    assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());+    assert_eq!(reopened.revisions().len(), 2);+    assert!(matches!(reopened.get(ObjRef::new(1, 0)).as_dict().and_then(|d| d.get(b"Lang")), Some(Object::String(_))));+}++/// The last `startxref` in the file still wins, wherever it is. One inside the trailing data+/// that no offset follows is a bad startxref, and the file is rebuilt from object headers as+/// before: an earlier keyword is never taken instead, since it could belong to a superseded+/// revision.+#[test]+fn a_startxref_keyword_inside_trailing_data_does_not_pick_an_earlier_one() {+    let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");+    let mut bytes = base.clone();+    bytes.extend_from_slice(b"\n<html><pre>");+    bytes.extend(std::iter::repeat_n(b' ', 5000));+    bytes.extend_from_slice(b"startxref is a PDF keyword</pre></html>\n");+    let doc = Document::open(Arc::new(bytes)).unwrap();+    assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "{:?}", doc.repair_log());+    assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(_)));+}++/// Objects after the final `startxref` are an update that never got its cross-reference section+/// (a save cut short). The last complete table cannot reach them, so the file is rebuilt from+/// object headers as before, and the note says why.+#[test]+fn objects_after_the_last_startxref_still_rebuild_the_table() {+    let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");+    bytes.extend(std::iter::repeat_n(b'\n', 5000));+    bytes.extend_from_slice(b"3 0 obj\n(newer)\nendobj\n");+    let doc = Document::open(Arc::new(bytes)).unwrap();+    assert!(doc.revisions().is_empty(), "rebuilt: {:?}", doc.repair_log());+    assert!(doc.repair_log().iter().any(|l| l.contains("objects follow it")), "{:?}", doc.repair_log());+    assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(s) if s.bytes == b"newer"));+}