fix(cos): read a hybrid cross-reference stream once per chain

The materialized cross-reference reader reread a section's /XRefStm
for every revision that named it, decoding the stream and inserting
its rows each time, although the first read had already merged every
entry. Up to 10,000 revisions naming one large stream repeated that
work 10,000 times. A stream already read is skipped, with one repair
log line.
This commit is contained in:
aiongg
2026-10-22 15:41:37 +00:00
parent b122455705
commit 196be22b64
+41 -1
View File
@@ -496,6 +496,12 @@ impl ObjectReader {
}
}
#[cfg(test)]
thread_local! {
/// Cross-reference sections read on this thread.
static SECTION_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object
/// is measured in full.
const OWNED_BYTES_DEPTH: usize = 256;
@@ -1748,6 +1754,9 @@ impl Document {
let mut revisions = Vec::new();
let mut oddities = Vec::new();
let mut visited = HashSet::new();
// Hybrid streams already read: a newer section has merged every entry of one, so reading
// it again for each older section that names it adds nothing and repeats the decode.
let (mut hybrids, mut noted) = (HashSet::new(), HashSet::new());
let mut next = Some(first);
while let Some(off) = next.take() {
if !visited.insert(off) || visited.len() > 10_000 {
@@ -1778,7 +1787,12 @@ impl Document {
revisions.push(Revision { xref_offset: off, is_stream, header_relative });
// 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 hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0);
let repeated = hybrid.is_some_and(|x| !hybrids.insert(x));
if let Some(x) = hybrid.filter(|x| repeated && noted.insert(*x)) {
log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once"));
}
if let Some(x) = hybrid.filter(|_| !repeated) {
let mut stm = BTreeMap::new();
match self.read_section(x as usize, &mut stm, &mut odd, log) {
Ok(_) => {
@@ -1822,6 +1836,8 @@ impl Document {
odd: &mut Vec<u32>,
log: &mut Vec<String>,
) -> Result<(Dict, bool), CosError> {
#[cfg(test)]
SECTION_READS.with(|c| c.set(c.get() + 1));
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
@@ -3199,6 +3215,30 @@ endobj
out
}
/// Many revisions naming one hybrid /XRefStm read it once.
#[test]
fn a_hybrid_stream_named_by_many_revisions_is_read_once() {
let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
let revisions = 200;
for _ in 0..revisions {
let at = out.len();
out.extend_from_slice(
format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
);
prev = at;
}
let doc = Document::open(Arc::new(out)).unwrap();
SECTION_READS.with(|c| c.set(0));
let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
let reads = SECTION_READS.with(|c| c.get());
assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1);
assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3));
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
assert_compact_matches_materialized(&doc);
}
fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option<String> {
doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text())))
}