Skip to main content

zpdf_parser/
lib.rs

1mod ccitt;
2mod crypt;
3pub use crypt::Decryptor;
4pub mod filters;
5mod header;
6mod jbig2;
7mod lexer;
8mod object_parser;
9mod recovery;
10mod xref;
11
12pub use header::PdfHeader;
13pub use lexer::Lexer;
14pub use object_parser::ObjectParser;
15pub use xref::{XrefEntry, XrefTable};
16
17use std::cell::{Cell, OnceCell, RefCell};
18use std::collections::HashMap;
19use std::sync::Arc;
20use zpdf_core::{ObjectId, ParseLimits, PdfDict, PdfName, PdfObject, PdfStream, Result};
21
22/// One fully-decoded /Type /ObjStm: decoded bytes + parsed offset table, shared
23/// via Arc so a cache hit is a refcount bump, not a copy of the decoded buffer.
24struct DecodedObjStm {
25    /// Decoded stream bytes (after the filter pipeline).
26    data: Arc<[u8]>,
27    /// `/First`: byte offset within `data` where object bodies begin.
28    first: usize,
29    /// Parsed header: (obj_num, offset_within_data) per contained object,
30    /// in stream order (index == `index_in_stream`).
31    entries: Vec<(u32, usize)>,
32}
33
34pub struct PdfFile {
35    data: Arc<[u8]>,
36    pub header: PdfHeader,
37    pub xref: XrefTable,
38    pub trailer: zpdf_core::PdfDict,
39    limits: ParseLimits,
40    /// Standard-security-handler decryptor, built once at open time from the
41    /// trailer `/Encrypt` dict. `None` for unencrypted (or unsupported-handler)
42    /// documents, in which case `resolve`/object-stream decoding are unchanged.
43    decryptor: Option<crypt::Decryptor>,
44    /// Cache of resolved top-level indirect objects, keyed by ObjectId.
45    /// `RefCell` suffices: `PdfFile` is never shared across threads in this
46    /// workspace (swap to `Mutex` if that ever changes).
47    object_cache: RefCell<HashMap<ObjectId, PdfObject>>,
48    /// Estimated retained bytes in `object_cache`. Cache admission stops at the
49    /// caller's limit; resolution still succeeds without retaining the object.
50    object_cache_bytes: Cell<u64>,
51    /// Cache of decoded object streams, keyed by the ObjStm object number.
52    /// Avoids re-decoding the whole stream for every compressed object it holds.
53    objstm_cache: RefCell<HashMap<u32, Arc<DecodedObjStm>>>,
54    /// Retained decoded-object-stream bytes, including parsed header entries.
55    objstm_cache_bytes: Cell<u64>,
56    /// Lazily-built repair table: populated at most once by a full-file object
57    /// scan, the first time an xref offset turns out to hold the wrong object
58    /// (or no parseable object at all). The inner `None` means the scan itself
59    /// failed and is not retried. Open-time recovery is independent of this.
60    repair_table: OnceCell<Option<XrefTable>>,
61}
62
63impl PdfFile {
64    pub fn parse(data: impl Into<Arc<[u8]>>) -> Result<Self> {
65        Self::parse_with_limits(data, ParseLimits::default())
66    }
67
68    pub fn parse_with_limits(data: impl Into<Arc<[u8]>>, limits: ParseLimits) -> Result<Self> {
69        Self::parse_with_password_and_limits(data, b"", limits)
70    }
71
72    /// Open with a user/owner password (for documents the empty password cannot
73    /// decrypt). Returns [`zpdf_core::Error::WrongPassword`] if it authenticates
74    /// as neither.
75    pub fn parse_with_password(data: impl Into<Arc<[u8]>>, password: &[u8]) -> Result<Self> {
76        Self::parse_with_password_and_limits(data, password, ParseLimits::default())
77    }
78
79    pub fn parse_with_password_and_limits(
80        data: impl Into<Arc<[u8]>>,
81        password: &[u8],
82        limits: ParseLimits,
83    ) -> Result<Self> {
84        let data: Arc<[u8]> = data.into();
85        // A missing `%PDF` marker is not fatal on its own: a sliced/headerless
86        // fragment that begins directly with `N G obj` can still be opened by the
87        // object-scan recovery below. Defer the NotAPdf verdict until recovery
88        // has also come up empty.
89        let header_res = header::parse_header(&data);
90
91        // Try the normal xref pipeline first. Fall back to tail-scan recovery if
92        // it fails structurally OR yields a trailer whose /Root doesn't resolve.
93        let normal = xref::parse_xref_and_trailer(&data, &limits);
94        let (xref, trailer) = match normal {
95            Ok((xref, trailer)) if root_resolves(&data, &xref, &trailer, &limits) => {
96                (xref, trailer)
97            }
98            other => {
99                match &other {
100                    Err(e) => {
101                        tracing::warn!("xref parse failed ({e}); attempting tail-scan recovery")
102                    }
103                    Ok(_) => {
104                        tracing::warn!("xref /Root did not resolve; attempting tail-scan recovery")
105                    }
106                }
107                match recovery::scan_all_objects(&data, &limits) {
108                    Ok(recovered) => recovered,
109                    // Recovery failed: fall back to the normal parse if it at
110                    // least produced a table, else surface the most useful error.
111                    // For a file that never carried a `%PDF` marker, NotAPdf is
112                    // more accurate than the recovery layer's InvalidXref.
113                    Err(rec_err) => match other {
114                        Ok(parsed) => parsed,
115                        Err(_) if header_res.is_err() => return Err(zpdf_core::Error::NotAPdf),
116                        Err(_) => return Err(rec_err),
117                    },
118                }
119            }
120        };
121        // Past this point the document is structurally usable; if the version
122        // header was absent entirely, assume a modern default (matching
123        // header::parse_header's malformed-version fallback) rather than failing.
124        let header = header_res.unwrap_or(PdfHeader { major: 1, minor: 7 });
125
126        let mut file = Self {
127            data,
128            header,
129            xref,
130            trailer,
131            limits,
132            decryptor: None,
133            object_cache: RefCell::new(HashMap::new()),
134            object_cache_bytes: Cell::new(0),
135            objstm_cache: RefCell::new(HashMap::new()),
136            objstm_cache_bytes: Cell::new(0),
137            repair_table: OnceCell::new(),
138        };
139        // Build the decryptor *after* construction so it can use `resolve` to
140        // fetch the (never-encrypted) /Encrypt dict; `decryptor` is still `None`
141        // at this point, so that resolve does not try to decrypt it.
142        file.decryptor = file.build_decryptor(password)?;
143        Ok(file)
144    }
145
146    /// True when the trailer carries an `/Encrypt` dictionary. Note this does not
147    /// imply decryption succeeded — open the document to find out.
148    pub fn is_encrypted(&self) -> bool {
149        self.trailer.get("Encrypt").is_some()
150    }
151
152    /// The document's decryptor, when one was built at open time. Writers use
153    /// it to encrypt objects added to an encrypted document with its key.
154    pub fn decryptor(&self) -> Option<&crypt::Decryptor> {
155        self.decryptor.as_ref()
156    }
157
158    /// Construct the Standard-security-handler decryptor from the trailer
159    /// `/Encrypt` dictionary, the first element of `/ID`, and the password.
160    /// `Ok(None)` for unencrypted documents or unsupported/degraded handlers;
161    /// `Err(WrongPassword)` when a non-empty password fails to authenticate.
162    fn build_decryptor(&self, password: &[u8]) -> Result<Option<crypt::Decryptor>> {
163        // /Encrypt is normally an indirect reference, but a direct dict is
164        // legal too (a direct dict has no object id to exempt from decryption).
165        // The /Encrypt dict is itself never encrypted; resolve it directly.
166        let Some(enc) = self.trailer.get("Encrypt") else {
167            return Ok(None);
168        };
169        let (enc_obj, encrypt_ref) = match enc {
170            PdfObject::Ref(r) => match self.resolve(*r) {
171                Ok(o) => (o, Some(*r)),
172                Err(_) => return Ok(None),
173            },
174            direct => (direct.clone(), None),
175        };
176        let Ok(enc_dict) = enc_obj.as_dict() else {
177            return Ok(None);
178        };
179        let id_first = self.first_id_bytes();
180        match crypt::Decryptor::from_encrypt_dict(enc_dict, &id_first, encrypt_ref, password) {
181            crypt::BuildResult::Decryptor(d) => Ok(Some(d)),
182            crypt::BuildResult::Degrade => Ok(None),
183            crypt::BuildResult::WrongPassword => Err(zpdf_core::Error::WrongPassword),
184        }
185    }
186
187    /// Raw bytes of the first element of the trailer `/ID` array (used in the
188    /// encryption key derivation). `/ID` is normally a direct array but may be an
189    /// indirect reference; resolve it (safe — `decryptor` is still `None` here,
190    /// and `/ID` is never encrypted). Empty if absent or malformed.
191    fn first_id_bytes(&self) -> Vec<u8> {
192        let arr = match self.trailer.get("ID") {
193            Some(PdfObject::Array(a)) => Some(std::borrow::Cow::Borrowed(a.as_slice())),
194            Some(PdfObject::Ref(r)) => self.resolve(*r).ok().and_then(|o| {
195                o.as_array()
196                    .ok()
197                    .map(|a| std::borrow::Cow::Owned(a.to_vec()))
198            }),
199            _ => None,
200        };
201        match arr.as_deref().and_then(|a| a.first()) {
202            Some(PdfObject::String(s)) => s.0.clone(),
203            _ => Vec::new(),
204        }
205    }
206
207    pub fn resolve(&self, id: zpdf_core::ObjectId) -> Result<PdfObject> {
208        self.resolve_depth(id, 0)
209    }
210
211    fn resolve_depth(&self, id: ObjectId, depth: u32) -> Result<PdfObject> {
212        /// Maximum length of a ref-to-ref chain (`1 0 obj 2 0 R endobj` ...)
213        /// followed before the reference is treated as null. Guards against
214        /// reference cycles (`A -> B -> A`) without a per-call visited set.
215        const MAX_REF_CHAIN: u32 = 32;
216        if depth > MAX_REF_CHAIN {
217            tracing::warn!(
218                "indirect reference chain longer than {MAX_REF_CHAIN} at {id}; treating as null"
219            );
220            return Ok(PdfObject::Null);
221        }
222
223        // Fast path: already resolved. The borrow ends with this block.
224        if let Some(obj) = self.object_cache.borrow().get(&id) {
225            return Ok(obj.clone());
226        }
227
228        // ISO 32000-1, 7.3.10: a reference to an object that is missing from
229        // the xref, or marked free, is a reference to the null object — not an
230        // error. BUT a damaged xref frequently just omits (or wrongly frees)
231        // objects that physically exist in the file, which would silently empty
232        // the page tree. So before treating a missing/free entry as null, give
233        // the lazy repair table (one memoized full-file scan) a chance to locate
234        // the real object. The Null is cached either way so the warning fires
235        // once per object and a genuinely-dangling ref stays cheap.
236        let obj = match self.xref.get(id) {
237            Some(XrefEntry::InUse { offset, .. }) => self.parse_at_offset_checked(*offset, id)?,
238            Some(XrefEntry::Compressed {
239                stream_obj,
240                index_in_stream,
241            }) => self.extract_from_object_stream(*stream_obj, *index_in_stream)?,
242            Some(XrefEntry::Free { .. }) => match self.repaired_object(id) {
243                Some(obj) => obj,
244                None => {
245                    tracing::warn!("reference to free object {id}; treating as null");
246                    PdfObject::Null
247                }
248            },
249            None => match self.repaired_object(id) {
250                Some(obj) => obj,
251                None => {
252                    tracing::warn!("reference to missing object {id}; treating as null");
253                    PdfObject::Null
254                }
255            },
256        };
257
258        // A top-level object body may itself be an indirect reference; follow
259        // the chain (depth-limited) so callers always get a direct value.
260        let obj = match obj {
261            PdfObject::Ref(next) => self.resolve_depth(next, depth + 1)?,
262            other => other,
263        };
264
265        self.cache_object(id, &obj);
266        Ok(obj)
267    }
268
269    /// Admit a resolved object only while the configured retained-memory budget
270    /// has room. A full cache degrades to reparsing instead of retaining an
271    /// attacker-controlled number of objects for the document's lifetime.
272    fn cache_object(&self, id: ObjectId, obj: &PdfObject) {
273        if self.object_cache.borrow().contains_key(&id) {
274            return;
275        }
276        let cost = estimate_cached_object_bytes(obj);
277        let used = self.object_cache_bytes.get();
278        if cost > self.limits.max_object_cache_bytes.saturating_sub(used) {
279            return;
280        }
281        self.object_cache.borrow_mut().insert(id, obj.clone());
282        self.object_cache_bytes.set(used.saturating_add(cost));
283    }
284
285    /// Parse the indirect object at `offset`, validating that the header's
286    /// `(num, gen)` matches the id the xref claimed lives there. On mismatch or
287    /// parse failure, consult the lazily-built repair table (full-file object
288    /// scan, run at most once) before giving up.
289    fn parse_at_offset_checked(&self, offset: u64, id: ObjectId) -> Result<PdfObject> {
290        let parser = ObjectParser::new(&self.data, &self.limits);
291        let file_offset = usize::try_from(offset).map_err(|_| {
292            zpdf_core::Error::InvalidObject(offset, "xref offset exceeds address space".into())
293        })?;
294        match parser.parse_indirect_with_id(file_offset) {
295            Ok((pid, mut obj)) if pid == id => {
296                // Top-level objects parsed straight from the file are encrypted;
297                // RC4-decrypt their strings and stream bytes in place (the
298                // decryptor skips the /Encrypt object itself). Objects pulled
299                // from an ObjStm take the Compressed arm and are already
300                // plaintext (the container was decrypted in get_or_decode_objstm).
301                if let Some(dec) = &self.decryptor {
302                    dec.decrypt_object(&mut obj, id);
303                }
304                Ok(obj)
305            }
306            Ok((pid, _)) => {
307                tracing::warn!("xref offset {offset} for {id} holds object {pid}; trying repair");
308                self.repaired_object(id).ok_or_else(|| {
309                    zpdf_core::Error::InvalidObject(
310                        offset,
311                        format!("xref entry for {id} points at object {pid}"),
312                    )
313                })
314            }
315            Err(e) => {
316                tracing::warn!("failed to parse {id} at xref offset {offset} ({e}); trying repair");
317                match self.repaired_object(id) {
318                    Some(obj) => Ok(obj),
319                    None => Err(e),
320                }
321            }
322        }
323    }
324
325    /// Look up `id` in the repair table, building the table on first use by
326    /// running tail-scan recovery over the whole file (memoized; the scan runs
327    /// at most once per `PdfFile`). Returns `None` if the scan failed, the id
328    /// is not in it, or the repaired entry does not actually hold `id`.
329    fn repaired_object(&self, id: ObjectId) -> Option<PdfObject> {
330        let table = self
331            .repair_table
332            .get_or_init(
333                || match recovery::scan_all_objects(&self.data, &self.limits) {
334                    Ok((table, _trailer)) => Some(table),
335                    Err(e) => {
336                        tracing::warn!("repair object scan failed: {e}");
337                        None
338                    }
339                },
340            )
341            .as_ref()?;
342        match table.get(id)? {
343            XrefEntry::InUse { offset, .. } => {
344                let parser = ObjectParser::new(&self.data, &self.limits);
345                let file_offset = usize::try_from(*offset).ok()?;
346                let (pid, mut obj) = parser.parse_indirect_with_id(file_offset).ok()?;
347                if pid != id {
348                    return None;
349                }
350                if let Some(dec) = &self.decryptor {
351                    dec.decrypt_object(&mut obj, id);
352                }
353                Some(obj)
354            }
355            XrefEntry::Compressed {
356                stream_obj,
357                index_in_stream,
358            } => self
359                .extract_from_object_stream(*stream_obj, *index_in_stream)
360                .ok(),
361            XrefEntry::Free { .. } => None,
362        }
363    }
364
365    /// Resolve a stream object and decode its data through the filter pipeline.
366    /// `/Filter` and `/DecodeParms` may be indirect references (or arrays
367    /// containing them); resolve those before handing the dict to the filter
368    /// layer, which has no access to the file.
369    pub fn resolve_stream_data(&self, id: zpdf_core::ObjectId) -> Result<Vec<u8>> {
370        self.resolve_stream_data_inner(id, true)
371    }
372
373    fn resolve_stream_data_inner(
374        &self,
375        id: zpdf_core::ObjectId,
376        inline_globals: bool,
377    ) -> Result<Vec<u8>> {
378        let obj = self.resolve(id)?;
379        let stream = obj.as_stream()?;
380        match self.dict_with_resolved_filters(&stream.dict, inline_globals) {
381            Some(resolved) => {
382                filters::decode_stream_with_limits(&stream.data, &resolved, &self.limits)
383            }
384            None => filters::decode_stream_with_limits(&stream.data, &stream.dict, &self.limits),
385        }
386    }
387
388    /// If `/Filter`, `/DecodeParms`, or `/DP` is an indirect reference (or an
389    /// array containing one), return a clone of `dict` with those values
390    /// resolved one level. `None` when nothing needs resolving (common case —
391    /// avoids cloning the dict). When `inline_globals` is set, a DecodeParms
392    /// `/JBIG2Globals` stream reference is also inlined (see
393    /// [`Self::inline_jbig2_globals`]).
394    fn dict_with_resolved_filters(&self, dict: &PdfDict, inline_globals: bool) -> Option<PdfDict> {
395        const KEYS: [&str; 3] = ["Filter", "DecodeParms", "DP"];
396        // A DecodeParms dict containing a /JBIG2Globals reference needs the
397        // globals stream inlined even though the dict itself is direct.
398        let dict_needs_globals = |obj: &PdfObject| {
399            inline_globals
400                && matches!(obj, PdfObject::Dict(d)
401                    if matches!(d.get("JBIG2Globals"), Some(PdfObject::Ref(_))))
402        };
403        let needs_resolve = |obj: &PdfObject| match obj {
404            PdfObject::Ref(_) => true,
405            PdfObject::Array(a) => a
406                .iter()
407                .any(|e| matches!(e, PdfObject::Ref(_)) || dict_needs_globals(e)),
408            other => dict_needs_globals(other),
409        };
410        if !KEYS.iter().any(|k| dict.get(k).is_some_and(needs_resolve)) {
411            return None;
412        }
413
414        let resolve_shallow = |obj: &PdfObject| match obj {
415            PdfObject::Ref(r) => self.resolve(*r).unwrap_or(PdfObject::Null),
416            other => other.clone(),
417        };
418        let inline = |obj: PdfObject| {
419            if inline_globals {
420                self.inline_jbig2_globals(obj)
421            } else {
422                obj
423            }
424        };
425        let mut out = dict.clone();
426        for key in KEYS {
427            let Some(value) = dict.get(key) else { continue };
428            let resolved = match resolve_shallow(value) {
429                // Also resolve refs *inside* a (possibly itself indirect) array.
430                PdfObject::Array(a) => {
431                    PdfObject::Array(a.iter().map(resolve_shallow).map(inline).collect())
432                }
433                other => inline(other),
434            };
435            out.insert(PdfName::new(key), resolved);
436        }
437        Some(out)
438    }
439
440    /// If `obj` is a DecodeParms dict whose `/JBIG2Globals` is an indirect
441    /// stream reference, replace the reference with an inline string holding
442    /// the globals stream's *decoded* bytes — the filter layer has no file
443    /// access to chase references itself. The globals stream is decoded
444    /// without globals inlining of its own, so a crafted reference cycle
445    /// cannot recurse. Anything else passes through unchanged.
446    fn inline_jbig2_globals(&self, obj: PdfObject) -> PdfObject {
447        let PdfObject::Dict(mut d) = obj else {
448            return obj;
449        };
450        if let Some(PdfObject::Ref(r)) = d.get("JBIG2Globals") {
451            let r = *r;
452            let value = match self.resolve_stream_data_inner(r, false) {
453                Ok(bytes) => PdfObject::String(zpdf_core::PdfString(bytes)),
454                Err(e) => {
455                    tracing::warn!("failed to decode /JBIG2Globals stream {r}: {e}");
456                    PdfObject::Null
457                }
458            };
459            d.insert(PdfName::new("JBIG2Globals"), value);
460        }
461        PdfObject::Dict(d)
462    }
463
464    /// Extract an object from a compressed object stream (/Type /ObjStm).
465    fn extract_from_object_stream(
466        &self,
467        stream_obj_num: u32,
468        index_in_stream: u32,
469    ) -> Result<PdfObject> {
470        let objstm = self.get_or_decode_objstm(stream_obj_num)?;
471
472        let idx = index_in_stream as usize;
473        if idx >= objstm.entries.len() {
474            return Err(zpdf_core::Error::InvalidObject(
475                0,
476                format!(
477                    "object stream index {idx} out of range (n={})",
478                    objstm.entries.len()
479                ),
480            ));
481        }
482
483        let (_, obj_offset) = objstm.entries[idx];
484        let oob = || {
485            zpdf_core::Error::InvalidObject(0, "object stream member offset out of range".into())
486        };
487        let data_start = objstm.first.checked_add(obj_offset).ok_or_else(oob)?;
488        let data_end = if idx + 1 < objstm.entries.len() {
489            objstm
490                .first
491                .checked_add(objstm.entries[idx + 1].1)
492                .ok_or_else(oob)?
493        } else {
494            objstm.data.len()
495        };
496
497        // Member offsets are attacker-controlled and need not be monotonic, so
498        // guard against start > end and out-of-bounds before slicing (would
499        // otherwise panic).
500        let data_end = data_end.min(objstm.data.len());
501        if data_start > data_end {
502            return Err(zpdf_core::Error::InvalidObject(
503                0,
504                "object stream member offsets out of order".into(),
505            ));
506        }
507
508        let obj_data = &objstm.data[data_start..data_end];
509        let mut lexer = Lexer::new(obj_data, 0, &self.limits);
510        lexer.next_token()
511    }
512
513    /// Get a decoded object stream from cache, decoding+parsing it once on miss.
514    /// Resolves the ObjStm container directly from the xref (it cannot itself
515    /// live in another ObjStm) WITHOUT going through `self.resolve`, so it never
516    /// re-enters the `object_cache` borrow.
517    fn get_or_decode_objstm(&self, stream_obj_num: u32) -> Result<Arc<DecodedObjStm>> {
518        if let Some(hit) = self.objstm_cache.borrow().get(&stream_obj_num) {
519            return Ok(Arc::clone(hit));
520        }
521
522        let stream_id = zpdf_core::ObjectId(stream_obj_num, 0);
523        let stream_entry = self
524            .xref
525            .get(stream_id)
526            .ok_or(zpdf_core::Error::ObjectNotFound(stream_id))?;
527        let stream_obj = match stream_entry {
528            XrefEntry::InUse { offset, .. } => {
529                let parser = ObjectParser::new(&self.data, &self.limits);
530                let file_offset = usize::try_from(*offset).map_err(|_| {
531                    zpdf_core::Error::InvalidObject(
532                        *offset,
533                        "object-stream offset exceeds address space".into(),
534                    )
535                })?;
536                parser.parse_indirect_at(file_offset)?
537            }
538            _ => return Err(zpdf_core::Error::ObjectNotFound(stream_id)),
539        };
540
541        let stream: &PdfStream = stream_obj.as_stream()?;
542        // Reject negative /N and /First (attacker-controlled): a negative i64 cast
543        // straight to usize becomes a near-usize::MAX value that overflows the
544        // offset arithmetic later.
545        let neg =
546            |what: &str| zpdf_core::Error::InvalidObject(0, format!("ObjStm {what} is negative"));
547        let n = usize::try_from(stream.dict.get_i64("N")?).map_err(|_| neg("/N"))?;
548        let first = usize::try_from(stream.dict.get_i64("First")?).map_err(|_| neg("/First"))?;
549        if n > self.limits.max_objects as usize {
550            return Err(zpdf_core::Error::StreamDecode(format!(
551                "ObjStm /N {n} exceeds object limit {}",
552                self.limits.max_objects
553            )));
554        }
555
556        // An encrypted document encrypts the ObjStm *container* once (keyed by
557        // the container's own object id); its member objects are not separately
558        // encrypted. Decrypt the raw bytes before running the filter pipeline.
559        let raw: std::borrow::Cow<[u8]> = match &self.decryptor {
560            Some(dec) => std::borrow::Cow::Owned(
561                dec.decrypt_stream_bytes(zpdf_core::ObjectId(stream_obj_num, 0), &stream.data),
562            ),
563            None => std::borrow::Cow::Borrowed(&stream.data),
564        };
565        let decoded = filters::decode_stream_with_limits(&raw, &stream.dict, &self.limits)?;
566
567        // Parse the header: N pairs of (obj_num, offset_within_data). Capacity is
568        // bounded by the header length to avoid a huge allocation on a bogus /N.
569        let header = &decoded[..first.min(decoded.len())];
570        let mut header_lexer = Lexer::new(header, 0, &self.limits);
571        let mut entries = Vec::with_capacity(n.min(header.len()));
572        for _ in 0..n {
573            let obj_num_tok = header_lexer.next_token()?;
574            let offset_tok = header_lexer.next_token()?;
575            let obj_num = u32::try_from(obj_num_tok.as_i64()?).map_err(|_| {
576                zpdf_core::Error::StreamDecode("ObjStm: object number out of range".into())
577            })?;
578            let offset = usize::try_from(offset_tok.as_i64()?).map_err(|_| {
579                zpdf_core::Error::StreamDecode("ObjStm: member offset out of range".into())
580            })?;
581            entries.push((obj_num, offset));
582        }
583
584        let decoded_arc = Arc::new(DecodedObjStm {
585            data: Arc::<[u8]>::from(decoded),
586            first,
587            entries,
588        });
589        let cost = estimate_objstm_bytes(&decoded_arc);
590        let used = self.objstm_cache_bytes.get();
591        if cost <= self.limits.max_objstm_cache_bytes.saturating_sub(used) {
592            self.objstm_cache
593                .borrow_mut()
594                .insert(stream_obj_num, Arc::clone(&decoded_arc));
595            self.objstm_cache_bytes.set(used.saturating_add(cost));
596        }
597        Ok(decoded_arc)
598    }
599
600    pub fn data(&self) -> &[u8] {
601        &self.data
602    }
603
604    /// Return the active parse limits (M3 security fix).
605    pub fn limits(&self) -> &ParseLimits {
606        &self.limits
607    }
608
609    /// Force-build (once) and return the full-file repair-scan table, or `None`
610    /// if the scan found nothing. Shares the `OnceCell` the lazy per-object
611    /// repair uses, so the scan runs at most once per `PdfFile`.
612    pub fn force_repair_scan(&self) -> Option<&XrefTable> {
613        self.repair_table
614            .get_or_init(
615                || match recovery::scan_all_objects(&self.data, &self.limits) {
616                    Ok((table, _trailer)) => Some(table),
617                    Err(e) => {
618                        tracing::warn!("repair object scan failed: {e}");
619                        None
620                    }
621                },
622            )
623            .as_ref()
624    }
625
626    /// Every object id known to this file: the live xref unioned with the
627    /// repair-scan table (built on demand). Deduped and sorted by `(num, gen)`.
628    pub fn all_object_ids(&self) -> Vec<ObjectId> {
629        let mut ids: Vec<ObjectId> = self.xref.object_ids().collect();
630        if let Some(table) = self.force_repair_scan() {
631            ids.extend(table.object_ids());
632        }
633        ids.sort_by_key(|id| (id.0, id.1));
634        ids.dedup();
635        ids
636    }
637
638    /// All objects whose dict `/Type` equals `ty`, in `(num, gen)` order.
639    /// Resolves through [`Self::resolve`] (so /ObjStm members are decoded and,
640    /// for encrypted files, decrypted) and falls back to the repair table for
641    /// ids the live xref lacks. Bounded by `limits.max_objects`. The document
642    /// layer uses this to rebuild a page list when the /Pages tree is
643    /// unreachable.
644    pub fn find_objects_by_type(&self, ty: &str) -> Vec<ObjectId> {
645        let mut out = Vec::new();
646        for id in self.all_object_ids() {
647            if out.len() as u32 >= self.limits.max_objects {
648                break;
649            }
650            let obj = match self.resolve(id) {
651                Ok(PdfObject::Null) | Err(_) => self.repaired_object(id),
652                Ok(o) => Some(o),
653            };
654            let is_match = obj
655                .as_ref()
656                .and_then(|o| o.as_dict().ok())
657                .map(|d| d.get_name("Type").map(|t| t == ty).unwrap_or(false))
658                .unwrap_or(false);
659            if is_match {
660                out.push(id);
661            }
662        }
663        out
664    }
665}
666
667/// Conservative heap-size estimate for one cached object and its hash-table
668/// entry. It intentionally counts inline child enum storage as well as nested
669/// payloads; overestimating only reduces cache hit rate, while underestimating
670/// would defeat the retention limit.
671fn estimate_cached_object_bytes(obj: &PdfObject) -> u64 {
672    const ENTRY_OVERHEAD: u64 = 64;
673
674    fn dict_bytes(dict: &PdfDict) -> u64 {
675        dict.0.iter().fold(0u64, |sum, (key, value)| {
676            sum.saturating_add(key.0.len() as u64)
677                .saturating_add(48) // conservative BTree node/link overhead
678                .saturating_add(object_bytes(value))
679        })
680    }
681
682    fn object_bytes(obj: &PdfObject) -> u64 {
683        let base = std::mem::size_of::<PdfObject>() as u64;
684        let payload = match obj {
685            PdfObject::String(s) => s.0.len() as u64,
686            PdfObject::Name(n) => n.0.len() as u64,
687            PdfObject::Array(items) => items.iter().fold(
688                (items.len() * std::mem::size_of::<PdfObject>()) as u64,
689                |sum, item| sum.saturating_add(object_bytes(item)),
690            ),
691            PdfObject::Dict(dict) => dict_bytes(dict),
692            PdfObject::Stream(stream) => {
693                (stream.data.len() as u64).saturating_add(dict_bytes(&stream.dict))
694            }
695            PdfObject::Null
696            | PdfObject::Bool(_)
697            | PdfObject::Integer(_)
698            | PdfObject::Real(_)
699            | PdfObject::Ref(_) => 0,
700        };
701        base.saturating_add(payload)
702    }
703
704    ENTRY_OVERHEAD.saturating_add(object_bytes(obj))
705}
706
707fn estimate_objstm_bytes(stream: &DecodedObjStm) -> u64 {
708    const ENTRY_OVERHEAD: u64 = 64;
709    ENTRY_OVERHEAD
710        .saturating_add(std::mem::size_of::<DecodedObjStm>() as u64)
711        .saturating_add(stream.data.len() as u64)
712        .saturating_add((stream.entries.len() * std::mem::size_of::<(u32, usize)>()) as u64)
713}
714
715/// Best-effort check that the trailer's /Root points at a usable Catalog. Runs
716/// once at open time (before `PdfFile` exists), so it is a free function that
717/// parses the Root directly rather than going through `PdfFile::resolve`.
718///
719/// Lenient by design: a Root that is present but compressed/free is trusted
720/// (the normal pipeline handles it); only a direct InUse Root is strictly
721/// checked for `/Type /Catalog`. A missing Root triggers recovery.
722fn root_resolves(
723    data: &[u8],
724    xref: &XrefTable,
725    trailer: &zpdf_core::PdfDict,
726    limits: &ParseLimits,
727) -> bool {
728    let Ok(root_ref) = trailer.get_ref("Root") else {
729        return false;
730    };
731    match xref.get(root_ref) {
732        Some(XrefEntry::InUse { offset, .. }) => {
733            let parser = ObjectParser::new(data, limits);
734            let Some(file_offset) = usize::try_from(*offset).ok() else {
735                return false;
736            };
737            matches!(
738                parser
739                    .parse_indirect_at(file_offset)
740                    .ok()
741                    .and_then(|o| o
742                        .as_dict()
743                        .ok()
744                        .map(|d| d.get_name("Type").unwrap_or("").to_string())),
745                Some(t) if t == "Catalog"
746            )
747        }
748        Some(_) => true, // compressed/free-but-present: trust the normal pipeline
749        None => false,
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    /// Validates the object-stream header parse + body-slicing arithmetic that
758    /// `get_or_decode_objstm`/`extract_from_object_stream` rely on, without
759    /// needing a full xref-stream fixture.
760    #[test]
761    fn objstm_header_and_slicing_math() {
762        let limits = ParseLimits::default();
763        let o10 = b"<< /Type /Catalog /Pages 2 0 R >>";
764        let o11 = b"42";
765        let header = format!("10 0 11 {} ", o10.len() + 1);
766        let first = header.len();
767        let mut decoded = header.into_bytes();
768        decoded.extend_from_slice(o10);
769        decoded.push(b' ');
770        decoded.extend_from_slice(o11);
771
772        // Mirror the header parse.
773        let mut hx = Lexer::new(&decoded[..first], 0, &limits);
774        let mut entries = Vec::new();
775        for _ in 0..2 {
776            let num = hx.next_token().unwrap().as_i64().unwrap() as u32;
777            let off = hx.next_token().unwrap().as_i64().unwrap() as usize;
778            entries.push((num, off));
779        }
780        assert_eq!(entries, vec![(10, 0), (11, o10.len() + 1)]);
781
782        // Slice + lex object index 0 (obj 10).
783        let (start0, end0) = (first + entries[0].1, first + entries[1].1);
784        let obj = Lexer::new(&decoded[start0..end0], 0, &limits)
785            .next_token()
786            .unwrap();
787        assert!(obj.as_dict().is_ok(), "obj 10 should lex as a dict");
788
789        // Slice + lex object index 1 (obj 11) — runs to end of decoded.
790        let start1 = first + entries[1].1;
791        let n = Lexer::new(&decoded[start1..], 0, &limits)
792            .next_token()
793            .unwrap();
794        assert_eq!(n.as_i64().unwrap(), 42);
795    }
796
797    /// Assemble a minimal PDF: the given `(num, body)` objects at gen 0, a
798    /// traditional xref covering each (one single-entry subsection apiece),
799    /// and a trailer pointing /Root at `root`.
800    fn build_pdf(objects: &[(u32, &str)], root: u32) -> Vec<u8> {
801        let mut d = Vec::from(&b"%PDF-1.4\n"[..]);
802        let mut offsets = Vec::new();
803        for (num, body) in objects {
804            offsets.push((*num, d.len()));
805            d.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
806        }
807        let xref_off = d.len();
808        d.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n");
809        for (num, off) in &offsets {
810            d.extend_from_slice(format!("{num} 1\n{off:010} 00000 n \n").as_bytes());
811        }
812        let size = objects.iter().map(|(n, _)| n + 1).max().unwrap_or(1);
813        d.extend_from_slice(
814            format!("trailer\n<< /Size {size} /Root {root} 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
815                .as_bytes(),
816        );
817        d
818    }
819
820    #[test]
821    fn dangling_ref_resolves_to_null() {
822        // Object 9 is referenced but absent from the xref entirely: per
823        // ISO 32000 7.3.10 it resolves to null, not an error.
824        let pdf = build_pdf(&[(1, "<< /Type /Catalog /Pages 9 0 R >>")], 1);
825        let file = PdfFile::parse(pdf).unwrap();
826        assert_eq!(file.resolve(ObjectId(9, 0)).unwrap(), PdfObject::Null);
827        // Second resolve hits the cache (warn fires once).
828        assert_eq!(file.resolve(ObjectId(9, 0)).unwrap(), PdfObject::Null);
829    }
830
831    #[test]
832    fn free_entry_resolves_to_null() {
833        let mut d = Vec::from(&b"%PDF-1.4\n"[..]);
834        let off1 = d.len();
835        d.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
836        let xref_off = d.len();
837        d.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n1 1\n");
838        d.extend_from_slice(format!("{off1:010} 00000 n \n").as_bytes());
839        d.extend_from_slice(b"2 1\n0000000000 00000 f \n");
840        d.extend_from_slice(
841            format!("trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
842                .as_bytes(),
843        );
844
845        let file = PdfFile::parse(d).unwrap();
846        assert!(matches!(
847            file.xref.get(ObjectId(2, 0)),
848            Some(XrefEntry::Free { .. })
849        ));
850        assert_eq!(file.resolve(ObjectId(2, 0)).unwrap(), PdfObject::Null);
851    }
852
853    #[test]
854    fn header_mismatch_triggers_lazy_repair() {
855        // The xref entry for object 3 points at object 2's offset; the real
856        // object 3 lives elsewhere. resolve(3) must repair via the lazy scan.
857        let mut d = Vec::from(&b"%PDF-1.4\n"[..]);
858        let off1 = d.len();
859        d.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
860        let off2 = d.len();
861        d.extend_from_slice(b"2 0 obj\n<< /Marker /Wrong >>\nendobj\n");
862        // Real object 3 — its offset is deliberately NOT in the xref.
863        d.extend_from_slice(b"3 0 obj\n<< /Marker /Real >>\nendobj\n");
864        let xref_off = d.len();
865        d.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n");
866        d.extend_from_slice(format!("1 1\n{off1:010} 00000 n \n").as_bytes());
867        d.extend_from_slice(format!("2 1\n{off2:010} 00000 n \n").as_bytes());
868        d.extend_from_slice(format!("3 1\n{off2:010} 00000 n \n").as_bytes()); // wrong!
869        d.extend_from_slice(
870            format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
871                .as_bytes(),
872        );
873
874        let file = PdfFile::parse(d).unwrap();
875        let obj = file.resolve(ObjectId(3, 0)).unwrap();
876        assert_eq!(obj.as_dict().unwrap().get_name("Marker").unwrap(), "Real");
877        // Object 2 still resolves normally (its entry was correct).
878        let obj2 = file.resolve(ObjectId(2, 0)).unwrap();
879        assert_eq!(obj2.as_dict().unwrap().get_name("Marker").unwrap(), "Wrong");
880    }
881
882    #[test]
883    fn ref_to_ref_chain_resolves() {
884        let pdf = build_pdf(
885            &[
886                (1, "<< /Type /Catalog /Pages 2 0 R >>"),
887                (4, "5 0 R"),
888                (5, "42"),
889            ],
890            1,
891        );
892        let file = PdfFile::parse(pdf).unwrap();
893        assert_eq!(
894            file.resolve(ObjectId(4, 0)).unwrap(),
895            PdfObject::Integer(42)
896        );
897    }
898
899    #[test]
900    fn ref_cycle_resolves_to_null() {
901        // 4 -> 5 -> 4: the chain guard must terminate (no hang/stack overflow)
902        // and degrade the value to null.
903        let pdf = build_pdf(
904            &[
905                (1, "<< /Type /Catalog /Pages 2 0 R >>"),
906                (4, "5 0 R"),
907                (5, "4 0 R"),
908            ],
909            1,
910        );
911        let file = PdfFile::parse(pdf).unwrap();
912        assert_eq!(file.resolve(ObjectId(4, 0)).unwrap(), PdfObject::Null);
913    }
914
915    #[test]
916    fn indirect_filter_is_resolved() {
917        use flate2::write::ZlibEncoder;
918        use flate2::Compression;
919        use std::io::Write;
920
921        let payload = b"indirect filter payload";
922        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
923        enc.write_all(payload).unwrap();
924        let compressed = enc.finish().unwrap();
925
926        let mut d = Vec::from(&b"%PDF-1.4\n"[..]);
927        let off1 = d.len();
928        d.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
929        let off3 = d.len();
930        d.extend_from_slice(
931            format!(
932                "3 0 obj\n<< /Length {} /Filter 4 0 R >>\nstream\n",
933                compressed.len()
934            )
935            .as_bytes(),
936        );
937        d.extend_from_slice(&compressed);
938        d.extend_from_slice(b"\nendstream\nendobj\n");
939        let off4 = d.len();
940        d.extend_from_slice(b"4 0 obj\n/FlateDecode\nendobj\n");
941        let xref_off = d.len();
942        d.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n");
943        d.extend_from_slice(format!("1 1\n{off1:010} 00000 n \n").as_bytes());
944        d.extend_from_slice(format!("3 1\n{off3:010} 00000 n \n").as_bytes());
945        d.extend_from_slice(format!("4 1\n{off4:010} 00000 n \n").as_bytes());
946        d.extend_from_slice(
947            format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
948                .as_bytes(),
949        );
950
951        let file = PdfFile::parse(d).unwrap();
952        let data = file.resolve_stream_data(ObjectId(3, 0)).unwrap();
953        assert_eq!(data, payload);
954    }
955
956    #[test]
957    fn resolve_stream_uses_file_decode_limits() {
958        let pdf = build_pdf(
959            &[
960                (1, "<< /Type /Catalog >>"),
961                (3, "<< /Length 4 >>\nstream\nfour\nendstream"),
962            ],
963            1,
964        );
965        let limits = ParseLimits {
966            max_decoded_stream_bytes: 3,
967            ..ParseLimits::default()
968        };
969        let file = PdfFile::parse_with_limits(pdf, limits).unwrap();
970        assert!(matches!(
971            file.resolve_stream_data(ObjectId(3, 0)),
972            Err(zpdf_core::Error::StreamSizeLimit(3))
973        ));
974    }
975
976    #[test]
977    fn zero_object_cache_budget_retains_nothing() {
978        let pdf = build_pdf(&[(1, "<< /Type /Catalog /Marker (large) >>")], 1);
979        let limits = ParseLimits {
980            max_object_cache_bytes: 0,
981            ..ParseLimits::default()
982        };
983        let file = PdfFile::parse_with_limits(pdf, limits).unwrap();
984        assert_eq!(
985            file.resolve(ObjectId(1, 0))
986                .unwrap()
987                .as_dict()
988                .unwrap()
989                .get_name("Type")
990                .unwrap(),
991            "Catalog"
992        );
993        assert!(file.object_cache.borrow().is_empty());
994        assert_eq!(file.object_cache_bytes.get(), 0);
995    }
996
997    #[test]
998    fn zero_objstm_cache_budget_decodes_without_retention() {
999        let pdf = build_pdf(
1000            &[
1001                (1, "<< /Type /Catalog >>"),
1002                (
1003                    5,
1004                    "<< /Type /ObjStm /N 1 /First 4 /Length 6 >>\nstream\n6 0 42\nendstream",
1005                ),
1006            ],
1007            1,
1008        );
1009        let limits = ParseLimits {
1010            max_objstm_cache_bytes: 0,
1011            ..ParseLimits::default()
1012        };
1013        let mut file = PdfFile::parse_with_limits(pdf, limits).unwrap();
1014        file.xref.insert_overwrite(
1015            ObjectId(6, 0),
1016            XrefEntry::Compressed {
1017                stream_obj: 5,
1018                index_in_stream: 0,
1019            },
1020        );
1021        assert_eq!(
1022            file.resolve(ObjectId(6, 0)).unwrap(),
1023            PdfObject::Integer(42)
1024        );
1025        assert!(file.objstm_cache.borrow().is_empty());
1026        assert_eq!(file.objstm_cache_bytes.get(), 0);
1027    }
1028
1029    /// An image stream with /Filter /JBIG2Decode whose /DecodeParms holds an
1030    /// indirect /JBIG2Globals stream: the globals reference must be resolved,
1031    /// decoded (here through its own FlateDecode), and inlined before the
1032    /// filter layer runs. The globals carry the page-info segment; the image
1033    /// stream carries an MMR generic region (two "WWWBBWWW" rows).
1034    #[test]
1035    fn jbig2_globals_stream_is_resolved_and_decoded() {
1036        use flate2::write::ZlibEncoder;
1037        use flate2::Compression;
1038        use std::io::Write;
1039
1040        // Globals: segment 0, type 48 (page information), page 1, 8x2 page.
1041        let globals: Vec<u8> = [
1042            &[0, 0, 0, 0, 0x30, 0x00, 0x01, 0, 0, 0, 19][..], // header, length 19
1043            &[0, 0, 0, 8, 0, 0, 0, 2][..],                    // width 8, height 2
1044            &[0; 8][..],                                      // x/y resolution
1045            &[0x00, 0, 0][..],                                // flags, striping
1046        ]
1047        .concat();
1048        let mut gz = ZlibEncoder::new(Vec::new(), Compression::default());
1049        gz.write_all(&globals).unwrap();
1050        let globals_z = gz.finish().unwrap();
1051
1052        // Image stream: segment 1, type 38 (immediate generic region), MMR
1053        // payload 0x31 0xF8 = T.6-coded WWWBBWWW twice.
1054        let image: Vec<u8> = [
1055            &[0, 0, 0, 1, 0x26, 0x00, 0x01, 0, 0, 0, 20][..], // header, length 20
1056            &[0, 0, 0, 8, 0, 0, 0, 2][..],                    // region 8x2 …
1057            &[0, 0, 0, 0, 0, 0, 0, 0, 0x00][..],              // … at (0,0), OR
1058            &[0x01, 0x31, 0xF8][..],                          // MMR flag + data
1059        ]
1060        .concat();
1061
1062        let mut d = Vec::from(&b"%PDF-1.4\n"[..]);
1063        let off1 = d.len();
1064        d.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1065        let off3 = d.len();
1066        d.extend_from_slice(
1067            format!(
1068                "3 0 obj\n<< /Length {} /Filter /JBIG2Decode \
1069                 /DecodeParms << /JBIG2Globals 4 0 R >> >>\nstream\n",
1070                image.len()
1071            )
1072            .as_bytes(),
1073        );
1074        d.extend_from_slice(&image);
1075        d.extend_from_slice(b"\nendstream\nendobj\n");
1076        let off4 = d.len();
1077        d.extend_from_slice(
1078            format!(
1079                "4 0 obj\n<< /Length {} /Filter /FlateDecode >>\nstream\n",
1080                globals_z.len()
1081            )
1082            .as_bytes(),
1083        );
1084        d.extend_from_slice(&globals_z);
1085        d.extend_from_slice(b"\nendstream\nendobj\n");
1086        let xref_off = d.len();
1087        d.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n");
1088        d.extend_from_slice(format!("1 1\n{off1:010} 00000 n \n").as_bytes());
1089        d.extend_from_slice(format!("3 1\n{off3:010} 00000 n \n").as_bytes());
1090        d.extend_from_slice(format!("4 1\n{off4:010} 00000 n \n").as_bytes());
1091        d.extend_from_slice(
1092            format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
1093                .as_bytes(),
1094        );
1095
1096        let file = PdfFile::parse(d).unwrap();
1097        let data = file.resolve_stream_data(ObjectId(3, 0)).unwrap();
1098        // WWWBBWWW in PDF 1-bpc polarity (black = 0): 1110 0111, both rows.
1099        assert_eq!(data, vec![0xE7, 0xE7]);
1100    }
1101}