Skip to main content

pdf_syntax/
xref.rs

1//! Reading and querying the xref table of a PDF file.
2
3use crate::crypto::{DecryptionError, DecryptionTarget, Decryptor, get};
4use crate::data::Data;
5use crate::metadata::Metadata;
6use crate::object::Name;
7use crate::object::ObjectIdentifier;
8use crate::object::Stream;
9use crate::object::dict::keys::{
10    AUTHOR, CREATION_DATE, CREATOR, ENCRYPT, FIRST, ID, INDEX, INFO, KEYWORDS, MOD_DATE, N,
11    OCPROPERTIES, PAGES, PREV, PRODUCER, ROOT, SIZE, SUBJECT, TITLE, TYPE, VERSION, W, XREF_STM,
12};
13use crate::object::indirect::IndirectObject;
14use crate::object::{Array, MaybeRef};
15use crate::object::{DateTime, Dict};
16use crate::object::{Object, ObjectLike};
17use crate::pdf::{PdfLoadLimits, PdfVersion};
18use crate::reader::Reader;
19use crate::reader::{Readable, ReaderContext, ReaderExt};
20use crate::sync::{Arc, FxHashMap, RwLock, RwLockExt};
21use crate::{PdfData, object};
22use alloc::vec;
23use alloc::vec::Vec;
24use core::cmp::max;
25use core::iter;
26use core::ops::Deref;
27use log::{error, warn};
28
29pub(crate) const XREF_ENTRY_LEN: usize = 20;
30
31#[derive(Debug, Copy, Clone)]
32pub(crate) enum XRefError {
33    Unknown,
34    Encryption(DecryptionError),
35}
36
37/// Parse the "root" xref from the PDF.
38pub(crate) fn root_xref(
39    data: PdfData,
40    password: &[u8],
41    limits: PdfLoadLimits,
42) -> Result<XRef, XRefError> {
43    let mut xref_map = FxHashMap::default();
44    let xref_pos = find_last_xref_pos(data.as_ref()).ok_or(XRefError::Unknown)?;
45    let trailer =
46        populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?;
47
48    XRef::new(
49        data.clone(),
50        xref_map,
51        XRefInput::TrailerDictData(trailer),
52        false,
53        password,
54        limits,
55    )
56}
57
58/// Try to manually parse the PDF to build an xref table and trailer dictionary.
59pub(crate) fn fallback(data: PdfData, password: &[u8], limits: PdfLoadLimits) -> Option<XRef> {
60    warn!("xref table was invalid, trying to manually build xref table");
61    let (xref_map, xref_input) = fallback_xref_map(&data, password);
62
63    if let Some(xref_input) = xref_input {
64        warn!("rebuild xref table with {} entries", xref_map.len());
65
66        XRef::new(data.clone(), xref_map, xref_input, true, password, limits).ok()
67    } else {
68        warn!("couldn't find trailer dictionary, failed to rebuild xref table");
69
70        None
71    }
72}
73
74fn fallback_xref_map<'a>(data: &'a PdfData, password: &[u8]) -> (XrefMap, Option<XRefInput<'a>>) {
75    fallback_xref_map_inner(data, ReaderContext::dummy(), true, password)
76}
77
78fn fallback_xref_map_inner<'a>(
79    data: &'a PdfData,
80    mut dummy_ctx: ReaderContext<'a>,
81    recurse: bool,
82    password: &[u8],
83) -> (XrefMap, Option<XRefInput<'a>>) {
84    let mut xref_map = FxHashMap::default();
85    let mut trailer_dicts = vec![];
86    let mut root_ref = None;
87
88    let mut r = Reader::new(data.as_ref());
89
90    let mut last_obj_num = None;
91
92    loop {
93        let cur_pos = r.offset();
94
95        let mut old_r = r.clone();
96
97        if let Some(obj_id) = r.read::<ObjectIdentifier>(&dummy_ctx) {
98            let mut cloned = r.clone();
99            // Check that the object following it is actually valid before inserting it.
100            cloned.skip_white_spaces_and_comments();
101            if cloned.skip::<Object<'_>>(false).is_some() {
102                xref_map.insert(obj_id, EntryType::Normal(cur_pos));
103                last_obj_num = Some(obj_id);
104                dummy_ctx.set_obj_number(obj_id);
105            }
106        } else if let Some(dict) = r.read::<Dict<'_>>(&dummy_ctx) {
107            if dict.contains_key(ROOT) {
108                trailer_dicts.push(dict.clone());
109            }
110
111            if dict
112                .get::<Name>(TYPE)
113                .is_some_and(|n| n.as_str() == "Catalog")
114            {
115                root_ref = last_obj_num;
116            }
117
118            if let Some(stream) = old_r.read::<Stream<'_>>(&dummy_ctx)
119                && stream.dict().get::<Name>(TYPE).as_deref() == Some(b"ObjStm")
120                && let Some(data) = stream.decoded().ok()
121                && let Some(last_obj_num) = last_obj_num
122                && let Some(obj_stream) = ObjectStream::new(stream, &data, &dummy_ctx)
123            {
124                for (idx, (obj_num, _)) in obj_stream.offsets.iter().enumerate() {
125                    let id = ObjectIdentifier::new(*obj_num as i32, 0);
126                    // If we already found an entry for that object number that was not
127                    // inside an object stream. Somewhat arbitrary and maybe
128                    // we can do better, but that seems to work for the current
129                    // set of tests.
130                    if xref_map
131                        .get(&id)
132                        .is_none_or(|e| !matches!(e, &EntryType::Normal(_)))
133                    {
134                        xref_map.insert(
135                            id,
136                            EntryType::ObjStream(last_obj_num.obj_number as u32, idx as u32),
137                        );
138                    }
139                }
140            }
141        } else {
142            r.read_byte();
143        }
144
145        if r.at_end() {
146            break;
147        }
148    }
149
150    // Try to choose the right trailer dict by doing basic validation.
151    let mut trailer_dict = None;
152
153    for dict in trailer_dicts {
154        if let Some(root_id) = dict.get_raw::<Dict<'_>>(ROOT) {
155            let check = |dict: &Dict<'_>| -> bool { dict.contains_key(PAGES) };
156
157            match root_id {
158                MaybeRef::Ref(r) => match xref_map.get(&r.into()) {
159                    Some(EntryType::Normal(offset)) => {
160                        let mut reader = Reader::new(&data.as_ref()[*offset..]);
161
162                        if let Some(obj) =
163                            reader.read_with_context::<IndirectObject<Dict<'_>>>(&dummy_ctx)
164                            && check(&obj.clone().get())
165                        {
166                            trailer_dict = Some(dict);
167                        }
168                    }
169                    Some(EntryType::ObjStream(obj_num, idx)) => {
170                        if let Some(EntryType::Normal(offset)) =
171                            xref_map.get(&ObjectIdentifier::new(*obj_num as i32, 0))
172                        {
173                            let mut reader = Reader::new(&data.as_ref()[*offset..]);
174
175                            if let Some(stream) =
176                                reader.read_with_context::<IndirectObject<Stream<'_>>>(&dummy_ctx)
177                                && let Some(data) = stream.clone().get().decoded().ok()
178                                && let Some(object_stream) =
179                                    ObjectStream::new(stream.get(), &data, &dummy_ctx)
180                                && let Some(obj) = object_stream.get::<Dict<'_>>(*idx)
181                                && check(&obj)
182                            {
183                                trailer_dict = Some(dict);
184                            }
185                        }
186                    }
187                    _ => {}
188                },
189                MaybeRef::NotRef(d) => {
190                    if check(&d) {
191                        trailer_dict = Some(dict);
192                    }
193                }
194            }
195        }
196    }
197
198    let has_encryption = trailer_dict
199        .as_ref()
200        .is_some_and(|t| t.contains_key(ENCRYPT));
201
202    if has_encryption && recurse {
203        // The problem is that in this case, we have used a dummy reader context which does not have
204        // a decryptor. Therefore, we were unable to decrypt any of the object streams and missed
205        // all objects that are inside of such a stream. Therefore, we need to redo the process
206        // using a `ReaderContext` that does have the ability to decrypt.
207        if let Some(Ok(xref)) = trailer_dict.as_ref().map(|d| {
208            XRef::new(
209                data.clone(),
210                xref_map.clone(),
211                XRefInput::TrailerDictData(d.data()),
212                true,
213                password,
214                PdfLoadLimits::default(),
215            )
216        }) {
217            let ctx = ReaderContext::new(&xref, false);
218            let (patched_map, _) = fallback_xref_map_inner(data, ctx, false, password);
219            xref_map = patched_map;
220        }
221    }
222
223    if let Some(trailer_dict_data) = trailer_dict.map(|d| d.data()) {
224        (
225            xref_map,
226            Some(XRefInput::TrailerDictData(trailer_dict_data)),
227        )
228    } else if let Some(root_ref) = root_ref {
229        (xref_map, Some(XRefInput::RootRef(root_ref)))
230    } else {
231        (xref_map, None)
232    }
233}
234
235const DUMMY_XREF: XRef = XRef(Inner::Dummy);
236
237/// An xref table.
238#[derive(Debug, Clone)]
239pub struct XRef(Inner);
240
241impl XRef {
242    fn new(
243        data: PdfData,
244        xref_map: XrefMap,
245        input: XRefInput<'_>,
246        repaired: bool,
247        password: &[u8],
248        load_limits: PdfLoadLimits,
249    ) -> Result<Self, XRefError> {
250        // This is a bit hacky, but the problem is we can't read the resolved trailer dictionary
251        // before we actually created the xref struct. So we first create it using dummy data
252        // and then populate the data.
253        let trailer_data = TrailerData::dummy();
254
255        let mut xref = Self(Inner::Some(Arc::new(SomeRepr {
256            data: Arc::new(Data::new(data)),
257            map: Arc::new(RwLock::new(MapRepr { xref_map, repaired })),
258            decryptor: Arc::new(Decryptor::None),
259            has_ocgs: false,
260            metadata: Arc::new(Metadata::default()),
261            trailer_data,
262            password: password.to_vec(),
263            load_limits,
264        })));
265
266        // We read the trailer twice, once to determine the encryption used and then a second
267        // time to resolve the catalog dictionary, etc. This allows us to support catalog dictionaries
268        // that are stored in an encrypted object stream.
269
270        let decryptor = {
271            match input {
272                XRefInput::TrailerDictData(trailer_dict_data) => {
273                    let mut r = Reader::new(trailer_dict_data);
274
275                    let trailer_dict = r
276                        .read_with_context::<Dict<'_>>(&ReaderContext::new(&xref, false))
277                        .ok_or(XRefError::Unknown)?;
278
279                    get_decryptor(&trailer_dict, password)?
280                }
281                XRefInput::RootRef(_) => Decryptor::None,
282            }
283        };
284
285        match &mut xref.0 {
286            Inner::Dummy => unreachable!(),
287            Inner::Some(r) => {
288                let mutable = Arc::make_mut(r);
289                mutable.decryptor = Arc::new(decryptor.clone());
290            }
291        }
292
293        let (trailer_data, has_ocgs, metadata) = match input {
294            XRefInput::TrailerDictData(trailer_dict_data) => {
295                let mut r = Reader::new(trailer_dict_data);
296
297                let trailer_dict = r
298                    .read_with_context::<Dict<'_>>(&ReaderContext::new(&xref, false))
299                    .ok_or(XRefError::Unknown)?;
300
301                let root_ref = trailer_dict.get_ref(ROOT).ok_or(XRefError::Unknown)?;
302                let root = trailer_dict
303                    .get::<Dict<'_>>(ROOT)
304                    .ok_or(XRefError::Unknown)?;
305                let metadata = trailer_dict
306                    .get::<Dict<'_>>(INFO)
307                    .map(|d| parse_metadata(&d))
308                    .unwrap_or_default();
309                let pages_ref = root.get_ref(PAGES).ok_or(XRefError::Unknown)?;
310                let has_ocgs = root.get::<Dict<'_>>(OCPROPERTIES).is_some();
311                let version = root
312                    .get::<Name>(VERSION)
313                    .and_then(|v| PdfVersion::from_bytes(v.deref()));
314
315                let td = TrailerData {
316                    pages_ref: pages_ref.into(),
317                    root_ref: root_ref.into(),
318                    version,
319                };
320
321                (td, has_ocgs, metadata)
322            }
323            XRefInput::RootRef(root_ref) => {
324                let root = xref.get::<Dict<'_>>(root_ref).ok_or(XRefError::Unknown)?;
325                let pages_ref = root.get_ref(PAGES).ok_or(XRefError::Unknown)?;
326
327                let td = TrailerData {
328                    pages_ref: pages_ref.into(),
329                    root_ref,
330                    version: None,
331                };
332
333                (td, false, Metadata::default())
334            }
335        };
336
337        match &mut xref.0 {
338            Inner::Dummy => unreachable!(),
339            Inner::Some(r) => {
340                let mutable = Arc::make_mut(r);
341                mutable.trailer_data = trailer_data;
342                mutable.decryptor = Arc::new(decryptor);
343                mutable.has_ocgs = has_ocgs;
344                mutable.metadata = Arc::new(metadata);
345            }
346        }
347
348        Ok(xref)
349    }
350
351    fn is_repaired(&self) -> bool {
352        match &self.0 {
353            Inner::Dummy => false,
354            Inner::Some(r) => {
355                let locked = r.map.get();
356                locked.repaired
357            }
358        }
359    }
360
361    pub(crate) fn dummy() -> &'static Self {
362        &DUMMY_XREF
363    }
364
365    pub(crate) fn load_limits(&self) -> PdfLoadLimits {
366        match &self.0 {
367            Inner::Dummy => PdfLoadLimits::default(),
368            Inner::Some(r) => r.load_limits,
369        }
370    }
371
372    pub(crate) fn len(&self) -> usize {
373        match &self.0 {
374            Inner::Dummy => 0,
375            Inner::Some(r) => r.map.get().xref_map.len(),
376        }
377    }
378
379    pub(crate) fn trailer_data(&self) -> &TrailerData {
380        match &self.0 {
381            Inner::Dummy => unreachable!(),
382            Inner::Some(r) => &r.trailer_data,
383        }
384    }
385
386    /// Number of cached parsed object-stream offset tables. QF2-B test
387    /// hook; not part of the public API.
388    #[cfg(test)]
389    pub(crate) fn object_stream_offsets_cache_len(&self) -> usize {
390        match &self.0 {
391            Inner::Dummy => 0,
392            Inner::Some(r) => r.data.object_stream_offsets_cache_len(),
393        }
394    }
395
396    pub(crate) fn metadata(&self) -> &Metadata {
397        match &self.0 {
398            Inner::Dummy => unreachable!(),
399            Inner::Some(r) => &r.metadata,
400        }
401    }
402
403    /// Return the object ID of the root dictionary.
404    pub fn root_id(&self) -> ObjectIdentifier {
405        self.trailer_data().root_ref
406    }
407
408    /// Whether the PDF has optional content groups.
409    pub fn has_optional_content_groups(&self) -> bool {
410        match &self.0 {
411            Inner::Dummy => false,
412            Inner::Some(r) => r.has_ocgs,
413        }
414    }
415
416    pub(crate) fn objects(&self) -> impl IntoIterator<Item = Object<'_>> + '_ {
417        match &self.0 {
418            Inner::Dummy => unimplemented!(),
419            Inner::Some(r) => {
420                let locked = r.map.get();
421                let mut elements = locked
422                    .xref_map
423                    .iter()
424                    .map(|(id, e)| {
425                        let offset = match e {
426                            EntryType::Normal(o) => (*o, 0),
427                            EntryType::ObjStream(id, index) => {
428                                if let Some(EntryType::Normal(offset)) =
429                                    locked.xref_map.get(&ObjectIdentifier::new(*id as i32, 0))
430                                {
431                                    (*offset, *index)
432                                } else {
433                                    (usize::MAX, 0)
434                                }
435                            }
436                        };
437
438                        (*id, offset)
439                    })
440                    .collect::<Vec<_>>();
441
442                // Try to yield in the order the objects appeared in the
443                // PDF.
444                elements.sort_by_key(|e1| e1.1);
445
446                let mut iter = elements.into_iter();
447
448                iter::from_fn(move || {
449                    for next in iter.by_ref() {
450                        if let Some(obj) = self.get_with(next.0, &ReaderContext::new(self, false)) {
451                            return Some(obj);
452                        } else {
453                            // Skip invalid objects.
454                            continue;
455                        }
456                    }
457
458                    None
459                })
460            }
461        }
462    }
463
464    pub(crate) fn repair(&self) {
465        let Inner::Some(r) = &self.0 else {
466            unreachable!();
467        };
468
469        let mut locked = r
470            .map
471            .try_put()
472            .expect("xref repair: map lock not contended");
473        assert!(!locked.repaired);
474
475        let (xref_map, _) = fallback_xref_map(r.data.get(), &r.password);
476        locked.xref_map = xref_map;
477        locked.repaired = true;
478    }
479
480    #[inline]
481    pub(crate) fn needs_decryption(&self, ctx: &ReaderContext<'_>) -> bool {
482        match &self.0 {
483            Inner::Dummy => false,
484            Inner::Some(r) => {
485                if matches!(r.decryptor.as_ref(), Decryptor::None) {
486                    false
487                } else {
488                    !ctx.in_content_stream() && !ctx.in_object_stream()
489                }
490            }
491        }
492    }
493
494    #[inline]
495    pub(crate) fn decrypt(
496        &self,
497        id: ObjectIdentifier,
498        data: &[u8],
499        target: DecryptionTarget,
500    ) -> Option<Vec<u8>> {
501        match &self.0 {
502            Inner::Dummy => Some(data.to_vec()),
503            Inner::Some(r) => r.decryptor.decrypt(id, data, target),
504        }
505    }
506
507    /// Return the object with the given identifier.
508    #[allow(private_bounds)]
509    pub fn get<'a, T>(&'a self, id: ObjectIdentifier) -> Option<T>
510    where
511        T: ObjectLike<'a>,
512    {
513        let ctx = ReaderContext::new(self, false);
514        self.get_with(id, &ctx)
515    }
516
517    /// Return the object with the given identifier.
518    #[allow(private_bounds)]
519    pub(crate) fn get_with<'a, T>(
520        &'a self,
521        id: ObjectIdentifier,
522        ctx: &ReaderContext<'a>,
523    ) -> Option<T>
524    where
525        T: ObjectLike<'a>,
526    {
527        let Inner::Some(repr) = &self.0 else {
528            return None;
529        };
530
531        let locked = repr.map.try_get()?;
532
533        let mut r = Reader::new(repr.data.get().as_ref());
534
535        let entry = *locked.xref_map.get(&id).or({
536            // An indirect reference to an undefined object shall not be considered an error by a PDF processor; it
537            // shall be treated as a reference to the null object.
538            None
539        })?;
540        drop(locked);
541
542        let mut ctx = ctx.clone();
543        ctx.set_obj_number(id);
544        ctx.set_in_content_stream(false);
545
546        match entry {
547            EntryType::Normal(offset) => {
548                ctx.set_in_object_stream(false);
549                r.jump(offset);
550
551                if let Some(object) = r.read_with_context::<IndirectObject<T>>(&ctx) {
552                    if object.id() == &id {
553                        return Some(object.get());
554                    }
555                } else {
556                    // There is a valid object at the offset, it's just not of the type the caller
557                    // expected, which is fine.
558                    if r.skip_not_in_content_stream::<IndirectObject<Object<'_>>>()
559                        .is_some()
560                    {
561                        return None;
562                    }
563                };
564
565                // The xref table is broken, try to repair if not already repaired.
566                if self.is_repaired() {
567                    error!(
568                        "attempt was made at repairing xref, but object {id:?} still couldn't be read"
569                    );
570
571                    None
572                } else {
573                    warn!("broken xref, attempting to repair");
574
575                    self.repair();
576
577                    // Now try reading again.
578                    self.get_with::<T>(id, &ctx)
579                }
580            }
581            EntryType::ObjStream(obj_stram_gen_num, index) => {
582                // Generation number is implicitly 0.
583                let obj_stream_id = ObjectIdentifier::new(obj_stram_gen_num as i32, 0);
584
585                if obj_stream_id == id {
586                    warn!("cycle detected in object stream");
587
588                    return None;
589                }
590
591                let stream = self.get_with::<Stream<'_>>(obj_stream_id, &ctx)?;
592                let data = repr.data.get_with(obj_stream_id, &ctx)?;
593                // QF2-B: re-use a cached `(obj_num, offset)` index table if
594                // we've already parsed this `/ObjStm` once for this
595                // document. The cache lives on the per-document `Data` and
596                // is dropped together with it.
597                let offsets = repr
598                    .data
599                    .get_object_stream_offsets_or_init(obj_stream_id, || {
600                        parse_object_stream_offsets(&stream, data)
601                    })?;
602                let object_stream = ObjectStream::from_cached_offsets(data, &ctx, offsets);
603                object_stream.get(index)
604            }
605        }
606    }
607}
608
609/// An input that is passed to the xref constructor so that we can fully resolve
610/// the PDF.
611#[derive(Debug, Copy, Clone)]
612pub(crate) enum XRefInput<'a> {
613    /// This option is going to be uesd in 99.999% of the case. It contains the
614    /// raw data of the trailer dictionary which is then going to be processed.
615    TrailerDictData(&'a [u8]),
616    /// In case the trailer dictionary could not be read (for example because
617    /// it is cut-off), we just pass the object ID of the root dictionary
618    /// in case we have found one, and try our best to build the PDF just
619    /// with the information we have there.
620    ///
621    /// Note that this won't work if the document is encrypted, as we
622    /// can't access the crypto dictionary.
623    RootRef(ObjectIdentifier),
624}
625
626pub(crate) fn find_last_xref_pos(data: &[u8]) -> Option<usize> {
627    let mut finder = Reader::new(data);
628    let mut pos = finder.len().checked_sub(1)?;
629    finder.jump(pos);
630
631    let needle = b"startxref";
632
633    loop {
634        if finder.forward_tag(needle).is_some() {
635            finder.skip_white_spaces_and_comments();
636
637            let offset = finder.read_without_context::<i32>()?.try_into().ok()?;
638
639            return Some(offset);
640        }
641
642        pos = pos.checked_sub(1)?;
643        finder.jump(pos);
644    }
645}
646
647/// A type of xref entry.
648#[derive(Debug, PartialEq, Eq, Clone, Copy)]
649enum EntryType {
650    /// An indirect object that is at a specific offset in the original data.
651    Normal(usize),
652    /// An indirect object that is part of an object stream. First number indicates the object
653    /// number of the _object stream_ (the generation number is always 0), the second number indicates
654    /// the index in the object stream.
655    ObjStream(u32, u32),
656}
657
658type XrefMap = FxHashMap<ObjectIdentifier, EntryType>;
659
660/// Representation of a proper xref table.
661#[derive(Debug)]
662struct MapRepr {
663    xref_map: XrefMap,
664    repaired: bool,
665}
666
667#[derive(Debug, Copy, Clone)]
668pub(crate) struct TrailerData {
669    pub(crate) pages_ref: ObjectIdentifier,
670    pub(crate) root_ref: ObjectIdentifier,
671    pub(crate) version: Option<PdfVersion>,
672}
673
674impl TrailerData {
675    pub(crate) fn dummy() -> Self {
676        Self {
677            pages_ref: ObjectIdentifier::new(0, 0),
678            root_ref: ObjectIdentifier::new(0, 0),
679            version: None,
680        }
681    }
682}
683
684#[derive(Debug, Clone)]
685struct SomeRepr {
686    data: Arc<Data>,
687    map: Arc<RwLock<MapRepr>>,
688    metadata: Arc<Metadata>,
689    decryptor: Arc<Decryptor>,
690    has_ocgs: bool,
691    password: Vec<u8>,
692    trailer_data: TrailerData,
693    load_limits: PdfLoadLimits,
694}
695
696#[derive(Debug, Clone)]
697enum Inner {
698    /// A dummy xref table that doesn't have any entries.
699    Dummy,
700    /// A proper xref table.
701    Some(Arc<SomeRepr>),
702}
703
704#[derive(Debug)]
705struct XRefEntry {
706    offset: usize,
707    gen_number: i32,
708    used: bool,
709}
710
711impl XRefEntry {
712    pub(crate) fn read(data: &[u8]) -> Option<Self> {
713        #[inline(always)]
714        fn parse_u32(data: &[u8]) -> Option<u32> {
715            let mut accum = 0_u32;
716
717            for byte in data {
718                accum = accum.checked_mul(10)?;
719
720                match *byte {
721                    b'0'..=b'9' => accum = accum.checked_add((*byte - b'0') as u32)?,
722                    _ => return None,
723                }
724            }
725
726            Some(accum)
727        }
728
729        let offset = parse_u32(&data[0..10])? as usize;
730        let gen_number = i32::try_from(parse_u32(&data[11..16])?).ok()?;
731
732        let used = data[17] == b'n';
733
734        Some(Self {
735            offset,
736            gen_number,
737            used,
738        })
739    }
740}
741
742/// Maximum depth for following xref Prev/XRefStm chains to prevent stack
743/// overflow on circular or deeply chained xref tables.
744const MAX_XREF_CHAIN_DEPTH: usize = 64;
745
746fn populate_xref_impl<'a>(data: &'a [u8], pos: usize, xref_map: &mut XrefMap) -> Option<&'a [u8]> {
747    populate_xref_depth(data, pos, xref_map, 0)
748}
749
750fn populate_xref_depth<'a>(
751    data: &'a [u8],
752    pos: usize,
753    xref_map: &mut XrefMap,
754    depth: usize,
755) -> Option<&'a [u8]> {
756    if depth > MAX_XREF_CHAIN_DEPTH {
757        log::warn!("Xref chain depth exceeds {MAX_XREF_CHAIN_DEPTH}, stopping traversal");
758        return None;
759    }
760    let mut reader = Reader::new(data);
761    reader.jump(pos);
762    // In case the position points to before the object number of a xref stream.
763    reader.skip_white_spaces_and_comments();
764
765    let mut r2 = reader.clone();
766    if reader
767        .clone()
768        .read_without_context::<ObjectIdentifier>()
769        .is_some()
770    {
771        populate_from_xref_stream(data, &mut r2, xref_map, depth)
772    } else {
773        populate_from_xref_table(data, &mut r2, xref_map, depth)
774    }
775}
776
777pub(super) struct SubsectionHeader {
778    pub(super) start: u32,
779    pub(super) num_entries: u32,
780}
781
782impl Readable<'_> for SubsectionHeader {
783    fn read(r: &mut Reader<'_>, _: &ReaderContext<'_>) -> Option<Self> {
784        r.skip_white_spaces();
785        let start = r.read_without_context::<u32>()?;
786        r.skip_white_spaces();
787        let num_entries = r.read_without_context::<u32>()?;
788        r.skip_white_spaces();
789
790        Some(Self { start, num_entries })
791    }
792}
793
794/// Populate the xref table, and return the trailer dict.
795fn populate_from_xref_table<'a>(
796    data: &'a [u8],
797    reader: &mut Reader<'a>,
798    insert_map: &mut XrefMap,
799    depth: usize,
800) -> Option<&'a [u8]> {
801    let trailer = {
802        let mut reader = reader.clone();
803        read_xref_table_trailer(&mut reader, &ReaderContext::dummy())?
804    };
805
806    reader.skip_white_spaces();
807    reader.forward_tag(b"xref")?;
808    reader.skip_white_spaces();
809
810    let mut max_obj = 0;
811
812    if let Some(prev) = trailer.get::<i32>(PREV) {
813        // First insert the entries from any previous xref tables.
814        populate_xref_depth(data, prev as usize, insert_map, depth + 1)?;
815    }
816
817    // In hybrid files, entries in `XRefStm` should have higher priority, therefore we insert them
818    // after looking at `PREV`.
819    if let Some(xref_stm) = trailer.get::<i32>(XREF_STM) {
820        populate_xref_depth(data, xref_stm as usize, insert_map, depth + 1)?;
821    }
822
823    while let Some(header) = reader.read_without_context::<SubsectionHeader>() {
824        reader.skip_white_spaces();
825
826        let start = header.start;
827        // Both come from the file. Ported from hayro upstream
828        // (LaurenzV/hayro#1194).
829        let end = start.checked_add(header.num_entries)?;
830
831        for obj_number in start..end {
832            max_obj = max(max_obj, obj_number);
833            let bytes = reader.read_bytes(XREF_ENTRY_LEN)?;
834            let entry = XRefEntry::read(bytes)?;
835
836            // Specification says we should ignore any object number > SIZE, but probably
837            // not important?
838            if entry.used {
839                insert_map.insert(
840                    ObjectIdentifier::new(obj_number as i32, entry.gen_number),
841                    EntryType::Normal(entry.offset),
842                );
843            }
844        }
845    }
846
847    Some(trailer.data())
848}
849
850fn populate_from_xref_stream<'a>(
851    data: &'a [u8],
852    reader: &mut Reader<'a>,
853    insert_map: &mut XrefMap,
854    depth: usize,
855) -> Option<&'a [u8]> {
856    let stream = reader
857        .read_with_context::<IndirectObject<Stream<'_>>>(&ReaderContext::dummy())?
858        .get();
859
860    if let Some(prev) = stream.dict().get::<i32>(PREV) {
861        // First insert the entries from any previous xref tables.
862        let _ = populate_xref_depth(data, prev as usize, insert_map, depth + 1)?;
863    }
864
865    let size = stream.dict().get::<u32>(SIZE)?;
866
867    let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?;
868
869    if f2_len > size_of::<u64>() as u8 {
870        error!("xref offset length is larger than the allowed limit");
871
872        return None;
873    }
874
875    // Do such files exist?
876    if f1_len != 1 {
877        warn!("first field in xref stream was longer than 1");
878    }
879
880    let xref_data = stream.decoded().ok()?;
881    let mut xref_reader = Reader::new(xref_data.as_ref());
882
883    if let Some(arr) = stream.dict().get::<Array<'_>>(INDEX) {
884        let iter = arr.iter::<(u32, u32)>();
885
886        for (start, num_elements) in iter {
887            xref_stream_subsection(
888                &mut xref_reader,
889                start,
890                num_elements,
891                f1_len,
892                f2_len,
893                f3_len,
894                insert_map,
895            )?;
896        }
897    } else {
898        xref_stream_subsection(
899            &mut xref_reader,
900            0,
901            size,
902            f1_len,
903            f2_len,
904            f3_len,
905            insert_map,
906        )?;
907    }
908
909    Some(stream.dict().data())
910}
911
912fn xref_stream_num(data: &[u8]) -> Option<u32> {
913    Some(match data.len() {
914        0 => return None,
915        1 => u8::from_be(data[0]) as u32,
916        2 => u16::from_be_bytes(data[0..2].try_into().ok()?) as u32,
917        3 => u32::from_be_bytes([0, data[0], data[1], data[2]]),
918        4 => u32::from_be_bytes(data[0..4].try_into().ok()?),
919        8 => {
920            if let Ok(num) = u32::try_from(u64::from_be_bytes(data[0..8].try_into().ok()?)) {
921                return Some(num);
922            } else {
923                warn!("xref stream number is too large");
924
925                return None;
926            }
927        }
928        n => {
929            warn!("invalid xref stream number {n}");
930
931            return None;
932        }
933    })
934}
935
936fn xref_stream_subsection<'a>(
937    xref_reader: &mut Reader<'a>,
938    start: u32,
939    num_elements: u32,
940    f1_len: u8,
941    f2_len: u8,
942    f3_len: u8,
943    insert_map: &mut XrefMap,
944) -> Option<()> {
945    for i in 0..num_elements {
946        let f_type = if f1_len == 0 {
947            1
948        } else {
949            // We assume a length of 1.
950            xref_reader.read_bytes(1)?[0]
951        };
952
953        let obj_number = start + i;
954
955        match f_type {
956            // We don't care about free objects.
957            0 => {
958                xref_reader.skip_bytes(f2_len as usize + f3_len as usize)?;
959            }
960            1 => {
961                let offset = if f2_len > 0 {
962                    let data = xref_reader.read_bytes(f2_len as usize)?;
963                    xref_stream_num(data)?
964                } else {
965                    0
966                };
967
968                let gen_number = if f3_len > 0 {
969                    let data = xref_reader.read_bytes(f3_len as usize)?;
970                    xref_stream_num(data)?
971                } else {
972                    0
973                };
974
975                insert_map.insert(
976                    ObjectIdentifier::new(obj_number as i32, gen_number as i32),
977                    EntryType::Normal(offset as usize),
978                );
979            }
980            2 => {
981                let obj_stream_number = {
982                    let data = xref_reader.read_bytes(f2_len as usize)?;
983                    xref_stream_num(data)?
984                };
985                let gen_number = 0;
986                let index = if f3_len > 0 {
987                    let data = xref_reader.read_bytes(f3_len as usize)?;
988                    xref_stream_num(data)?
989                } else {
990                    0
991                };
992
993                insert_map.insert(
994                    ObjectIdentifier::new(obj_number as i32, gen_number),
995                    EntryType::ObjStream(obj_stream_number, index),
996                );
997            }
998            _ => {
999                warn!("xref has unknown field type {f_type}");
1000
1001                return None;
1002            }
1003        }
1004    }
1005
1006    Some(())
1007}
1008
1009fn read_xref_table_trailer<'a>(
1010    reader: &mut Reader<'a>,
1011    ctx: &ReaderContext<'a>,
1012) -> Option<Dict<'a>> {
1013    reader.skip_white_spaces();
1014    reader.forward_tag(b"xref")?;
1015    reader.skip_white_spaces();
1016
1017    while let Some(header) = reader.read_without_context::<SubsectionHeader>() {
1018        let len = XREF_ENTRY_LEN.checked_mul(header.num_entries as usize)?;
1019        reader.jump(reader.offset().checked_add(len)?);
1020    }
1021
1022    reader.skip_white_spaces();
1023    reader.forward_tag(b"trailer")?;
1024    reader.skip_white_spaces();
1025
1026    reader.read_with_context::<Dict<'_>>(ctx)
1027}
1028
1029fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result<Decryptor, XRefError> {
1030    if let Some(encryption_dict) = trailer_dict.get::<Dict<'_>>(ENCRYPT) {
1031        let id = if let Some(id) = trailer_dict
1032            .get::<Array<'_>>(ID)
1033            .and_then(|a| a.flex_iter().next::<object::String>())
1034        {
1035            id.to_vec()
1036        } else {
1037            // Assume an empty ID entry.
1038            vec![]
1039        };
1040
1041        get(&encryption_dict, &id, password).map_err(XRefError::Encryption)
1042    } else {
1043        Ok(Decryptor::None)
1044    }
1045}
1046
1047/// Parse the `(obj_num, absolute_byte_offset)` index table that lives at the
1048/// start of a compressed `/ObjStm`.
1049///
1050/// Returns `None` if the stream dict is missing `/N` / `/First`, or if the
1051/// header is truncated. The returned table is the same value that an
1052/// `ObjectStream` would have populated internally before this was split out
1053/// (QF2-B). Splitting it allows the result to be cached per-document; see
1054/// [`crate::data::Data::get_object_stream_offsets_or_init`].
1055fn parse_object_stream_offsets(
1056    inner: &Stream<'_>,
1057    data: &[u8],
1058) -> Option<crate::data::ObjectStreamOffsets> {
1059    let num_objects = inner.dict().get::<usize>(N)?;
1060    let first_offset = inner.dict().get::<usize>(FIRST)?;
1061
1062    let mut r = Reader::new(data);
1063    let mut offsets = Vec::with_capacity(num_objects);
1064
1065    for _ in 0..num_objects {
1066        r.skip_white_spaces_and_comments();
1067        // Skip object number
1068        let obj_num = r.read_without_context::<u32>()?;
1069        r.skip_white_spaces_and_comments();
1070        let relative_offset = r.read_without_context::<usize>()?;
1071        offsets.push((obj_num, first_offset + relative_offset));
1072    }
1073
1074    Some(offsets)
1075}
1076
1077/// Holds a borrowed view onto the decoded bytes of an `/ObjStm` plus a
1078/// (possibly cached) parsed offset table.
1079///
1080/// QF2-B: `offsets` is now an `Arc<...>` so the same allocation can be
1081/// returned from the per-document cache on subsequent lookups, eliminating
1082/// the linear re-parse hot loop reported in
1083/// `QF1_A_FLAMEGRAPH_REPORT.md` (449× per main thread on the
1084/// `scan_for_xfa` fallback path).
1085struct ObjectStream<'a> {
1086    data: &'a [u8],
1087    ctx: ReaderContext<'a>,
1088    offsets: Arc<crate::data::ObjectStreamOffsets>,
1089}
1090
1091impl<'a> ObjectStream<'a> {
1092    /// Build a fresh `ObjectStream` by parsing the index table inline (no
1093    /// caching). Used by the xref-repair / trailer-fallback paths that
1094    /// don't have access to a `Data` cache.
1095    fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option<Self> {
1096        let offsets = Arc::new(parse_object_stream_offsets(&inner, data)?);
1097
1098        let mut ctx = ctx.clone();
1099        ctx.set_in_object_stream(true);
1100
1101        Some(Self { data, ctx, offsets })
1102    }
1103
1104    /// Build an `ObjectStream` that reuses an already-parsed offsets table
1105    /// (typically retrieved from the per-document cache). Cheap — does no
1106    /// header scan.
1107    fn from_cached_offsets(
1108        data: &'a [u8],
1109        ctx: &ReaderContext<'a>,
1110        offsets: Arc<crate::data::ObjectStreamOffsets>,
1111    ) -> Self {
1112        let mut ctx = ctx.clone();
1113        ctx.set_in_object_stream(true);
1114
1115        Self { data, ctx, offsets }
1116    }
1117
1118    fn get<T>(&self, index: u32) -> Option<T>
1119    where
1120        T: ObjectLike<'a>,
1121    {
1122        let offset = self.offsets.get(index as usize)?.1;
1123        let mut r = Reader::new(self.data);
1124        r.jump(offset);
1125        r.skip_white_spaces_and_comments();
1126
1127        r.read_with_context::<T>(&self.ctx)
1128    }
1129}
1130
1131fn parse_metadata(info_dict: &Dict<'_>) -> Metadata {
1132    Metadata {
1133        creation_date: info_dict
1134            .get::<object::String>(CREATION_DATE)
1135            .and_then(|c| DateTime::from_bytes(&c)),
1136        modification_date: info_dict
1137            .get::<object::String>(MOD_DATE)
1138            .and_then(|c| DateTime::from_bytes(&c)),
1139        title: info_dict.get::<object::String>(TITLE).map(|t| t.to_vec()),
1140        author: info_dict.get::<object::String>(AUTHOR).map(|t| t.to_vec()),
1141        subject: info_dict.get::<object::String>(SUBJECT).map(|t| t.to_vec()),
1142        keywords: info_dict
1143            .get::<object::String>(KEYWORDS)
1144            .map(|t| t.to_vec()),
1145        creator: info_dict.get::<object::String>(CREATOR).map(|t| t.to_vec()),
1146        producer: info_dict
1147            .get::<object::String>(PRODUCER)
1148            .map(|t| t.to_vec()),
1149    }
1150}
1151
1152#[cfg(test)]
1153mod qf2b_objectstream_cache_tests {
1154    //! QF2-B — end-to-end coverage for the per-document ObjectStream
1155    //! offsets cache, using a real `/ObjStm`-containing fixture.
1156
1157    use crate::pdf::Pdf;
1158    use crate::xref::parse_object_stream_offsets;
1159
1160    /// A PDF 1.5 document carrying an `/ObjStm`, built here rather than read
1161    /// from the corpus.
1162    ///
1163    /// This used to name a corpus file and `return` when it was missing — so in
1164    /// a tree without the goldens the test passed while asserting nothing, and
1165    /// the corpus name travelled into published source. Building the fixture
1166    /// fixes both: its provenance is this function, and it exists everywhere
1167    /// the test runs.
1168    ///
1169    /// Two objects (catalog and pages) packed into an object stream, addressed
1170    /// by an xref stream — the smallest shape that makes the offsets cache do
1171    /// its work.
1172    fn objstm_fixture() -> Vec<u8> {
1173        fn deflate(data: &[u8]) -> Vec<u8> {
1174            use std::io::Write;
1175            let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
1176            e.write_all(data).expect("in-memory write");
1177            e.finish().expect("in-memory finish")
1178        }
1179
1180        let catalog = b"<< /Type /Catalog /Pages 3 0 R >>";
1181        let pages = b"<< /Type /Pages /Kids [] /Count 0 >>";
1182        let pairs = format!("2 0 3 {}", catalog.len() + 1);
1183        let first = pairs.len() + 1;
1184        let mut payload = pairs.into_bytes();
1185        payload.push(b'\n');
1186        payload.extend_from_slice(catalog);
1187        payload.push(b' ');
1188        payload.extend_from_slice(pages);
1189        let body = deflate(&payload);
1190
1191        let mut out = b"%PDF-1.5\n".to_vec();
1192        let objstm_at = out.len();
1193        out.extend_from_slice(
1194            format!(
1195                "1 0 obj\n<< /Type /ObjStm /N 2 /First {first} /Length {} \
1196                 /Filter /FlateDecode >>\nstream\n",
1197                body.len()
1198            )
1199            .as_bytes(),
1200        );
1201        out.extend_from_slice(&body);
1202        out.extend_from_slice(b"\nendstream\nendobj\n");
1203
1204        let xref_at = out.len();
1205        let entry = |t: u8, a: usize, b: u8| {
1206            let mut v = vec![t];
1207            v.extend_from_slice(&(a as u16).to_be_bytes());
1208            v.push(b);
1209            v
1210        };
1211        let mut rows = Vec::new();
1212        rows.extend(entry(0, 0, 255));
1213        rows.extend(entry(1, objstm_at, 0));
1214        rows.extend(entry(2, 1, 0)); // object 2 lives in stream 1, index 0
1215        rows.extend(entry(2, 1, 1)); // object 3 lives in stream 1, index 1
1216        rows.extend(entry(1, xref_at, 0));
1217        let xbody = deflate(&rows);
1218        out.extend_from_slice(
1219            format!(
1220                "4 0 obj\n<< /Type /XRef /Size 5 /W [1 2 1] /Root 2 0 R /Length {} \
1221                 /Filter /FlateDecode >>\nstream\n",
1222                xbody.len()
1223            )
1224            .as_bytes(),
1225        );
1226        out.extend_from_slice(&xbody);
1227        out.extend_from_slice(b"\nendstream\nendobj\n");
1228        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
1229        out
1230    }
1231
1232    /// The fixture is built, so failing to parse it is a fault in this crate,
1233    /// not a missing file. It used to be an `Option` that the tests turned into
1234    /// an early `return`, which is how they passed while asserting nothing.
1235    fn load_fixture() -> Pdf {
1236        Pdf::new(objstm_fixture()).expect("the generated /ObjStm fixture must parse")
1237    }
1238
1239    #[test]
1240    fn qf2b_objstm_cache_populates_and_is_stable_on_repeat() {
1241        let pdf = load_fixture();
1242
1243        let xref = pdf.xref();
1244
1245        // `Pdf::new` resolves the trailer and catalog during construction;
1246        // for PDF 1.5+ files those typically live in an `/ObjStm` so the
1247        // cache is already non-empty at this point. That is itself
1248        // evidence that the cache is active.
1249        let after_construction = xref.object_stream_offsets_cache_len();
1250        assert!(
1251            after_construction >= 1,
1252            "fixture is a PDF 1.5+ doc with /ObjStm; at least one offsets table should already be cached after construction; got {after_construction}"
1253        );
1254
1255        // Resolve the catalog explicitly — must not grow the cache because
1256        // the /ObjStm carrying the catalog is already a hit.
1257        let _: Option<crate::object::Dict<'_>> = xref.get(xref.root_id());
1258        assert_eq!(
1259            xref.object_stream_offsets_cache_len(),
1260            after_construction,
1261            "repeated resolution of the same indirect object must reuse the cached offsets table"
1262        );
1263
1264        // Resolve a number of additional indirect objects. Each new
1265        // `/ObjStm` we touch may add one entry, but re-touching anything
1266        // already seen must not.
1267        for raw in 1..=20i32 {
1268            let id = crate::object::ObjectIdentifier::new(raw, 0);
1269            let _: Option<crate::object::Dict<'_>> = xref.get(id);
1270        }
1271        let after_scan = xref.object_stream_offsets_cache_len();
1272
1273        // Idempotency: a second sweep must not grow the cache further.
1274        for raw in 1..=20i32 {
1275            let id = crate::object::ObjectIdentifier::new(raw, 0);
1276            let _: Option<crate::object::Dict<'_>> = xref.get(id);
1277        }
1278        assert_eq!(
1279            xref.object_stream_offsets_cache_len(),
1280            after_scan,
1281            "repeated full scans must be cache-stable (no re-parse)"
1282        );
1283    }
1284
1285    #[test]
1286    fn qf2b_two_pdfs_have_independent_caches() {
1287        let pdf_a = load_fixture();
1288        let pdf_b = load_fixture();
1289
1290        // Sanity: both start with the same construction-time count for
1291        // the same fixture (same shape).
1292        let base_a = pdf_a.xref().object_stream_offsets_cache_len();
1293        let base_b = pdf_b.xref().object_stream_offsets_cache_len();
1294        assert_eq!(base_a, base_b);
1295
1296        // Touch many ids in pdf_a to (likely) populate additional /ObjStm
1297        // cache entries.
1298        for raw in 1..=50i32 {
1299            let id = crate::object::ObjectIdentifier::new(raw, 0);
1300            let _: Option<crate::object::Dict<'_>> = pdf_a.xref().get(id);
1301        }
1302        let warm_a = pdf_a.xref().object_stream_offsets_cache_len();
1303
1304        // pdf_b must NOT have grown — caches are per-document.
1305        assert_eq!(
1306            pdf_b.xref().object_stream_offsets_cache_len(),
1307            base_b,
1308            "pdf_b cache must be independent of pdf_a's warming (base_a={base_a}, warm_a={warm_a}, base_b={base_b})"
1309        );
1310    }
1311
1312    #[test]
1313    fn qf2b_parse_helper_returns_none_on_truncated_header() {
1314        // Synthetic: dict says N=3 but data has only one (num, offset)
1315        // pair. The helper must return None, and the caller must not
1316        // cache a `None`.
1317        use crate::object::Stream;
1318        use crate::reader::{Reader, ReaderContext, ReaderExt};
1319        use crate::xref::DUMMY_XREF;
1320
1321        // Build a minimal indirect stream object with /N 3 /First 6:
1322        // "1 0 obj <</N 3/First 6/Length 4>>stream\n1 0\nendstream\nendobj\n"
1323        let raw: &[u8] = b"1 0 obj <</N 3 /First 6 /Length 4>>\nstream\n1 0 \nendstream\nendobj\n";
1324        let mut r = Reader::new(raw);
1325        let ctx = ReaderContext::new(&DUMMY_XREF, false);
1326        let stream: Stream<'_> = r
1327            .read_with_context::<crate::object::indirect::IndirectObject<Stream<'_>>>(&ctx)
1328            .expect("synthetic stream should parse")
1329            .get();
1330
1331        // The stream body has only "1 0 " — three entries cannot be
1332        // recovered, so the helper must return `None`.
1333        let body: &[u8] = b"1 0 ";
1334        assert!(
1335            parse_object_stream_offsets(&stream, body).is_none(),
1336            "truncated headers must not produce a partial offsets table"
1337        );
1338    }
1339
1340    /// QF2-B perf harness. Not a correctness test — it prints microbench
1341    /// numbers and only runs when explicitly requested with
1342    /// `--ignored qf2b_bench`. The harness compares **direct re-parse**
1343    /// of /ObjStm offsets tables (what the pre-QF2-B `ObjectStream::new`
1344    /// did on every `xref.get` of an /ObjStm-stored object) versus the
1345    /// QF2-B cached lookup. This isolates the parse cost from the
1346    /// downstream object-decoding cost, which dominates `xref.get` and
1347    /// would otherwise hide the cache win.
1348    #[test]
1349    #[ignore = "perf measurement; run with `cargo test --release -- --ignored qf2b_bench`"]
1350    fn qf2b_bench_offsets_parse_vs_cached() {
1351        use std::time::Instant;
1352
1353        // 161 /ObjStm headers, 575 KB. Walking and decoding 30+ ObjStms
1354        // here is enough to measure the parse delta cleanly.
1355        // Built from the manifest directory rather than written out as one
1356        // string. This file ships in the published crate and the repository
1357        // tree it points at does not, so a literal path into it is a leaked
1358        // internal path -- which is what the prepublish audit refuses, and it
1359        // stopped the 1.0.0 publish at crate 8 of 32 (#358). It also makes the
1360        // benchmark independent of the working directory it is started from.
1361        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1362            .join("..")
1363            .join("..")
1364            .join("corpus")
1365            .join("f3800.pdf");
1366        let Ok(bytes) = std::fs::read(&path) else {
1367            eprintln!(
1368                "[qf2b_bench] fixture {} unavailable; skipping",
1369                path.display()
1370            );
1371            return;
1372        };
1373        let pdf = Pdf::new(bytes).expect("load f3800.pdf");
1374        let xref = pdf.xref();
1375
1376        // Warm cache via a single full sweep so we know which /ObjStms
1377        // exist.
1378        let max_id = (xref.len() as i32).min(3000);
1379        for n in 1..=max_id {
1380            let id = crate::object::ObjectIdentifier::new(n, 0);
1381            let _: Option<crate::object::Object<'_>> = xref.get(id);
1382        }
1383        let cached_objstms = xref.object_stream_offsets_cache_len();
1384        assert!(
1385            cached_objstms >= 5,
1386            "fixture must trigger several /ObjStms (got {cached_objstms})"
1387        );
1388
1389        // Collect the object-stream container ids by looking them up in
1390        // the xref entries. We then iterate the cache to compare timing
1391        // for parse-from-scratch vs cache-hit.
1392        let mut objstm_ids: Vec<crate::object::ObjectIdentifier> = Vec::new();
1393        for n in 1..=max_id {
1394            let id = crate::object::ObjectIdentifier::new(n, 0);
1395            // Only ObjStm container ids resolve as Stream + /Type ObjStm.
1396            if let Some(stream) = xref.get::<crate::object::Stream<'_>>(id)
1397                && stream
1398                    .dict()
1399                    .get::<crate::object::Name>(crate::object::dict::keys::TYPE)
1400                    .as_deref()
1401                    == Some(b"ObjStm")
1402            {
1403                objstm_ids.push(id);
1404            }
1405            if objstm_ids.len() >= cached_objstms {
1406                break;
1407            }
1408        }
1409        let containers = objstm_ids.len();
1410        assert!(containers > 0);
1411
1412        // Direct re-parse loop — mirrors pre-QF2-B behaviour: parse the
1413        // offsets table from scratch every time, no cache.
1414        const REPEATS: u32 = 200;
1415        let mut sink_parse = 0usize;
1416        let t_parse = Instant::now();
1417        for _ in 0..REPEATS {
1418            for id in &objstm_ids {
1419                let stream = xref
1420                    .get::<crate::object::Stream<'_>>(*id)
1421                    .expect("stream resolves");
1422                let Ok(decoded) = stream.decoded() else {
1423                    continue;
1424                };
1425                if let Some(offs) = parse_object_stream_offsets(&stream, &decoded) {
1426                    sink_parse = sink_parse.wrapping_add(offs.len());
1427                }
1428            }
1429        }
1430        let parse_elapsed = t_parse.elapsed();
1431
1432        // Cache-hit loop — mirrors QF2-B behaviour: retrieve the same
1433        // parsed table from the per-document cache.
1434        let inner = match &xref.0 {
1435            crate::xref::Inner::Some(r) => r.clone(),
1436            _ => unreachable!(),
1437        };
1438        let mut sink_cache = 0usize;
1439        let t_cache = Instant::now();
1440        for _ in 0..REPEATS {
1441            for id in &objstm_ids {
1442                let offs = inner
1443                    .data
1444                    .get_object_stream_offsets_or_init(*id, || {
1445                        let stream = xref
1446                            .get::<crate::object::Stream<'_>>(*id)
1447                            .expect("stream resolves");
1448                        let decoded = stream.decoded().ok()?;
1449                        parse_object_stream_offsets(&stream, &decoded)
1450                    })
1451                    .expect("cached entry must exist after warm-up");
1452                sink_cache = sink_cache.wrapping_add(offs.len());
1453            }
1454        }
1455        let cache_elapsed = t_cache.elapsed();
1456
1457        assert_eq!(
1458            sink_parse, sink_cache,
1459            "parsed and cached results must agree on offset-count totals"
1460        );
1461
1462        let speedup = parse_elapsed.as_secs_f64() / cache_elapsed.as_secs_f64().max(1e-9);
1463        let reduction = (1.0 - cache_elapsed.as_secs_f64() / parse_elapsed.as_secs_f64()) * 100.0;
1464
1465        eprintln!("[qf2b_bench] fixture: f3800.pdf");
1466        eprintln!("[qf2b_bench] /ObjStm containers measured: {containers}");
1467        eprintln!("[qf2b_bench] iterations per container:    {REPEATS}");
1468        eprintln!("[qf2b_bench] direct re-parse total:       {parse_elapsed:?}");
1469        eprintln!("[qf2b_bench] cached lookup total:         {cache_elapsed:?}");
1470        eprintln!("[qf2b_bench] speedup:                     {speedup:.1}x");
1471        eprintln!("[qf2b_bench] parse-time reduction:        {reduction:.1}%");
1472
1473        // Acceptance gate: QF2-B target is ≥ 10 % reduction on parse path.
1474        // The microbench should show much more than that, since the cache
1475        // hit is O(1) hashmap fetch + Arc clone vs O(N) memchr+nom parse.
1476        assert!(
1477            reduction >= 10.0,
1478            "QF2-B acceptance: ≥ 10 % parse-time reduction required; got {reduction:.2} %"
1479        );
1480    }
1481}
1482
1483/// Regression tests for fixes ported from hayro upstream (LaurenzV/hayro#1194).
1484///
1485/// Both subsection-header fields come from the file. `start + num_entries` and
1486/// `XREF_ENTRY_LEN * num_entries` overflowed on a header that declares more
1487/// entries than the address space holds.
1488#[cfg(test)]
1489mod upstream_hardening_tests {
1490    use super::*;
1491
1492    #[test]
1493    fn xref_table_trailer_rejects_overflowing_entry_skip() {
1494        let data = b"xref\n0 999999999999999999999\ntrailer\n<<>>";
1495        let mut reader = Reader::new(data);
1496        assert!(read_xref_table_trailer(&mut reader, &ReaderContext::dummy()).is_none());
1497    }
1498
1499    #[test]
1500    fn xref_table_trailer_still_reads_a_sane_header() {
1501        let data = b"xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 1 >>";
1502        let mut reader = Reader::new(data);
1503        assert!(read_xref_table_trailer(&mut reader, &ReaderContext::dummy()).is_some());
1504    }
1505}