perf(cos): rebuild a damaged cross-reference index without parsing every object

Reconstruct parsed every object to see whether it is an object stream, and
searched each object's next 1,024 bytes and the whole file byte by byte.

Only objects whose dictionary holds ObjStm or a # (a name can hide letters as
#xx) are now parsed for that check. Every /XRef position is found once, and
find() scans for the needle's rarest byte in 32-byte blocks. The index, trailer
and repair log are unchanged.

Fixes #4868

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-26 21:57:29 +01:00
co-authored by Claude Sonnet 5.5
parent f94668f1c8
commit be8345aa3f
3 changed files with 126 additions and 4 deletions
+68 -4
View File
@@ -2052,10 +2052,29 @@ impl Document {
// Offsets of objects that may be cross-reference streams: their dictionaries are the
// trailers of files that have no `trailer` keyword (§7.5.8).
let mut xref_streams = Vec::new();
for (num, offset, generation, body) in object_headers(&data) {
// Objects whose bytes (the last object of a number wins, as in `entries`) could hold an
// `/ObjStm` type; the others are not parsed for the object-stream check below.
let mut candidates: HashSet<u32> = HashSet::new();
let headers = object_headers(&data);
// Where `/XRef` occurs in the file, found in one pass.
let mut xref_marks = Vec::new();
let mut mark_from = 0;
while let Some(p) = find(&data, b"/XRef", mark_from, data.len()) {
xref_marks.push(p);
mark_from = p + 1;
}
for (i, &(num, offset, generation, body)) in headers.iter().enumerate() {
entries.insert(num, XrefEntry::InFile { offset, generation });
objstms.push(num);
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
let end = headers.get(i + 1).and_then(|h| usize::try_from(h.1).ok()).unwrap_or(data.len());
if may_be_object_stream(&data, body, end) {
candidates.insert(num);
} else {
candidates.remove(&num);
}
// `/XRef` within the 1024 bytes after the header (the first one that is there).
let first = xref_marks.partition_point(|&p| p < body);
if xref_marks.get(first).is_some_and(|&p| p + 5 <= body.saturating_add(1024).min(data.len()))
&& let Ok(at) = usize::try_from(offset)
{
xref_streams.push(at);
@@ -2068,7 +2087,8 @@ impl Document {
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Objects inside object streams.
for num in objstms {
if let Ok(o) = self.try_get(num)
if candidates.contains(&num)
&& let Ok(o) = self.try_get(num)
&& let Object::Stream(s) = &*o
&& s.dict.name(b"Type") == Some(b"ObjStm")
&& let Ok(stm) = self.objstm(num)
@@ -2165,6 +2185,24 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
}
}
/// Whether the object whose body is `data[from..to]` could be an object stream: its dictionary
/// holds `ObjStm`, or a `#` (a name may hide its letters as `#xx`, as in `/Obj#53tm`). A stream's
/// data is not searched: it ends the dictionary, at the `stream` keyword after `>>`.
fn may_be_object_stream(data: &[u8], from: usize, to: usize) -> bool {
let Some(body) = data.get(from..to.min(data.len())) else { return false };
let mut dict = body;
let mut at = 0;
while let Some(p) = find(body, b"stream", at, body.len()) {
at = p + 6;
let after_dict = body[..p].trim_ascii_end().ends_with(b">>");
if after_dict && matches!(body.get(at), Some(b'\r' | b'\n')) {
dict = &body[..p];
break;
}
}
dict.contains(&b'#') || find(dict, b"ObjStm", 0, dict.len()).is_some()
}
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position
/// after `obj`).
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> {
@@ -2241,7 +2279,33 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<
if from >= to || needle.is_empty() || to - from < needle.len() {
return None;
}
hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
let hay = &hay[from..to];
// Look for the needle's rarest byte first, a plain byte scan, and compare the rest only there.
const COMMON: &[u8] = b" etaoinsrhldcumfpgwybvkxjqz";
let (k, rare) =
needle.iter().copied().enumerate().max_by_key(|&(_, b)| COMMON.iter().position(|&c| c == b).unwrap_or(COMMON.len())).unwrap_or((0, 0));
let mut i = k;
while let Some(p) = hay.get(i..).and_then(|rest| position_of(rest, rare)) {
let start = i + p - k;
if hay.get(start..start + needle.len()) == Some(needle) {
return Some(start + from);
}
i += p + 1;
}
None
}
/// The first position of `byte` in `hay`. Whole 32-byte blocks are tested without branching, which
/// the compiler turns into vector compares; only a block that holds the byte is searched.
fn position_of(hay: &[u8], byte: u8) -> Option<usize> {
const BLOCK: usize = 32;
let (blocks, rest) = hay.as_chunks::<BLOCK>();
for (i, block) in blocks.iter().enumerate() {
if block.iter().fold(false, |any, &b| any | (b == byte)) {
return block.iter().position(|&b| b == byte).map(|p| i * BLOCK + p);
}
}
rest.iter().position(|&b| b == byte).map(|p| blocks.len() * BLOCK + p)
}
fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
+1
View File
@@ -4,5 +4,6 @@ mod bounded_writer;
mod corpus;
mod encryption;
mod proptests;
mod reconstruct;
mod shared_streams;
mod writer;
+57
View File
@@ -0,0 +1,57 @@
//! Rebuilding the cross-reference index of a damaged file finds the objects inside object
//! streams however the stream's type is written.
use std::sync::Arc;
use pdfcraft_cos::{Document, Object};
/// A file whose `startxref` points nowhere, with three object streams (type written plainly, as
/// `/Obj#53tm`, and after a string that contains the word `stream`), and one ordinary stream
/// whose data mentions `ObjStm` and `#`.
fn damaged() -> Vec<u8> {
let mut out = b"%PDF-1.5\n".to_vec();
let mut add = |n: u32, body: &[u8]| {
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let objstm = |dict_prefix: &str, members: &[(u32, &str)]| {
let mut header = String::new();
let mut body = String::new();
for (n, text) in members {
header.push_str(&format!("{n} {} ", body.len()));
body.push_str(text);
body.push('\n');
}
let mut b =
format!("<< {dict_prefix} /N {} /First {} /Length {} >>\nstream\n", members.len(), header.len(), header.len() + body.len()).into_bytes();
b.extend_from_slice(header.as_bytes());
b.extend_from_slice(body.as_bytes());
b.extend_from_slice(b"\nendstream");
b
};
add(1, b"<< /Type /Catalog /Pages 2 0 R >>");
add(2, b"<< /Type /Pages /Kids [] /Count 0 >>");
add(3, &objstm("/Type /ObjStm", &[(10, "<< /From 3 >>"), (11, "[3]")]));
add(4, &objstm("/Type /Obj#53tm", &[(12, "<< /From 4 >>")]));
add(5, &objstm("/Note (a stream) /Type /ObjStm", &[(13, "<< /From 5 >>")]));
let data = b"/Type /ObjStm # 14 0 << /From 6 >>";
let mut plain = format!("<< /Length {} >>\nstream\n", data.len()).into_bytes();
plain.extend_from_slice(data);
plain.extend_from_slice(b"\nendstream");
add(6, &plain);
out.extend_from_slice(b"trailer\n<< /Size 20 /Root 1 0 R >>\nstartxref\n99999999\n%%EOF\n");
out
}
#[test]
fn objects_in_object_streams_are_found_however_the_type_is_written() {
let doc = Document::open(Arc::new(damaged())).expect("opens");
assert!(doc.reconstructed());
for n in [10, 11, 12, 13] {
let o = doc.try_get(n).unwrap_or_else(|e| panic!("object {n}: {e}"));
assert!(matches!(&*o, Object::Dict(_) | Object::Array(_)), "object {n} is {o:?}");
}
// The data of an ordinary stream is not an object stream, whatever it says.
assert!(!doc.object_numbers().contains(&14));
}