Skip to main content

pdfboss_aio/
document.rs

1//! The async document model: opening fetches only the file tail, the xref
2//! chain and the page-tree nodes; objects are fetched span-by-span through
3//! growing windows and parsed by the sync core machinery. The whole file
4//! is never read.
5
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8use std::sync::Arc;
9
10use bytes::Bytes;
11use pdfboss_core::elements::{ElementOpts, Span, XrefKind};
12use pdfboss_core::lexer::{Lexer, Token};
13use pdfboss_core::object::decode_text_string;
14use pdfboss_core::parser::{NoResolve, Parser, Resolve};
15use pdfboss_core::xref::XrefEntry;
16use pdfboss_core::{AsyncObjectSource, Dict, Metadata, ObjRef, Object, Page, Stream};
17
18use crate::backend::{Backend, BoxFuture, FileBackend, MemBackend};
19use crate::cache::CachedBackend;
20use crate::error::{Error, Result};
21
22/// Initial tail window scanned for `startxref`, doubling per retry.
23const TAIL_WINDOW: u64 = 4096;
24/// Widest tail window tried before the chain is declared unusable.
25const MAX_TAIL_WINDOW: u64 = 64 * 1024;
26
27/// Widest window `parse_in_file` and `parse_section_at` will fetch for a
28/// single object or xref section before giving up as unparseable, however
29/// far from EOF that leaves them.
30///
31/// Both grow their window by doubling until the parse completes, bounded
32/// only by end-of-file otherwise -- fine for a well-formed file, but a
33/// corrupt xref entry (or offset arithmetic gone wrong) pointing into
34/// unrelated bytes deep inside a *large* file would double all the way to
35/// EOF before giving up. For the HTTP backend in particular, one bogus
36/// offset could then pull a Range request spanning most of a multi-
37/// gigabyte file -- exactly the amplification this cap exists to bound.
38///
39/// The cap can't be small: `parse_in_file` must buffer an object's entire
40/// span in one read, including a stream's raw bytes (`stream_dishonors_length`
41/// checks the captured `Stream::data` against the declared `/Length`,
42/// which requires having read all of it), and a single legitimate object
43/// -- an embedded image or file attachment -- can genuinely run to
44/// hundreds of megabytes. 256 MiB is chosen as comfortable headroom over
45/// that, while still bounding any one fetch to a small fraction of even a
46/// multi-gigabyte file. The same bound serves the section loop: a classic
47/// xref table over 256 MiB of entries (20 bytes each) implies on the order
48/// of ten million objects, far beyond any real document.
49const MAX_GROWTH_WINDOW: u64 = 256 * 1024 * 1024;
50
51/// Bounded fetch helper: whole-range reads with truncation detection.
52pub(crate) struct Fetcher {
53    pub(crate) backend: Arc<dyn Backend>,
54    pub(crate) len: u64,
55}
56
57impl Fetcher {
58    /// Reads exactly `[start, end)` (callers clamp to the file length). A
59    /// read that stops short of `end` is reported as
60    /// [`Error::TruncatedRead`] carrying the range being fetched.
61    pub(crate) async fn read_range(&self, start: u64, end: u64) -> Result<Vec<u8>> {
62        let wanted = usize::try_from(end.saturating_sub(start)).map_err(|overflow| {
63            Error::Core(pdfboss_core::Error::Other(format!(
64                "range {start}..{end} does not fit this platform: {overflow}"
65            )))
66        })?;
67        let mut buf = vec![0u8; wanted];
68        let mut filled = 0;
69        while filled < wanted {
70            let got = self
71                .backend
72                .read_at(start + filled as u64, &mut buf[filled..])
73                .await
74                .map_err(Error::from)?;
75            if got == 0 {
76                return Err(Error::TruncatedRead {
77                    offset: start,
78                    wanted,
79                    got: filled,
80                });
81            }
82            filled += got;
83        }
84        Ok(buf)
85    }
86
87    /// Reads the window `[offset, offset + window)` clamped to the file
88    /// end; an offset at or past the end yields an empty buffer.
89    pub(crate) async fn window(&self, offset: u64, window: usize) -> Result<Vec<u8>> {
90        let end = self.len.min(offset.saturating_add(window as u64));
91        if offset >= end {
92            return Ok(Vec::new());
93        }
94        self.read_range(offset, end).await
95    }
96}
97
98/// Finds the first occurrence of `needle` in `haystack`.
99pub(crate) fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
100    if needle.is_empty() || haystack.len() < needle.len() {
101        return None;
102    }
103    haystack.windows(needle.len()).position(|w| w == needle)
104}
105
106/// Finds the last occurrence of `needle` in `haystack`.
107pub(crate) fn rfind_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
108    if needle.is_empty() || haystack.len() < needle.len() {
109        return None;
110    }
111    haystack.windows(needle.len()).rposition(|w| w == needle)
112}
113
114/// The file's final `startxref` announcement.
115pub(crate) struct StartXrefRecord {
116    /// The announced xref offset.
117    pub(crate) offset: u64,
118    /// Span of `startxref` through the offset integer. Exposed by a
119    /// `startxref_record()` accessor consumed by the element stream's
120    /// physical layer.
121    pub(crate) span: Span,
122}
123
124/// Locates the last `startxref` (and the last `%%EOF`) by scanning a
125/// growing tail window: 4 KiB doubling to 64 KiB (ISO 32000 §7.5.5). No
126/// whole-file recovery scan exists here — that would defeat the
127/// never-read-the-whole-file guarantee — so an absent keyword is
128/// `InvalidXref`.
129pub(crate) async fn find_tail(fetcher: &Fetcher) -> Result<(StartXrefRecord, Option<Span>)> {
130    let mut window = TAIL_WINDOW;
131    loop {
132        let start = fetcher.len.saturating_sub(window);
133        let tail = fetcher.read_range(start, fetcher.len).await?;
134        if let Some(rel) = rfind_bytes(&tail, b"startxref") {
135            let mut lexer = Lexer::at(&tail, rel + b"startxref".len());
136            if let Ok(Token::Int(value)) = lexer.next_token() {
137                if value >= 0 && (value as u64) < fetcher.len {
138                    let record = StartXrefRecord {
139                        offset: value as u64,
140                        span: Span {
141                            start: start + rel as u64,
142                            end: start + lexer.pos() as u64,
143                        },
144                    };
145                    let eof = rfind_bytes(&tail, b"%%EOF").map(|pos| Span {
146                        start: start + pos as u64,
147                        end: start + pos as u64 + 5,
148                    });
149                    return Ok((record, eof));
150                }
151            }
152        }
153        if window >= fetcher.len || window >= MAX_TAIL_WINDOW {
154            return Err(Error::Core(pdfboss_core::Error::InvalidXref));
155        }
156        window *= 2;
157    }
158}
159
160/// Parses the `%PDF-x.y` header from the first bytes of the file,
161/// scanning up to 1 KiB; absent or malformed headers default to 1.4,
162/// mirroring the sync document model.
163pub(crate) fn parse_version(head: &[u8]) -> (u8, u8) {
164    try_parse_version(head).unwrap_or((1, 4))
165}
166
167fn try_parse_version(head: &[u8]) -> Option<(u8, u8)> {
168    let window = &head[..head.len().min(1024)];
169    let pos = find_bytes(window, b"%PDF-")?;
170    let rest = &window[pos + 5..];
171    let (major, used) = read_version_component(rest)?;
172    if rest.get(used) != Some(&b'.') {
173        return None;
174    }
175    let minor = read_version_component(&rest[used + 1..])?.0;
176    Some((major, minor))
177}
178
179/// Reads a run of 1–3 ASCII digits as a `u8`, returning the value and the
180/// number of bytes consumed.
181fn read_version_component(bytes: &[u8]) -> Option<(u8, usize)> {
182    let end = bytes
183        .iter()
184        .position(|b| !b.is_ascii_digit())
185        .unwrap_or(bytes.len());
186    if end == 0 || end > 3 {
187        return None;
188    }
189    let value = std::str::from_utf8(&bytes[..end]).ok()?.parse().ok()?;
190    Some((value, end))
191}
192
193/// Span of the `%PDF-` header: match start through the run of version
194/// characters (ASCII digits and dots) after it, scanning the first 1 KiB
195/// (adopted rule 1, pinned by the core iterator). `None` when no header
196/// exists — the Header element is simply omitted (lenient).
197pub(crate) fn header_span_in(head: &[u8]) -> Option<Span> {
198    let window = &head[..head.len().min(1024)];
199    let pos = find_bytes(window, b"%PDF-")?;
200    let version_end = window[pos + 5..]
201        .iter()
202        .position(|&b| !(b.is_ascii_digit() || b == b'.'))
203        .map(|rel| pos + 5 + rel)
204        .unwrap_or(window.len());
205    Some(Span {
206        start: pos as u64,
207        end: version_end as u64,
208    })
209}
210
211/// Bytes of slack demanded beyond a window parse end so trailing-keyword
212/// lookahead (`endobj`, `endstream`, `trailer` dict close) can never be cut
213/// mid-token by the window edge.
214pub(crate) const PARSE_SLACK: usize = 16;
215
216/// The flattened record for one page leaf: its reference (when the leaf
217/// was reached through one), its dictionary, and its inherited
218/// `/Resources` (ISO 32000 §7.7.3.4).
219#[derive(Clone, Debug)]
220pub(crate) struct PageRecord {
221    /// `None` for a page dict inlined directly into `/Kids` (no `ObjRef`
222    /// exists for it). Consumed by the logical element layer building
223    /// `Element::Page` (Plan 02 task 12).
224    pub(crate) r: Option<ObjRef>,
225    /// Consumed, with `r` above, by the logical element layer (Plan 02
226    /// task 12).
227    pub(crate) dict: Dict,
228    /// Consumed, with `dict` above, by the logical element layer (Plan 02
229    /// task 12).
230    pub(crate) resources: Dict,
231    /// Inherited `/MediaBox`, still raw: `None` when no node on the path
232    /// declared one. Defaults apply in [`Page::from_tree_attrs`], not here,
233    /// so this record cannot disagree with the synchronous walk about what
234    /// "the file said nothing" means.
235    pub(crate) media_box: Option<pdfboss_core::Rect>,
236    /// Inherited `/CropBox`, raw as above.
237    pub(crate) crop_box: Option<pdfboss_core::Rect>,
238    /// Leaf `/BleedBox`, raw as above. Not inheritable (ISO 32000
239    /// §7.7.3.3, Table 30), so read from the leaf dictionary only.
240    pub(crate) bleed_box: Option<pdfboss_core::Rect>,
241    /// Leaf `/TrimBox`, raw as above.
242    pub(crate) trim_box: Option<pdfboss_core::Rect>,
243    /// Leaf `/ArtBox`, raw as above.
244    pub(crate) art_box: Option<pdfboss_core::Rect>,
245    /// Inherited `/Rotate`, raw as above.
246    pub(crate) rotate: Option<i32>,
247}
248
249/// Attributes inherited down the page tree (ISO 32000 §7.7.3.4), carried
250/// raw; defaults apply in [`Page::from_tree_attrs`].
251#[derive(Clone, Default)]
252struct InheritedAttrs {
253    resources: Dict,
254    media_box: Option<pdfboss_core::Rect>,
255    crop_box: Option<pdfboss_core::Rect>,
256    rotate: Option<i32>,
257}
258
259/// Page-tree traversal depth cap, mirroring the sync document model.
260const MAX_TREE_DEPTH: usize = 256;
261
262/// One cross-reference section as found while walking the chain.
263#[derive(Clone)]
264pub(crate) struct SectionRecord {
265    /// Consumed, with `span` and `entries` below, by the element stream's
266    /// physical layer building `Element::XrefSection`.
267    pub(crate) kind: XrefKind,
268    /// Classic: `xref` keyword to the `trailer` keyword. Stream: the whole
269    /// xref-stream object.
270    pub(crate) span: Span,
271    /// Number of entries the section declares (subsection sums).
272    pub(crate) entries: usize,
273    /// The section's trailer dictionary (classic trailer, or the stream's
274    /// own dictionary).
275    pub(crate) trailer_dict: Dict,
276    /// Classic: `trailer` keyword through the dictionary. Stream: same as
277    /// `span`.
278    pub(crate) trailer_span: Span,
279}
280
281/// A parsed section plus everything the chain walk needs from it.
282pub(crate) struct ParsedSection {
283    pub(crate) record: SectionRecord,
284    pub(crate) entries: Vec<(u32, XrefEntry)>,
285    pub(crate) prev: Option<u64>,
286    pub(crate) xrefstm: Option<u64>,
287}
288
289/// Parses the cross-reference section at absolute offset `base` from a
290/// fetched window (ISO 32000 §7.5.4 classic tables, §7.5.8 xref streams).
291/// `Ok(None)` means the window ended inside the section and the caller
292/// must fetch a wider one; hard errors are reserved for input that no
293/// wider window could fix.
294pub(crate) fn parse_section_window(
295    buf: &[u8],
296    base: u64,
297    file_len: u64,
298    at_eof: bool,
299) -> Result<Option<ParsedSection>> {
300    let mut probe = Lexer::new(buf);
301    let classic = matches!(probe.peek_token(),
302                           Ok(Token::Keyword(ref k)) if k.as_slice() == b"xref");
303    if classic {
304        parse_classic_window(buf, base, file_len, at_eof)
305    } else {
306        parse_stream_window(buf, base, at_eof)
307    }
308}
309
310/// Classic table: `xref`, subsections of `start count` then `count` entry
311/// lines of `offset gen n|f` (read token-wise, so 19/20/21-byte entry
312/// lines all load), then `trailer` and its dictionary.
313fn parse_classic_window(
314    buf: &[u8],
315    base: u64,
316    file_len: u64,
317    at_eof: bool,
318) -> Result<Option<ParsedSection>> {
319    /// Maps a mid-window lex/parse failure to "need more bytes" unless the
320    /// window already reaches the end of the file.
321    fn incomplete<T>(at_eof: bool) -> Result<Option<T>> {
322        if at_eof {
323            Err(Error::Core(pdfboss_core::Error::InvalidXref))
324        } else {
325            Ok(None)
326        }
327    }
328
329    let mut lexer = Lexer::new(buf);
330    match lexer.next_token() {
331        Ok(Token::Keyword(ref k)) if k.as_slice() == b"xref" => {}
332        _ => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
333    }
334    let mut entries: Vec<(u32, XrefEntry)> = Vec::new();
335    loop {
336        lexer.skip_whitespace_and_comments();
337        let keyword_start = lexer.pos();
338        let token = match lexer.next_token() {
339            Ok(t) => t,
340            Err(_) => return incomplete(at_eof),
341        };
342        match token {
343            Token::Int(start) if start >= 0 => {
344                let count = match lexer.next_token() {
345                    Ok(Token::Int(c)) if c >= 0 => c as u64,
346                    Ok(Token::Eof) => return incomplete(at_eof),
347                    Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
348                    Err(_) => return incomplete(at_eof),
349                };
350                // Even a degenerate entry line needs at least 11 bytes, so
351                // a count beyond this bound cannot be real regardless of
352                // how wide the window grows: hard error.
353                if count > file_len / 11 + 1 {
354                    return Err(Error::Core(pdfboss_core::Error::InvalidXref));
355                }
356                for i in 0..count {
357                    let field1 = match lexer.next_token() {
358                        Ok(Token::Int(v)) if v >= 0 => v as u64,
359                        Ok(Token::Eof) => return incomplete(at_eof),
360                        Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
361                        Err(_) => return incomplete(at_eof),
362                    };
363                    let field2 = match lexer.next_token() {
364                        Ok(Token::Int(v)) if v >= 0 => v,
365                        Ok(Token::Eof) => return incomplete(at_eof),
366                        Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
367                        Err(_) => return incomplete(at_eof),
368                    };
369                    let entry = match lexer.next_token() {
370                        Ok(Token::Keyword(ref k)) if k.as_slice() == b"n" => XrefEntry::InFile {
371                            offset: field1,
372                            gen: field2.min(65535) as u16,
373                        },
374                        Ok(Token::Keyword(ref k)) if k.as_slice() == b"f" => XrefEntry::Free,
375                        Ok(Token::Eof) => return incomplete(at_eof),
376                        Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
377                        Err(_) => return incomplete(at_eof),
378                    };
379                    if let Ok(num) = u32::try_from(start as u64 + i) {
380                        entries.push((num, entry));
381                    }
382                }
383                // An entry group flush with the window edge may itself be
384                // truncated mid-number: demand slack before trusting it.
385                if lexer.pos() + PARSE_SLACK > buf.len() && !at_eof {
386                    return Ok(None);
387                }
388            }
389            Token::Keyword(ref k) if k.as_slice() == b"trailer" => {
390                let mut parser = Parser::at(buf, lexer.pos());
391                let trailer_dict = match parser.parse_object(&NoResolve) {
392                    Ok(Object::Dict(d)) => d,
393                    Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
394                    Err(_) => return incomplete(at_eof),
395                };
396                if parser.pos() + PARSE_SLACK > buf.len() && !at_eof {
397                    return Ok(None); // the dict may have been cut leniently
398                }
399                let prev = trailer_dict.get_int("Prev").and_then(non_negative);
400                let xrefstm = trailer_dict.get_int("XRefStm").and_then(non_negative);
401                let entry_count = entries.len();
402                return Ok(Some(ParsedSection {
403                    record: SectionRecord {
404                        kind: XrefKind::Table,
405                        span: Span {
406                            start: base,
407                            end: base + keyword_start as u64,
408                        },
409                        entries: entry_count,
410                        trailer_dict,
411                        trailer_span: Span {
412                            start: base + keyword_start as u64,
413                            end: base + parser.pos() as u64,
414                        },
415                    },
416                    entries,
417                    prev,
418                    xrefstm,
419                }));
420            }
421            Token::Eof => return incomplete(at_eof),
422            _ => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
423        }
424    }
425}
426
427/// Cross-reference stream: an indirect stream object whose decoded data
428/// holds fixed-width big-endian fields laid out per `/W`; a zero-width
429/// type field defaults to type 1, `/Index` defaults to `[0 Size]`, and the
430/// stream's own dictionary is the section trailer.
431fn parse_stream_window(buf: &[u8], base: u64, at_eof: bool) -> Result<Option<ParsedSection>> {
432    let mut parser = Parser::at(buf, 0);
433    let stream = match parser.parse_indirect(&NoResolve) {
434        Ok((_, Object::Stream(s))) => s,
435        // Core's dict parser leniently breaks on `Eof` instead of erroring,
436        // so a window cut inside the dictionary (before the `stream`
437        // keyword is even reached) parses as a plain `Object::Dict` sitting
438        // flush with the window edge: ask for more bytes rather than
439        // hard-erroring, unless the window already reaches file end.
440        Ok(_) => {
441            if !at_eof && parser.pos() + PARSE_SLACK > buf.len() {
442                return Ok(None);
443            }
444            return Err(Error::Core(pdfboss_core::Error::InvalidXref));
445        }
446        Err(_) if !at_eof => return Ok(None),
447        Err(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
448    };
449    if parser.pos() + PARSE_SLACK > buf.len() && !at_eof {
450        return Ok(None); // stream data may have been cut leniently
451    }
452    // Trust a declared /Length only when the parsed data honors it — a
453    // window cut inside the stream falls into the lenient recovery path
454    // and must grow instead. A missing or unresolvable (indirect) /Length
455    // carries no verifiable bound at all, so it can only be accepted once
456    // the window reaches file end.
457    match stream.dict.get_int("Length") {
458        Some(declared) if declared >= 0 => {
459            if stream.data.len() as u64 != declared as u64 && !at_eof {
460                return Ok(None);
461            }
462        }
463        _ => {
464            if !at_eof {
465                return Ok(None);
466            }
467        }
468    }
469    let decoded = pdfboss_core::filters::decode_stream(&stream, &NoResolve)
470        .map_err(|_| Error::Core(pdfboss_core::Error::InvalidXref))?;
471    let dict = stream.dict;
472    let widths: Vec<usize> = dict
473        .get_array("W")
474        .ok_or(Error::Core(pdfboss_core::Error::InvalidXref))?
475        .iter()
476        .map(|v| {
477            v.as_int()
478                .filter(|&n| (0..=8).contains(&n))
479                .map(|n| n as usize)
480        })
481        .collect::<Option<Vec<_>>>()
482        .ok_or(Error::Core(pdfboss_core::Error::InvalidXref))?;
483    let w1 = widths.first().copied().unwrap_or(0);
484    let w2 = widths.get(1).copied().unwrap_or(0);
485    let w3 = widths.get(2).copied().unwrap_or(0);
486    let entry_len = w1 + w2 + w3;
487    if entry_len == 0 {
488        return Err(Error::Core(pdfboss_core::Error::InvalidXref));
489    }
490    let size = dict.get_int("Size").unwrap_or(0).max(0) as u64;
491    let subsections: Vec<(u64, u64)> = match dict.get_array("Index") {
492        Some(index) => index
493            .chunks(2)
494            .filter_map(|pair| {
495                let start = pair.first()?.as_int()?;
496                let count = pair.get(1)?.as_int()?;
497                (start >= 0 && count >= 0).then_some((start as u64, count as u64))
498            })
499            .collect(),
500        None => vec![(0, size)],
501    };
502    let mut entries: Vec<(u32, XrefEntry)> = Vec::new();
503    let mut pos = 0usize;
504    'subsections: for (start, count) in subsections {
505        for i in 0..count {
506            if pos + entry_len > decoded.len() {
507                break 'subsections; // lenient: truncated data ends the table
508            }
509            let kind = if w1 == 0 {
510                1
511            } else {
512                read_be(&decoded[pos..pos + w1])
513            };
514            let field2 = read_be(&decoded[pos + w1..pos + w1 + w2]);
515            let field3 = read_be(&decoded[pos + w1 + w2..pos + entry_len]);
516            pos += entry_len;
517            let entry = match kind {
518                1 => XrefEntry::InFile {
519                    offset: field2,
520                    gen: field3.min(65535) as u16,
521                },
522                2 => match (u32::try_from(field2), u32::try_from(field3)) {
523                    (Ok(stream_num), Ok(index)) => XrefEntry::InStream { stream_num, index },
524                    _ => XrefEntry::Free,
525                },
526                // Type 0 is free; unknown types read as references to the
527                // null object, which a free entry models exactly.
528                _ => XrefEntry::Free,
529            };
530            if let Ok(num) = u32::try_from(start + i) {
531                entries.push((num, entry));
532            }
533        }
534    }
535    let prev = dict.get_int("Prev").and_then(non_negative);
536    let span = Span {
537        start: base,
538        end: base + parser.pos() as u64,
539    };
540    let entry_count = entries.len();
541    Ok(Some(ParsedSection {
542        record: SectionRecord {
543            kind: XrefKind::Stream,
544            span,
545            entries: entry_count,
546            trailer_dict: dict,
547            trailer_span: span,
548        },
549        entries,
550        prev,
551        xrefstm: None,
552    }))
553}
554
555/// Big-endian integer from up to 8 bytes; an empty slice reads as 0.
556fn read_be(bytes: &[u8]) -> u64 {
557    bytes.iter().fold(0, |acc, &b| (acc << 8) | u64::from(b))
558}
559
560/// Keeps non-negative integers as offsets, dropping the rest.
561fn non_negative(value: i64) -> Option<u64> {
562    u64::try_from(value).ok()
563}
564
565/// Merged cross-reference entries plus the merged trailer, mirroring the
566/// sync loader's newest-wins semantics.
567pub(crate) struct XrefIndex {
568    pub(crate) entries: HashMap<u32, XrefEntry>,
569    /// The merged trailer dictionary, e.g. `/Info` lookups in `metadata()`;
570    /// also consumed, with `trailer_span` below, by the `merged_trailer()`
571    /// accessor.
572    pub(crate) trailer: Dict,
573    /// Span for the single merged `Trailer` element: the newest section's
574    /// trailer region (classic), or that section's own span (stream) —
575    /// adopted rule 4.
576    pub(crate) trailer_span: Span,
577}
578
579/// A PDF document over an async random-access backend. The whole file is
580/// never read: opening fetches only the tail, the xref chain and the page
581/// tree; objects are fetched span-by-span on demand.
582///
583/// Cloning is cheap (a shared handle); every method takes `&self`, so one
584/// instance can serve many tasks concurrently.
585#[derive(Clone)]
586pub struct AsyncDocument {
587    pub(crate) inner: Arc<DocumentInner>,
588}
589
590pub(crate) struct DocumentInner {
591    pub(crate) backend: Arc<dyn Backend>,
592    pub(crate) file_len: u64,
593    pub(crate) version: (u8, u8),
594    /// Span of the `%PDF-` header run; `None` when the first 1 KiB holds
595    /// no header (the Header element is then omitted, adopted rule 1).
596    /// Exposed by a `header_span()` accessor consumed by the element
597    /// stream's physical layer.
598    pub(crate) header_span: Option<Span>,
599    pub(crate) xref: XrefIndex,
600    /// Sections in chain order — newest→oldest — for the element stream.
601    /// Exposed by a `sections()` accessor consumed by the element stream's
602    /// physical layer.
603    pub(crate) sections: Vec<SectionRecord>,
604    /// Exposed by a `startxref_record()` accessor consumed by the element
605    /// stream's physical layer.
606    pub(crate) startxref: StartXrefRecord,
607    /// Exposed by an `eof_span()` accessor consumed by the element stream's
608    /// physical layer.
609    pub(crate) eof_span: Option<Span>,
610    /// Cache of fetched indirect objects.
611    pub(crate) objects: std::sync::Mutex<HashMap<(u32, u16), Arc<Object>>>,
612    /// Decoded object streams, keyed by container number, so a resident
613    /// container is fetched, decoded and header-parsed once. The map lock
614    /// is never held across a fetch (no deadlocks on nested containers);
615    /// concurrent misses may decode twice, and the first insert wins.
616    pub(crate) objstms: tokio::sync::Mutex<HashMap<u32, Arc<ObjStmCache>>>,
617    /// The flattened page tree, set exactly once at the end of the open
618    /// flow (fetching only catalog and tree nodes).
619    pub(crate) pages: std::sync::OnceLock<Vec<PageRecord>>,
620    /// Present when the file uses the Standard security handler and opens
621    /// under the empty user password; decrypts strings and stream data as
622    /// objects are parsed from the file, exactly as the synchronous
623    /// document does. Set exactly once during the open flow, before any
624    /// content object is fetched.
625    pub(crate) decryptor: std::sync::OnceLock<pdfboss_core::Decryptor>,
626}
627
628impl AsyncDocument {
629    /// Opens a file through a [`FileBackend`] wrapped in a
630    /// [`CachedBackend`] with default capacity.
631    ///
632    /// Files encrypted with the Standard security handler under the empty
633    /// user password are decrypted transparently, exactly as the
634    /// synchronous document decrypts them; a file that requires a real
635    /// password is rejected with [`pdfboss_core::Error::Encrypted`].
636    pub async fn open(path: impl AsRef<Path>) -> Result<AsyncDocument> {
637        AsyncDocument::open_with_password(path, "").await
638    }
639
640    /// [`AsyncDocument::open`] with the password that opens the file,
641    /// accepted as either the user or the owner password — the async twin
642    /// of [`pdfboss_core::Document::open_with_password`].
643    pub async fn open_with_password(
644        path: impl AsRef<Path>,
645        password: &str,
646    ) -> Result<AsyncDocument> {
647        let backend = FileBackend::open(path).await.map_err(Error::from)?;
648        AsyncDocument::from_arc(Arc::new(CachedBackend::new(backend)), password).await
649    }
650
651    /// Opens an in-memory document through an uncached [`MemBackend`].
652    ///
653    /// Decrypts empty-password Standard-handler files transparently, like
654    /// [`AsyncDocument::open`]; a required password is rejected with
655    /// [`pdfboss_core::Error::Encrypted`].
656    pub async fn from_bytes(bytes: impl Into<Bytes>) -> Result<AsyncDocument> {
657        AsyncDocument::from_bytes_with_password(bytes, "").await
658    }
659
660    /// [`AsyncDocument::from_bytes`] with the password that opens the file,
661    /// accepted as either the user or the owner password.
662    pub async fn from_bytes_with_password(
663        bytes: impl Into<Bytes>,
664        password: &str,
665    ) -> Result<AsyncDocument> {
666        AsyncDocument::from_arc(Arc::new(MemBackend::from(bytes.into())), password).await
667    }
668
669    /// Opens a document over any backend, as-is (no cache is added).
670    ///
671    /// Decrypts empty-password Standard-handler files transparently, like
672    /// [`AsyncDocument::open`]; a required password is rejected with
673    /// [`pdfboss_core::Error::Encrypted`].
674    pub async fn with_backend(backend: impl Backend) -> Result<AsyncDocument> {
675        AsyncDocument::from_arc(Arc::new(backend), "").await
676    }
677
678    /// [`AsyncDocument::with_backend`] with the password that opens the
679    /// file, accepted as either the user or the owner password.
680    pub async fn with_backend_with_password(
681        backend: impl Backend,
682        password: &str,
683    ) -> Result<AsyncDocument> {
684        AsyncDocument::from_arc(Arc::new(backend), password).await
685    }
686
687    /// Opens a remote document over HTTP range requests, wrapped in a
688    /// [`CachedBackend`] with default capacity.
689    ///
690    /// Decrypts empty-password Standard-handler files transparently, like
691    /// [`AsyncDocument::open`]; a required password is rejected with
692    /// [`pdfboss_core::Error::Encrypted`].
693    #[cfg(feature = "http")]
694    pub async fn open_url(url: impl reqwest::IntoUrl) -> Result<AsyncDocument> {
695        AsyncDocument::open_url_with_password(url, "").await
696    }
697
698    /// [`AsyncDocument::open_url`] with the password that opens the file,
699    /// accepted as either the user or the owner password.
700    #[cfg(feature = "http")]
701    pub async fn open_url_with_password(
702        url: impl reqwest::IntoUrl,
703        password: &str,
704    ) -> Result<AsyncDocument> {
705        let backend = crate::backend::HttpBackend::new(url).await?;
706        AsyncDocument::from_arc(Arc::new(CachedBackend::new(backend)), password).await
707    }
708
709    /// The open flow: header window → tail scan → xref chain → indexes.
710    async fn from_arc(backend: Arc<dyn Backend>, password: &str) -> Result<AsyncDocument> {
711        let file_len = backend.len().await.map_err(Error::from)?;
712        let fetcher = Fetcher {
713            backend: Arc::clone(&backend),
714            len: file_len,
715        };
716        let head = fetcher.window(0, 1024).await?;
717        let version = parse_version(&head);
718        let header_span = header_span_in(&head);
719        let (startxref, eof_span) = find_tail(&fetcher).await?;
720        let (xref, sections) = load_xref_chain(&fetcher, startxref.offset).await?;
721        let encrypted = xref.trailer.get("Encrypt").is_some_and(|o| !o.is_null());
722        let inner = DocumentInner {
723            backend,
724            file_len,
725            version,
726            header_span,
727            xref,
728            sections,
729            startxref,
730            eof_span,
731            objects: std::sync::Mutex::new(HashMap::new()),
732            objstms: tokio::sync::Mutex::new(HashMap::new()),
733            pages: std::sync::OnceLock::new(),
734            decryptor: std::sync::OnceLock::new(),
735        };
736        let doc = AsyncDocument {
737            inner: Arc::new(inner),
738        };
739        if encrypted {
740            doc.setup_decryption(password).await?;
741        }
742        let pages = doc.flatten_pages().await;
743        doc.inner
744            .pages
745            .set(pages)
746            .expect("page index is set exactly once at open");
747        Ok(doc)
748    }
749
750    /// Configures decryption for an encrypted file, mirroring the
751    /// synchronous document: the Standard handler with RC4 (`/V` 1-2),
752    /// AESV2 (`/V` 4) and AESV3 (`/V` 5), with `password` accepted as the
753    /// user or the owner password (the empty string is the transparent
754    /// empty-user-password case); a password the file does not accept is
755    /// reported as [`pdfboss_core::Error::Encrypted`]. Runs before any
756    /// content object is fetched, and reads `/Encrypt` and `/ID` while
757    /// decryption is still off — both are stored unencrypted.
758    async fn setup_decryption(&self, password: &str) -> Result<()> {
759        let enc_obj = self
760            .inner
761            .xref
762            .trailer
763            .get("Encrypt")
764            .cloned()
765            .unwrap_or(Object::Null);
766        let enc = self.resolve(&enc_obj).await?;
767        let enc_dict = enc
768            .as_dict()
769            .ok_or(Error::Core(pdfboss_core::Error::Encrypted))?;
770        let id0: Vec<u8> = self
771            .inner
772            .xref
773            .trailer
774            .get("ID")
775            .and_then(Object::as_array)
776            .and_then(<[Object]>::first)
777            .and_then(Object::as_str_bytes)
778            .unwrap_or(&[])
779            .to_vec();
780        match pdfboss_core::Decryptor::from_standard_with_password_str(enc_dict, &id0, password) {
781            Some(dec) => {
782                self.inner
783                    .decryptor
784                    .set(dec)
785                    .map_err(|_| Error::Core(pdfboss_core::Error::Encrypted))?;
786                // Objects fetched while resolving /Encrypt were cached
787                // without decryption; drop them so they are re-read through
788                // the decrypting path if referenced again. The container
789                // cache too, for the same reason.
790                self.inner
791                    .objects
792                    .lock()
793                    .expect("object cache mutex")
794                    .clear();
795                self.inner.objstms.lock().await.clear();
796                Ok(())
797            }
798            None => Err(Error::Core(pdfboss_core::Error::Encrypted)),
799        }
800    }
801
802    /// The PDF version from the header, e.g. `(1, 7)`.
803    pub fn version(&self) -> (u8, u8) {
804        self.inner.version
805    }
806
807    /// A fetch helper bound to this document's backend.
808    pub(crate) fn fetcher(&self) -> Fetcher {
809        Fetcher {
810            backend: Arc::clone(&self.inner.backend),
811            len: self.inner.file_len,
812        }
813    }
814}
815
816/// What lets every shared algorithm run over an asynchronous document:
817/// `page_content_with`, `extract_text_with`, `render_page_reporting_with`
818/// all take any `AsyncObjectSource`, and this is the genuinely asynchronous
819/// one. `AsyncDocument` is an `Arc` handle, so cloning one to hand to an
820/// entry point by value is two atomic increments.
821///
822/// The trait speaks core's `Result`, so this crate's transport failures
823/// cross the boundary as [`pdfboss_core::Error::Transport`] (parse-layer
824/// errors unwrap back to their original variant — see the `From` impl in
825/// `crate::error`). Leniency inside the shared algorithms keys off the parse
826/// variants, which survive the crossing unchanged.
827impl AsyncObjectSource for AsyncDocument {
828    fn get(&self, r: ObjRef) -> BoxFuture<'_, pdfboss_core::Result<Object>> {
829        Box::pin(async move { self.get_object(r).await.map_err(Into::into) })
830    }
831
832    fn stream_data<'a>(&'a self, s: &'a Stream) -> BoxFuture<'a, pdfboss_core::Result<Vec<u8>>> {
833        Box::pin(async move { self.decode_stream(s).await.map_err(Into::into) })
834    }
835
836    fn resolve<'a>(&'a self, o: &'a Object) -> BoxFuture<'a, pdfboss_core::Result<Object>> {
837        // The inherent `resolve` wins the name lookup here, so this forwards
838        // the document's own lenient chain-chasing, not the shared loop.
839        Box::pin(async move { self.resolve(o).await.map_err(Into::into) })
840    }
841}
842
843/// Initial object window, doubling until the object parses completely.
844const OBJECT_WINDOW: usize = 2048;
845/// Reference-chase depth limit, mirroring the sync document model.
846const MAX_RESOLVE_DEPTH: usize = 32;
847
848/// Resolver for window parsing: answers the one known `/Length` value and
849/// records the first reference it could not answer, so the caller can
850/// fetch it and re-parse.
851struct LengthProbe {
852    known: Option<(ObjRef, i64)>,
853    missing: std::cell::Cell<Option<ObjRef>>,
854}
855
856impl LengthProbe {
857    fn new(known: Option<(ObjRef, i64)>) -> LengthProbe {
858        LengthProbe {
859            known,
860            missing: std::cell::Cell::new(None),
861        }
862    }
863
864    fn missing(&self) -> Option<ObjRef> {
865        self.missing.get()
866    }
867}
868
869impl Resolve for LengthProbe {
870    fn resolve_ref(&self, r: ObjRef) -> Option<Object> {
871        match self.known {
872            Some((known_ref, value)) if known_ref == r => Some(Object::Int(value)),
873            _ => {
874                if self.missing.get().is_none() {
875                    self.missing.set(Some(r));
876                }
877                None
878            }
879        }
880    }
881}
882
883/// True when `object` is a stream whose declared `/Length` (direct, or the
884/// known indirect value) does not match the bytes the parser captured —
885/// the signature of a stream cut by the window edge, which fell into the
886/// lenient recovery path the sync parser would not have taken.
887fn stream_dishonors_length(object: &Object, known_length: Option<(ObjRef, i64)>) -> bool {
888    let Some(stream) = object.as_stream() else {
889        return false;
890    };
891    let declared = match stream.dict.get("Length") {
892        Some(Object::Int(n)) => Some(*n),
893        Some(Object::Ref(r)) => {
894            known_length.and_then(|(known_ref, value)| (known_ref == *r).then_some(value))
895        }
896        _ => None,
897    };
898    match declared {
899        Some(length) if length >= 0 => stream.data.len() as u64 != length as u64,
900        _ => false,
901    }
902}
903
904/// Human-readable object type name for error messages.
905fn object_type_name(o: &Object) -> &'static str {
906    match o {
907        Object::Null => "null",
908        Object::Bool(_) => "boolean",
909        Object::Int(_) => "integer",
910        Object::Real(_) => "real",
911        Object::String(_) => "string",
912        Object::Name(_) => "name",
913        Object::Array(_) => "array",
914        Object::Dict(_) => "dictionary",
915        Object::Stream(_) => "stream",
916        Object::Ref(_) => "reference",
917    }
918}
919
920/// A fetched and decoded object stream: the container's physical span, the
921/// decoded bytes, and each member's number and offset (ISO 32000 §7.5.7).
922pub(crate) struct ObjStmCache {
923    /// Consumed by the element stream's physical layer.
924    pub(crate) container: ObjRef,
925    /// Consumed, with `container` above, by the element stream's physical
926    /// layer.
927    pub(crate) container_span: Span,
928    first: usize,
929    data: Vec<u8>,
930    /// (object number, offset relative to `first`) per member, in header
931    /// order.
932    pub(crate) members: Vec<(u32, usize)>,
933}
934
935impl ObjStmCache {
936    /// Parses member `index` out of the decoded bytes.
937    pub(crate) fn object(&self, index: u32) -> Result<Object> {
938        let start = self.member_start(index)?;
939        Parser::at(&self.data, start)
940            .parse_object(&NoResolve)
941            .map_err(Error::Core)
942    }
943
944    /// Member `index`'s byte range within the decoded stream: from its
945    /// header offset to the parser position after its last token.
946    /// Consumed by the element stream's physical layer.
947    pub(crate) fn member_span(&self, index: u32) -> Result<Span> {
948        let start = self.member_start(index)?;
949        let mut parser = Parser::at(&self.data, start);
950        parser.parse_object(&NoResolve).map_err(Error::Core)?;
951        Ok(Span {
952            start: start as u64,
953            end: parser.pos() as u64,
954        })
955    }
956
957    /// Absolute start of member `index` within the decoded bytes.
958    fn member_start(&self, index: u32) -> Result<usize> {
959        let offset = self
960            .members
961            .get(index as usize)
962            .map(|entry| entry.1)
963            .ok_or_else(|| {
964                Error::Core(pdfboss_core::Error::Other(format!(
965                    "object stream index {index} out of range (N = {})",
966                    self.members.len()
967                )))
968            })?;
969        self.first
970            .checked_add(offset)
971            .filter(|&pos| pos <= self.data.len())
972            .ok_or_else(|| {
973                Error::Core(pdfboss_core::Error::Other(format!(
974                    "object stream offset {offset} lies outside the stream"
975                )))
976            })
977    }
978}
979
980/// Parses the object-stream header: `2*n` integers, pairs of object number
981/// and byte offset relative to `/First` (ISO 32000 §7.5.7).
982fn parse_objstm_header(data: &[u8], n: usize) -> Result<Vec<(u32, usize)>> {
983    let mut lexer = Lexer::new(data);
984    let mut members = Vec::with_capacity(n);
985    for _ in 0..n {
986        let num = expect_header_int(&mut lexer)?;
987        let offset = expect_header_int(&mut lexer)?;
988        members.push((u32::try_from(num).unwrap_or(u32::MAX), offset));
989    }
990    Ok(members)
991}
992
993/// Reads one non-negative integer from the object-stream header.
994fn expect_header_int(lexer: &mut Lexer) -> Result<usize> {
995    match lexer.next_token().map_err(Error::Core)? {
996        Token::Int(v) if v >= 0 => Ok(v as usize),
997        _ => Err(Error::Core(pdfboss_core::Error::Syntax {
998            offset: lexer.pos(),
999            msg: "malformed object stream header".to_string(),
1000        })),
1001    }
1002}
1003
1004/// A [`Resolve`] over a prefetched reference map.
1005struct MapResolve(HashMap<ObjRef, Object>);
1006
1007impl Resolve for MapResolve {
1008    fn resolve_ref(&self, r: ObjRef) -> Option<Object> {
1009        self.0.get(&r).cloned()
1010    }
1011}
1012
1013impl AsyncDocument {
1014    /// Fetches an indirect object by reference (xref lookup, object-stream
1015    /// indirection, cached). A generation mismatch between the request and
1016    /// the file is tolerated (lenient), mirroring the sync document.
1017    pub async fn get_object(&self, r: ObjRef) -> Result<Object> {
1018        let mut chain = Vec::new();
1019        self.fetch_object_cached(r, &mut chain).await
1020    }
1021
1022    /// Chases reference chains with a depth guard (beyond that:
1023    /// `CircularReference`); a reference to a missing or unreadable object
1024    /// resolves to `Null` (lenient), mirroring the sync document.
1025    pub async fn resolve(&self, o: &Object) -> Result<Object> {
1026        let mut chain = Vec::new();
1027        self.resolve_with_chain(o, &mut chain).await
1028    }
1029
1030    /// Cached fetch. `chain` carries the object numbers currently being
1031    /// loaded up this call path, guarding re-entrant fetches (e.g. a
1032    /// stream whose `/Length` refers back to the stream itself) without
1033    /// blocking unrelated concurrent fetches of the same object.
1034    pub(crate) fn fetch_object_cached<'a>(
1035        &'a self,
1036        r: ObjRef,
1037        chain: &'a mut Vec<u32>,
1038    ) -> BoxFuture<'a, Result<Object>> {
1039        Box::pin(async move {
1040            if let Some(cached) = self
1041                .inner
1042                .objects
1043                .lock()
1044                .expect("object cache mutex")
1045                .get(&(r.num, r.gen))
1046            {
1047                return Ok((**cached).clone());
1048            }
1049            if chain.contains(&r.num) {
1050                return Err(Error::Core(pdfboss_core::Error::CircularReference(r.num)));
1051            }
1052            chain.push(r.num);
1053            let outcome = self.load_object(r, chain).await;
1054            chain.pop();
1055            let object = outcome?;
1056            self.inner
1057                .objects
1058                .lock()
1059                .expect("object cache mutex")
1060                .insert((r.num, r.gen), Arc::new(object.clone()));
1061            Ok(object)
1062        })
1063    }
1064
1065    /// Uncached fetch: parses the object at its file offset or extracts it
1066    /// from its containing object stream.
1067    async fn load_object(&self, r: ObjRef, chain: &mut Vec<u32>) -> Result<Object> {
1068        match self.inner.xref.entries.get(&r.num).copied() {
1069            None | Some(XrefEntry::Free) => Err(Error::Core(pdfboss_core::Error::ObjectNotFound(
1070                r.num, r.gen,
1071            ))),
1072            Some(XrefEntry::InFile { offset, .. }) => {
1073                let parsed = self.parse_in_file(offset, chain).await?;
1074                Ok(parsed.1)
1075            }
1076            Some(XrefEntry::InStream { stream_num, index }) => {
1077                let cache = self.objstm_cache_with_chain(stream_num, chain).await?;
1078                cache.object(index)
1079            }
1080        }
1081    }
1082
1083    /// Parses the indirect object at `offset` from a growing window (2 KiB
1084    /// doubling), returning the object and its physical span
1085    /// (`N G obj … endobj`, end-exclusive). An indirect `/Length` triggers
1086    /// exactly one extra object fetch, then a re-parse with the value
1087    /// known. The parse is only accepted when it provably matches what the
1088    /// sync parser would produce on the whole file: slack after the parse
1089    /// end (or true end of file), and stream data honoring its declared
1090    /// length.
1091    pub(crate) async fn parse_in_file(
1092        &self,
1093        offset: u64,
1094        chain: &mut Vec<u32>,
1095    ) -> Result<(Span, Object)> {
1096        if offset >= self.inner.file_len {
1097            return Err(Error::Core(pdfboss_core::Error::Other(format!(
1098                "object offset {offset} lies outside the file"
1099            ))));
1100        }
1101        let fetcher = self.fetcher();
1102        let mut window = OBJECT_WINDOW;
1103        let mut known_length: Option<(ObjRef, i64)> = None;
1104        loop {
1105            let buf = fetcher.window(offset, window).await?;
1106            let at_eof = offset + buf.len() as u64 >= self.inner.file_len;
1107            let probe = LengthProbe::new(known_length);
1108            let mut parser = Parser::at(&buf, 0);
1109            match parser.parse_indirect(&probe) {
1110                Ok((parsed_ref, mut object)) => {
1111                    let end = parser.pos();
1112                    if end + PARSE_SLACK <= buf.len() || at_eof {
1113                        if let Some(missing) = probe.missing() {
1114                            if let Ok(length_object) =
1115                                self.fetch_object_cached(missing, chain).await
1116                            {
1117                                if let Some(value) = length_object.as_int() {
1118                                    known_length = Some((missing, value));
1119                                    continue;
1120                                }
1121                            }
1122                            // Unresolvable length: the recovery-scan result
1123                            // stands, exactly as in the sync parser.
1124                        }
1125                        if at_eof || !stream_dishonors_length(&object, known_length) {
1126                            // Objects stored directly in the file carry
1127                            // encrypted strings and stream data; decrypt with
1128                            // the object's OWN header numbers, exactly as the
1129                            // synchronous parse does. (Objects living in
1130                            // object streams are decrypted with their
1131                            // container, which passes through here.)
1132                            if let Some(dec) = self.inner.decryptor.get() {
1133                                dec.decrypt_object(&mut object, parsed_ref.num, parsed_ref.gen);
1134                            }
1135                            return Ok((
1136                                Span {
1137                                    start: offset,
1138                                    end: offset + end as u64,
1139                                },
1140                                object,
1141                            ));
1142                        }
1143                    }
1144                }
1145                Err(parse_error) => {
1146                    if at_eof {
1147                        return Err(Error::Core(parse_error));
1148                    }
1149                }
1150            }
1151            if window as u64 >= MAX_GROWTH_WINDOW {
1152                return Err(Error::Core(pdfboss_core::Error::Other(format!(
1153                    "invalid or unrecoverable cross-reference data: object at offset {offset} \
1154                     exceeded the {MAX_GROWTH_WINDOW}-byte parse window without completing"
1155                ))));
1156            }
1157            window = window.saturating_mul(2);
1158        }
1159    }
1160
1161    /// The decoded container for object stream `stream_num`, fetched,
1162    /// decoded and header-parsed at most once. Consumed by the element
1163    /// stream's physical layer.
1164    pub(crate) async fn objstm_cache(&self, stream_num: u32) -> Result<Arc<ObjStmCache>> {
1165        let mut chain = Vec::new();
1166        self.objstm_cache_with_chain(stream_num, &mut chain).await
1167    }
1168
1169    async fn objstm_cache_with_chain(
1170        &self,
1171        stream_num: u32,
1172        chain: &mut Vec<u32>,
1173    ) -> Result<Arc<ObjStmCache>> {
1174        // Circularity is checked first: a reference chain leading back into
1175        // a container being decoded must fail fast. The map lock is never
1176        // held across the build below, so nested container fetches can
1177        // never deadlock on it; concurrent misses may decode a container
1178        // twice, and the first insert wins (correctness is unaffected).
1179        if chain.contains(&stream_num) {
1180            return Err(Error::Core(pdfboss_core::Error::CircularReference(
1181                stream_num,
1182            )));
1183        }
1184        if let Some(hit) = self.inner.objstms.lock().await.get(&stream_num) {
1185            return Ok(Arc::clone(hit));
1186        }
1187        let offset = match self.inner.xref.entries.get(&stream_num).copied() {
1188            Some(XrefEntry::InFile { offset, .. }) => offset,
1189            // A container cannot itself live in an object stream
1190            // (ISO 32000 §7.5.7), and a free or absent one has no bytes.
1191            Some(XrefEntry::InStream { .. }) | Some(XrefEntry::Free) | None => {
1192                return Err(Error::Core(pdfboss_core::Error::ObjectNotFound(
1193                    stream_num, 0,
1194                )))
1195            }
1196        };
1197        chain.push(stream_num);
1198        let outcome = self.build_objstm_cache(stream_num, offset, chain).await;
1199        chain.pop();
1200        let entry = outcome?;
1201        let mut cache = self.inner.objstms.lock().await;
1202        let stored = cache
1203            .entry(stream_num)
1204            .or_insert_with(|| Arc::clone(&entry));
1205        Ok(Arc::clone(stored))
1206    }
1207
1208    /// Fetches, decodes and header-parses one container. `chain` already
1209    /// carries the container's number.
1210    async fn build_objstm_cache(
1211        &self,
1212        stream_num: u32,
1213        offset: u64,
1214        chain: &mut Vec<u32>,
1215    ) -> Result<Arc<ObjStmCache>> {
1216        let (container_span, object) = self.parse_in_file(offset, chain).await?;
1217        let stream = object
1218            .as_stream()
1219            .ok_or(Error::Core(pdfboss_core::Error::TypeMismatch {
1220                expected: "stream",
1221                found: object_type_name(&object),
1222            }))?;
1223        let n = self
1224            .resolve_with_chain(stream.dict.get("N").unwrap_or(&Object::Null), chain)
1225            .await?
1226            .as_int()
1227            .and_then(|v| usize::try_from(v).ok())
1228            .ok_or(Error::Core(pdfboss_core::Error::MissingKey("N")))?;
1229        let first = self
1230            .resolve_with_chain(stream.dict.get("First").unwrap_or(&Object::Null), chain)
1231            .await?
1232            .as_int()
1233            .and_then(|v| usize::try_from(v).ok())
1234            .ok_or(Error::Core(pdfboss_core::Error::MissingKey("First")))?;
1235        let data = self.decode_stream_with_chain(stream, chain).await?;
1236        let members = parse_objstm_header(&data, n)?;
1237        Ok(Arc::new(ObjStmCache {
1238            container: ObjRef {
1239                num: stream_num,
1240                gen: 0,
1241            },
1242            container_span,
1243            first,
1244            data,
1245            members,
1246        }))
1247    }
1248
1249    /// Chain-threaded resolve (see [`AsyncDocument::resolve`]).
1250    pub(crate) async fn resolve_with_chain(
1251        &self,
1252        o: &Object,
1253        chain: &mut Vec<u32>,
1254    ) -> Result<Object> {
1255        let mut current = o.clone();
1256        let mut last_num = 0;
1257        for _ in 0..MAX_RESOLVE_DEPTH {
1258            match current {
1259                Object::Ref(r) => {
1260                    last_num = r.num;
1261                    current = match self.fetch_object_cached(r, chain).await {
1262                        Ok(object) => object,
1263                        Err(Error::Core(pdfboss_core::Error::CircularReference(n))) => {
1264                            return Err(Error::Core(pdfboss_core::Error::CircularReference(n)))
1265                        }
1266                        Err(_) => return Ok(Object::Null),
1267                    };
1268                }
1269                other => return Ok(other),
1270            }
1271        }
1272        Err(Error::Core(pdfboss_core::Error::CircularReference(
1273            last_num,
1274        )))
1275    }
1276
1277    /// Decodes a stream through its filter chain. The sync filter pipeline
1278    /// resolves references synchronously, so every reference reachable
1279    /// from the filter-relevant dict keys is fetched up front into a map
1280    /// the pipeline can consult.
1281    pub(crate) async fn decode_stream_with_chain(
1282        &self,
1283        s: &Stream,
1284        chain: &mut Vec<u32>,
1285    ) -> Result<Vec<u8>> {
1286        let resolver = self.prefetch_filter_refs(&s.dict, chain).await;
1287        pdfboss_core::filters::decode_stream(s, &resolver).map_err(Error::Core)
1288    }
1289
1290    /// Transitively resolves references reachable from the stream dict's
1291    /// filter-relevant keys (bounded rounds; failures resolve to Null,
1292    /// lenient).
1293    async fn prefetch_filter_refs(&self, dict: &Dict, chain: &mut Vec<u32>) -> MapResolve {
1294        const FILTER_KEYS: [&str; 5] = ["Length", "Filter", "DecodeParms", "DP", "F"];
1295        let mut map: HashMap<ObjRef, Object> = HashMap::new();
1296        let mut frontier: Vec<Object> = FILTER_KEYS
1297            .iter()
1298            .filter_map(|key| dict.get(key).cloned())
1299            .collect();
1300        for _ in 0..MAX_RESOLVE_DEPTH {
1301            let mut next = Vec::new();
1302            for value in frontier.drain(..) {
1303                match value {
1304                    Object::Ref(r) => {
1305                        if let std::collections::hash_map::Entry::Vacant(entry) = map.entry(r) {
1306                            let resolved = match self.fetch_object_cached(r, chain).await {
1307                                Ok(object) => object,
1308                                Err(_) => Object::Null,
1309                            };
1310                            next.push(resolved.clone());
1311                            entry.insert(resolved);
1312                        }
1313                    }
1314                    Object::Array(items) => next.extend(items),
1315                    Object::Dict(d) => next.extend(d.iter().map(|(_, v)| v.clone())),
1316                    _ => {}
1317                }
1318            }
1319            if next.is_empty() {
1320                break;
1321            }
1322            frontier = next;
1323        }
1324        MapResolve(map)
1325    }
1326
1327    /// Decodes a stream's data through its filter chain, resolving indirect
1328    /// filter parameters against this document.
1329    pub async fn decode_stream(&self, s: &Stream) -> Result<Vec<u8>> {
1330        let mut chain = Vec::new();
1331        self.decode_stream_with_chain(s, &mut chain).await
1332    }
1333
1334    /// Raw file bytes for `span` (for hex views), clamped to the file
1335    /// length.
1336    pub async fn read_span(&self, span: Span) -> Result<Vec<u8>> {
1337        let start = span.start.min(self.inner.file_len);
1338        let end = span.end.min(self.inner.file_len);
1339        if start >= end {
1340            return Ok(Vec::new());
1341        }
1342        self.fetcher().read_range(start, end).await
1343    }
1344
1345    /// Total length of the underlying file in bytes.
1346    pub fn file_len(&self) -> u64 {
1347        self.inner.file_len
1348    }
1349
1350    /// Document metadata from the trailer `/Info` dictionary (lenient:
1351    /// absent or malformed entries are simply `None`), mirroring the sync
1352    /// document.
1353    pub async fn metadata(&self) -> Result<Metadata> {
1354        let mut meta = Metadata::default();
1355        let Some(info) = self.inner.xref.trailer.get("Info") else {
1356            return Ok(meta);
1357        };
1358        let Ok(info) = self.resolve(info).await else {
1359            return Ok(meta);
1360        };
1361        let Some(dict) = info.as_dict() else {
1362            return Ok(meta);
1363        };
1364        meta.title = self.meta_string(dict, "Title").await;
1365        meta.author = self.meta_string(dict, "Author").await;
1366        meta.subject = self.meta_string(dict, "Subject").await;
1367        meta.keywords = self.meta_string(dict, "Keywords").await;
1368        meta.creator = self.meta_string(dict, "Creator").await;
1369        meta.producer = self.meta_string(dict, "Producer").await;
1370        meta.creation_date = self.meta_string(dict, "CreationDate").await;
1371        meta.mod_date = self.meta_string(dict, "ModDate").await;
1372        Ok(meta)
1373    }
1374
1375    /// Reads `key` from an info dictionary as a decoded text string.
1376    async fn meta_string(&self, dict: &Dict, key: &str) -> Option<String> {
1377        let value = self.resolve(dict.get(key)?).await.ok()?;
1378        Some(decode_text_string(value.as_str_bytes()?))
1379    }
1380
1381    /// The document's optional-content visibility under its default
1382    /// configuration, or `None` when the catalog declares no
1383    /// `/OCProperties` — the async twin of the sync document's `oc_state`.
1384    /// A rendering caller passes it on through
1385    /// `pdfboss_render::RenderOptions::oc`.
1386    pub async fn oc_state(&self) -> Option<pdfboss_core::OcState> {
1387        pdfboss_core::OcState::load_with(self, &self.inner.xref.trailer).await
1388    }
1389
1390    /// Number of pages: the flattened page tree's length. The tree is
1391    /// flattened once at open, so this is synchronous and authoritative —
1392    /// mirroring the sync document once its tree has been flattened.
1393    pub fn page_count(&self) -> usize {
1394        self.inner.pages.get().map_or(0, Vec::len)
1395    }
1396
1397    /// The page at 0-based `index`, as the same [`Page`] type the
1398    /// synchronous document hands out — built by [`Page::from_tree_attrs`],
1399    /// the one implementation of page defaulting, over attributes this
1400    /// document resolved while flattening the tree at open. Synchronous
1401    /// because everything it needs is already resolved.
1402    ///
1403    /// With a `Page` in hand, every shared algorithm runs over this
1404    /// document directly: `extract_text_with(doc.clone(), &page)`,
1405    /// `render_page_reporting_with(doc.clone(), &page, ..)`, and
1406    /// `page_content_with(&doc, &page)` — the document is an `Arc` handle,
1407    /// so the clone is two atomic increments.
1408    pub fn page(&self, index: usize) -> Result<Page> {
1409        let Some(record) = self.page_record(index) else {
1410            return Err(Error::Core(pdfboss_core::Error::PageNotFound(
1411                index,
1412                self.page_count(),
1413            )));
1414        };
1415        Ok(Page::from_tree_attrs(
1416            index,
1417            Some(record.resources),
1418            record.media_box,
1419            record.crop_box,
1420            record.bleed_box,
1421            record.trim_box,
1422            record.art_box,
1423            record.rotate,
1424            record.dict,
1425            record.r,
1426        ))
1427    }
1428
1429    /// The flattened record for the page at 0-based `index`. Consumed by
1430    /// the logical element layer (Plan 02 tasks 11-12).
1431    pub(crate) fn page_record(&self, index: usize) -> Option<PageRecord> {
1432        self.inner
1433            .pages
1434            .get()
1435            .and_then(|pages| pages.get(index))
1436            .cloned()
1437    }
1438
1439    /// Flattens the page tree by iterative depth-first traversal of
1440    /// `/Kids` with a visited-reference cycle guard and a depth cap,
1441    /// carrying inherited `/Resources`. Any structural problem simply
1442    /// truncates or skips (lenient) — this never fails. Only the catalog
1443    /// and tree nodes are fetched; page content is not.
1444    async fn flatten_pages(&self) -> Vec<PageRecord> {
1445        let mut chain = Vec::new();
1446        let mut pages = Vec::new();
1447        let Some(root) = self.inner.xref.trailer.get("Root") else {
1448            return pages;
1449        };
1450        let Ok(catalog) = self.resolve_with_chain(root, &mut chain).await else {
1451            return pages;
1452        };
1453        let Some(tree_root) = catalog.as_dict().and_then(|d| d.get("Pages")).cloned() else {
1454            return pages;
1455        };
1456        let mut visited: HashSet<ObjRef> = HashSet::new();
1457        let mut stack: Vec<(Object, InheritedAttrs, usize)> =
1458            vec![(tree_root, InheritedAttrs::default(), 0)];
1459        while let Some((node, mut inherited, depth)) = stack.pop() {
1460            if depth > MAX_TREE_DEPTH {
1461                continue;
1462            }
1463            let node_ref = node.as_ref();
1464            if let Some(r) = node_ref {
1465                if !visited.insert(r) {
1466                    continue; // cycle: this node was already traversed
1467                }
1468            }
1469            let Ok(resolved) = self.resolve_with_chain(&node, &mut chain).await else {
1470                continue;
1471            };
1472            let Some(dict) = resolved.as_dict() else {
1473                continue;
1474            };
1475            if let Some(value) = dict.get("Resources") {
1476                if let Ok(res) = self.resolve_with_chain(value, &mut chain).await {
1477                    if let Some(res) = res.as_dict() {
1478                        inherited.resources = res.clone();
1479                    }
1480                }
1481            }
1482            if let Some(mb) = self.rect_value(dict, "MediaBox", &mut chain).await {
1483                inherited.media_box = Some(mb);
1484            }
1485            if let Some(cb) = self.rect_value(dict, "CropBox", &mut chain).await {
1486                inherited.crop_box = Some(cb);
1487            }
1488            if let Some(rot) = self.int_value(dict, "Rotate", &mut chain).await {
1489                inherited.rotate = Some(rot);
1490            }
1491            let is_page = dict.get_name("Type").is_some_and(|n| n.0 == "Page");
1492            let kids = if is_page {
1493                None
1494            } else {
1495                self.array_value(dict, "Kids", &mut chain).await
1496            };
1497            match kids {
1498                Some(kids) => {
1499                    // Reverse push so pop order matches document order.
1500                    for kid in kids.iter().rev() {
1501                        stack.push((kid.clone(), inherited.clone(), depth + 1));
1502                    }
1503                }
1504                None => {
1505                    // BleedBox, TrimBox and ArtBox are not inheritable
1506                    // (ISO 32000 §7.7.3.3, Table 30): read them from the
1507                    // leaf dictionary only, mirroring the synchronous walk.
1508                    let bleed_box = self.rect_value(dict, "BleedBox", &mut chain).await;
1509                    let trim_box = self.rect_value(dict, "TrimBox", &mut chain).await;
1510                    let art_box = self.rect_value(dict, "ArtBox", &mut chain).await;
1511                    pages.push(PageRecord {
1512                        r: node_ref,
1513                        dict: dict.clone(),
1514                        resources: inherited.resources.clone(),
1515                        media_box: inherited.media_box,
1516                        crop_box: inherited.crop_box,
1517                        bleed_box,
1518                        trim_box,
1519                        art_box,
1520                        rotate: inherited.rotate,
1521                    });
1522                }
1523            }
1524        }
1525        pages
1526    }
1527
1528    /// Resolves `dict[key]` to a normalized rectangle: a four-number array
1529    /// whose elements may themselves be references, mirroring the synchronous
1530    /// walk's reading exactly.
1531    async fn rect_value(
1532        &self,
1533        dict: &Dict,
1534        key: &str,
1535        chain: &mut Vec<u32>,
1536    ) -> Option<pdfboss_core::Rect> {
1537        let items = self.array_value(dict, key, chain).await?;
1538        if items.len() != 4 {
1539            return None;
1540        }
1541        let mut coords = [0.0f32; 4];
1542        for (slot, item) in coords.iter_mut().zip(&items) {
1543            let n = self.resolve_with_chain(item, chain).await.ok()?.as_f64()?;
1544            if !n.is_finite() {
1545                return None;
1546            }
1547            *slot = n as f32;
1548        }
1549        Some(pdfboss_core::Rect::new(coords[0], coords[1], coords[2], coords[3]).normalize())
1550    }
1551
1552    /// Resolves `dict[key]` to an integer (reals truncate, lenient),
1553    /// mirroring the synchronous walk's reading exactly.
1554    async fn int_value(&self, dict: &Dict, key: &str, chain: &mut Vec<u32>) -> Option<i32> {
1555        let v = self
1556            .resolve_with_chain(dict.get(key)?, chain)
1557            .await
1558            .ok()?
1559            .as_f64()?;
1560        if v.is_finite() {
1561            Some(v as i32)
1562        } else {
1563            None
1564        }
1565    }
1566
1567    /// Resolves `dict[key]` to an array, if present and well-formed.
1568    async fn array_value(
1569        &self,
1570        dict: &Dict,
1571        key: &str,
1572        chain: &mut Vec<u32>,
1573    ) -> Option<Vec<Object>> {
1574        match self.resolve_with_chain(dict.get(key)?, chain).await.ok()? {
1575            Object::Array(items) => Some(items),
1576            _ => None,
1577        }
1578    }
1579
1580    /// Lazy element stream mirroring the sync iterator's ordering and
1581    /// salvage semantics. Physical elements come in file order (header,
1582    /// objects by offset, xref/trailer sections, startxref, eof); logical
1583    /// elements follow in document order (pages ascending, and within a
1584    /// page: fonts, images, annotations, then content ops if enabled).
1585    /// Nothing is fetched, parsed or decoded before it is yielded.
1586    ///
1587    /// The returned stream owns a cheap `Arc` clone of this document rather
1588    /// than borrowing it, so it is `'static` and outlives `self`.
1589    pub fn elements(&self, opts: ElementOpts) -> crate::stream::ElementStream {
1590        crate::stream::element_stream(self, opts)
1591    }
1592
1593    /// Fetches an in-file object together with its physical span, caching
1594    /// the object like [`AsyncDocument::get_object`].
1595    pub(crate) async fn physical_object(&self, r: ObjRef, offset: u64) -> Result<(Span, Object)> {
1596        let mut chain = vec![r.num];
1597        let (span, object) = self.parse_in_file(offset, &mut chain).await?;
1598        self.inner
1599            .objects
1600            .lock()
1601            .expect("object cache mutex")
1602            .insert((r.num, r.gen), Arc::new(object.clone()));
1603        Ok((span, object))
1604    }
1605
1606    /// Span of the `%PDF-` header run; `None` when the file has none (the
1607    /// Header element is then omitted, adopted rule 1).
1608    pub(crate) fn header_span(&self) -> Option<Span> {
1609        self.inner.header_span
1610    }
1611
1612    /// All merged xref entries (order unspecified).
1613    pub(crate) fn xref_entries(&self) -> Vec<(u32, XrefEntry)> {
1614        self.inner
1615            .xref
1616            .entries
1617            .iter()
1618            .map(|(&num, &entry)| (num, entry))
1619            .collect()
1620    }
1621
1622    /// Sections in chain order (newest→oldest).
1623    pub(crate) fn sections(&self) -> &[SectionRecord] {
1624        &self.inner.sections
1625    }
1626
1627    /// The merged trailer dictionary and the span of the newest section's
1628    /// trailer region, for the single Trailer element (adopted rule 4).
1629    pub(crate) fn merged_trailer(&self) -> (Dict, Span) {
1630        (
1631            self.inner.xref.trailer.clone(),
1632            self.inner.xref.trailer_span,
1633        )
1634    }
1635
1636    /// The final `startxref` announcement: `(offset, span)`.
1637    pub(crate) fn startxref_record(&self) -> (u64, Span) {
1638        (self.inner.startxref.offset, self.inner.startxref.span)
1639    }
1640
1641    /// Span of the final `%%EOF`, when one exists.
1642    pub(crate) fn eof_span(&self) -> Option<Span> {
1643        self.inner.eof_span
1644    }
1645}
1646
1647/// Initial window for an xref section, doubling until the section parses
1648/// completely.
1649const SECTION_WINDOW: usize = 4096;
1650
1651/// Fetches and parses the section at `offset` through a growing window.
1652async fn parse_section_at(fetcher: &Fetcher, offset: u64) -> Result<ParsedSection> {
1653    let mut window = SECTION_WINDOW;
1654    loop {
1655        let buf = fetcher.window(offset, window).await?;
1656        let at_eof = offset + buf.len() as u64 >= fetcher.len;
1657        if let Some(parsed) = parse_section_window(&buf, offset, fetcher.len, at_eof)? {
1658            return Ok(parsed);
1659        }
1660        // None: the window ended inside the section — double and refetch,
1661        // unless that has already grown past the shared cap (see
1662        // `MAX_GROWTH_WINDOW`) without completing or reaching EOF.
1663        if window as u64 >= MAX_GROWTH_WINDOW {
1664            return Err(Error::Core(pdfboss_core::Error::Other(format!(
1665                "invalid or unrecoverable cross-reference data: section at offset {offset} \
1666                 exceeded the {MAX_GROWTH_WINDOW}-byte parse window without completing"
1667            ))));
1668        }
1669        window = window.saturating_mul(2);
1670    }
1671}
1672
1673/// Walks the section chain newest→oldest starting at `start`, merging every
1674/// section into one index (first-seen entries and trailer keys win). A
1675/// classic trailer's `/XRefStm` section (hybrid file, ISO 32000 §7.5.8.4)
1676/// merges ahead of its table — the table marks the stream's objects free to
1677/// hide them from readers without stream support — and both merge before
1678/// `/Prev` is followed. Visited offsets guard against loops. Merge order and
1679/// emission order are independent: entries still merge hybrid-before-table
1680/// (so the hybrid's objects win over the table's masking free entries), but
1681/// sections are *emitted* classic-table-before-its-hybrid-stream, matching
1682/// pdfboss-core's element iterator — the parity arbiter — which yields
1683/// `[Table, Stream]` for a hybrid file (see
1684/// `pdfboss_core::elements::tests::hybrid_xrefstm_yields_both_sections`).
1685/// Beyond a hybrid pair, sections come back in chain order — newest→oldest
1686/// — for the element stream. The merged trailer's span is the startxref
1687/// section's trailer region.
1688pub(crate) async fn load_xref_chain(
1689    fetcher: &Fetcher,
1690    start: u64,
1691) -> Result<(XrefIndex, Vec<SectionRecord>)> {
1692    let mut entries: HashMap<u32, XrefEntry> = HashMap::new();
1693    let mut trailer = Dict::new();
1694    let mut trailer_span: Option<Span> = None;
1695    let mut sections: Vec<SectionRecord> = Vec::new();
1696    let mut visited: HashSet<u64> = HashSet::new();
1697    let mut next = Some(start);
1698    while let Some(offset) = next {
1699        if !visited.insert(offset) {
1700            break;
1701        }
1702        let parsed = parse_section_at(fetcher, offset).await?;
1703        if trailer_span.is_none() {
1704            trailer_span = Some(parsed.record.trailer_span);
1705        }
1706        // Merge (not emit) the hybrid ahead of its table: first-seen-wins
1707        // means the hybrid's objects must beat the table's masking free
1708        // entries. Its record is held back and pushed after the table's
1709        // below, so emission order stays classic-then-hybrid.
1710        let mut hybrid_record = None;
1711        if let Some(hybrid_offset) = parsed.xrefstm.filter(|&v| v < fetcher.len) {
1712            if visited.insert(hybrid_offset) {
1713                // Lenient: a broken hybrid stream leaves the table alone.
1714                if let Ok(hybrid) = parse_section_at(fetcher, hybrid_offset).await {
1715                    merge_section(&mut entries, &mut trailer, &hybrid);
1716                    hybrid_record = Some(hybrid.record);
1717                }
1718            }
1719        }
1720        next = parsed.prev.filter(|&v| v < fetcher.len);
1721        merge_section(&mut entries, &mut trailer, &parsed);
1722        sections.push(parsed.record);
1723        if let Some(record) = hybrid_record {
1724            sections.push(record);
1725        }
1726    }
1727    if entries.is_empty() {
1728        return Err(Error::Core(pdfboss_core::Error::InvalidXref));
1729    }
1730    // Non-empty entries imply at least one parsed section, which set the
1731    // span before any merge could run.
1732    let trailer_span = trailer_span.expect("set on the first parsed section");
1733    Ok((
1734        XrefIndex {
1735            entries,
1736            trailer,
1737            trailer_span,
1738        },
1739        sections,
1740    ))
1741}
1742
1743/// Merges a section into the accumulated index: entries and trailer keys
1744/// already present win (sections are walked newest to oldest).
1745fn merge_section(
1746    entries: &mut HashMap<u32, XrefEntry>,
1747    trailer: &mut Dict,
1748    parsed: &ParsedSection,
1749) {
1750    for (num, entry) in &parsed.entries {
1751        entries.entry(*num).or_insert(*entry);
1752    }
1753    for (key, value) in parsed.record.trailer_dict.iter() {
1754        if trailer.get(&key.0).is_none() {
1755            trailer.insert(key.clone(), value.clone());
1756        }
1757    }
1758}
1759
1760/// Compile-time guarantee that documents can be shared across tasks.
1761#[allow(dead_code)]
1762fn assert_document_is_shareable()
1763where
1764    AsyncDocument: Send + Sync + Clone,
1765{
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770    use super::*;
1771    use crate::backend::MemBackend;
1772    use pdfboss_testkit::{multi_page_doc, simple_doc};
1773
1774    fn fetcher_for(data: Vec<u8>) -> Fetcher {
1775        let len = data.len() as u64;
1776        Fetcher {
1777            backend: std::sync::Arc::new(MemBackend::from(data)),
1778            len,
1779        }
1780    }
1781
1782    /// Offset of the first occurrence of `needle` in `data`.
1783    fn pos_of(data: &[u8], needle: &[u8]) -> usize {
1784        data.windows(needle.len())
1785            .position(|w| w == needle)
1786            .expect("needle present")
1787    }
1788
1789    #[tokio::test]
1790    async fn read_range_returns_exact_bytes_and_detects_truncation() {
1791        let fetcher = fetcher_for(b"0123456789".to_vec());
1792        assert_eq!(fetcher.read_range(2, 6).await.unwrap(), b"2345");
1793        assert_eq!(fetcher.window(8, 100).await.unwrap(), b"89");
1794        assert!(fetcher.window(10, 100).await.unwrap().is_empty());
1795        // A fetcher whose declared length exceeds the real data hits EOF
1796        // mid-range: TruncatedRead with the range it was fetching.
1797        let lying = Fetcher {
1798            backend: std::sync::Arc::new(MemBackend::from(b"0123456789".to_vec())),
1799            len: 20,
1800        };
1801        match lying.read_range(5, 15).await {
1802            Err(crate::Error::TruncatedRead {
1803                offset,
1804                wanted,
1805                got,
1806            }) => {
1807                assert_eq!(offset, 5);
1808                assert_eq!(wanted, 10);
1809                assert_eq!(got, 5);
1810            }
1811            other => panic!("expected TruncatedRead, got {other:?}"),
1812        }
1813    }
1814
1815    #[tokio::test]
1816    async fn tail_scan_finds_startxref_and_eof() {
1817        let data = simple_doc("tail scan");
1818        let xref_pos = pos_of(&data, b"xref\n0 ") as u64;
1819        let startxref_pos = pos_of(&data, b"startxref") as u64;
1820        let eof_pos = pos_of(&data, b"%%EOF") as u64;
1821        let fetcher = fetcher_for(data);
1822        let (record, eof) = find_tail(&fetcher).await.unwrap();
1823        assert_eq!(record.offset, xref_pos);
1824        assert_eq!(record.span.start, startxref_pos);
1825        assert!(record.span.end > startxref_pos + b"startxref".len() as u64);
1826        assert_eq!(
1827            eof,
1828            Some(Span {
1829                start: eof_pos,
1830                end: eof_pos + 5
1831            })
1832        );
1833    }
1834
1835    #[tokio::test]
1836    async fn tail_scan_grows_past_trailing_padding() {
1837        let mut data = simple_doc("padded");
1838        let xref_pos = pos_of(&data, b"xref\n0 ") as u64;
1839        data.extend_from_slice(&vec![b' '; 8192]);
1840        let fetcher = fetcher_for(data);
1841        let (record, eof) = find_tail(&fetcher).await.unwrap();
1842        assert_eq!(record.offset, xref_pos);
1843        assert!(eof.is_some());
1844    }
1845
1846    #[tokio::test]
1847    async fn tail_scan_without_startxref_is_invalid_xref() {
1848        let fetcher = fetcher_for(b"not a pdf at all".to_vec());
1849        assert!(matches!(
1850            find_tail(&fetcher).await,
1851            Err(crate::Error::Core(pdfboss_core::Error::InvalidXref))
1852        ));
1853    }
1854
1855    #[test]
1856    fn version_parse_matches_header_and_defaults() {
1857        assert_eq!(parse_version(b"%PDF-1.7\nrest"), (1, 7));
1858        assert_eq!(parse_version(b"junk\n%PDF-2.0\n"), (2, 0));
1859        assert_eq!(parse_version(b"%QQQ-1.7"), (1, 4));
1860        assert_eq!(parse_version(b""), (1, 4));
1861        assert_eq!(parse_version(b"%PDF-1."), (1, 4));
1862    }
1863
1864    #[test]
1865    fn header_span_covers_the_version_run() {
1866        assert_eq!(
1867            header_span_in(b"%PDF-1.7\nrest"),
1868            Some(Span { start: 0, end: 8 })
1869        );
1870        assert_eq!(
1871            header_span_in(b"junk\n%PDF-2.0\n"),
1872            Some(Span { start: 5, end: 13 })
1873        );
1874        assert_eq!(header_span_in(b"%QQQ-1.7"), None);
1875        assert_eq!(header_span_in(b""), None);
1876    }
1877
1878    use pdfboss_core::xref::XrefEntry;
1879
1880    /// Extracts the section bytes starting at the classic `xref` keyword
1881    /// (through end of file) plus the section's absolute offset.
1882    fn classic_section(data: &[u8]) -> (Vec<u8>, u64) {
1883        let off = pos_of(data, b"xref\n0 ");
1884        (data[off..].to_vec(), off as u64)
1885    }
1886
1887    #[test]
1888    fn classic_section_window_parses_entries_and_trailer() {
1889        let data = pdfboss_testkit::simple_doc("sections");
1890        let (buf, base) = classic_section(&data);
1891        let file_len = data.len() as u64;
1892        let parsed = parse_section_window(&buf, base, file_len, true)
1893            .unwrap()
1894            .expect("complete section parses");
1895        assert_eq!(parsed.record.kind, pdfboss_core::elements::XrefKind::Table);
1896        assert_eq!(parsed.record.entries, 6); // objects 0..=5
1897        assert_eq!(parsed.entries.len(), 6);
1898        assert!(matches!(
1899            parsed.entries.iter().find(|(num, _)| *num == 0),
1900            Some((0, XrefEntry::Free))
1901        ));
1902        let obj1_off = pos_of(&data, b"1 0 obj") as u64;
1903        assert!(parsed.entries.iter().any(|(num, entry)| *num == 1
1904            && matches!(entry, XrefEntry::InFile { offset, gen: 0 } if *offset == obj1_off)));
1905        assert_eq!(
1906            parsed.record.trailer_dict.get_ref("Root").map(|r| r.num),
1907            Some(1)
1908        );
1909        assert_eq!(parsed.prev, None);
1910        assert_eq!(parsed.xrefstm, None);
1911        // Spans: section runs from the xref keyword to the trailer keyword;
1912        // the trailer span covers `trailer << … >>`.
1913        assert_eq!(parsed.record.span.start, base);
1914        let trailer_off = pos_of(&data, b"trailer") as u64;
1915        assert_eq!(parsed.record.span.end, trailer_off);
1916        assert_eq!(parsed.record.trailer_span.start, trailer_off);
1917        let dict_end = pos_of(&data, b"startxref") as u64;
1918        assert!(parsed.record.trailer_span.end > trailer_off);
1919        assert!(parsed.record.trailer_span.end <= dict_end);
1920    }
1921
1922    #[test]
1923    fn truncated_classic_section_asks_for_more_bytes() {
1924        let data = pdfboss_testkit::simple_doc("cut short");
1925        let (buf, base) = classic_section(&data);
1926        let file_len = data.len() as u64;
1927        // Cut mid-table: with more file remaining the parser must ask for a
1928        // wider window instead of failing or silently succeeding.
1929        let cut = &buf[..40];
1930        assert!(parse_section_window(cut, base, file_len, false)
1931            .unwrap()
1932            .is_none());
1933        // The same truncated bytes at real end of file are a hard error.
1934        assert!(parse_section_window(cut, base, base + 40, true).is_err());
1935    }
1936
1937    #[test]
1938    fn xref_stream_section_window_parses_entries() {
1939        let (dict, payload) = pdfboss_testkit::objstm_payload(&[
1940            (1, "<< /Type /Catalog /Pages 2 0 R >>"),
1941            (2, "<< /Type /Pages /Kids [] /Count 0 >>"),
1942        ]);
1943        let mut b = pdfboss_testkit::PdfBuilder::new();
1944        b.stream(6, &dict, &payload);
1945        let data = b.build_xref_stream(1);
1946        let off = pos_of(&data, b"7 0 obj") as u64; // the xref stream object
1947        let buf = data[off as usize..].to_vec();
1948        let parsed = parse_section_window(&buf, off, data.len() as u64, true)
1949            .unwrap()
1950            .expect("complete stream section parses");
1951        assert_eq!(parsed.record.kind, pdfboss_core::elements::XrefKind::Stream);
1952        assert_eq!(parsed.record.span.start, off);
1953        assert_eq!(parsed.record.trailer_span, parsed.record.span);
1954        assert!(parsed.entries.iter().any(|(num, entry)| *num == 1
1955            && matches!(
1956                entry,
1957                XrefEntry::InStream {
1958                    stream_num: 6,
1959                    index: 0
1960                }
1961            )));
1962        assert!(parsed
1963            .entries
1964            .iter()
1965            .any(|(num, entry)| *num == 6 && matches!(entry, XrefEntry::InFile { .. })));
1966        assert_eq!(
1967            parsed
1968                .record
1969                .trailer_dict
1970                .get_name("Type")
1971                .map(|n| n.0.as_str()),
1972            Some("XRef")
1973        );
1974    }
1975
1976    #[test]
1977    fn implausible_subsection_count_is_a_hard_error() {
1978        // A count no file of this length could hold must fail immediately,
1979        // not grow the window forever.
1980        let buf = b"xref\n0 999999999\n".to_vec();
1981        assert!(parse_section_window(&buf, 0, 4096, false).is_err());
1982    }
1983
1984    #[test]
1985    fn truncated_stream_section_asks_for_more_bytes() {
1986        // Reviewer's minimal reproducer: a window cut mid-dictionary, before
1987        // the `stream` keyword is even reached, leniently parses as a plain
1988        // `Object::Dict` (core's dict parser breaks on `Eof`) — that must
1989        // ask for a wider window, not hard-error.
1990        let mid_dict = b"7 0 obj\n<< /Type /XRef /Length 10 ".to_vec();
1991        assert!(parse_section_window(&mid_dict, 0, 10_000, false)
1992            .unwrap()
1993            .is_none());
1994        // The same truncated bytes at real end of file are a hard error.
1995        assert!(parse_section_window(&mid_dict, 0, mid_dict.len() as u64, true).is_err());
1996
1997        // A real stream section cut partway into its (still-encoded) stream
1998        // data must also ask for a wider window.
1999        let (dict, payload) = pdfboss_testkit::objstm_payload(&[
2000            (1, "<< /Type /Catalog /Pages 2 0 R >>"),
2001            (2, "<< /Type /Pages /Kids [] /Count 0 >>"),
2002        ]);
2003        let mut b = pdfboss_testkit::PdfBuilder::new();
2004        b.stream(6, &dict, &payload);
2005        let data = b.build_xref_stream(1);
2006        let off = pos_of(&data, b"7 0 obj");
2007        let buf = data[off..].to_vec();
2008        let base = off as u64;
2009        let file_len = data.len() as u64;
2010        let stream_kw = find_bytes(&buf, b"stream\n").expect("stream keyword present");
2011        // Cut a few bytes into the stream data: past the keyword, well
2012        // short of `endstream`.
2013        let cut = &buf[..stream_kw + b"stream\n".len() + 4];
2014        assert!(parse_section_window(cut, base, file_len, false)
2015            .unwrap()
2016            .is_none());
2017    }
2018
2019    use pdfboss_core::xref::load_xref;
2020
2021    /// Asserts the async xref agrees with the sync loader entry-for-entry
2022    /// for object numbers 0..size.
2023    async fn assert_xref_parity(data: Vec<u8>) {
2024        let sync_xref = load_xref(&data).unwrap();
2025        let size = sync_xref.trailer.get_int("Size").unwrap_or(64).max(1) as u32;
2026        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2027        for num in 0..size + 2 {
2028            assert_eq!(
2029                doc.inner.xref.entries.get(&num).copied(),
2030                sync_xref.get(num),
2031                "entry for object {num}"
2032            );
2033        }
2034        assert_eq!(
2035            doc.inner.xref.trailer.get_ref("Root"),
2036            sync_xref.trailer.get_ref("Root")
2037        );
2038        assert_eq!(
2039            doc.inner.xref.trailer.get_int("Size"),
2040            sync_xref.trailer.get_int("Size")
2041        );
2042    }
2043
2044    #[tokio::test]
2045    async fn classic_document_matches_sync_xref() {
2046        assert_xref_parity(simple_doc("chain walk")).await;
2047        let doc = AsyncDocument::from_bytes(simple_doc("chain walk"))
2048            .await
2049            .unwrap();
2050        assert_eq!(doc.version(), (1, 7));
2051        assert_eq!(doc.inner.sections.len(), 1);
2052        let clone = doc.clone();
2053        assert_eq!(clone.version(), (1, 7));
2054    }
2055
2056    #[tokio::test]
2057    async fn xref_stream_document_matches_sync_xref() {
2058        let (dict, payload) = pdfboss_testkit::objstm_payload(&[
2059            (1, "<< /Type /Catalog /Pages 2 0 R >>"),
2060            (2, "<< /Type /Pages /Kids [] /Count 0 >>"),
2061        ]);
2062        let mut b = pdfboss_testkit::PdfBuilder::new();
2063        b.stream(6, &dict, &payload);
2064        assert_xref_parity(b.build_xref_stream(1)).await;
2065    }
2066
2067    /// An incremental update: a classic base section, then an xref stream
2068    /// whose /Prev points back at it.
2069    fn prev_chain_doc() -> Vec<u8> {
2070        let mut data = b"%PDF-1.5\n".to_vec();
2071        let obj1 = data.len();
2072        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
2073        let obj2_old = data.len();
2074        data.extend_from_slice(b"2 0 obj\n(old)\nendobj\n");
2075        let classic_off = data.len();
2076        data.extend_from_slice(b"xref\n0 3\n0000000000 65535 f\r\n");
2077        data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
2078        data.extend_from_slice(format!("{obj2_old:010} 00000 n\r\n").as_bytes());
2079        data.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
2080        let obj2_new = data.len();
2081        data.extend_from_slice(b"2 0 obj\n(new)\nendobj\n");
2082        let stream_off = data.len();
2083        let mut fields = Vec::new();
2084        for offset in [obj2_new, stream_off] {
2085            fields.push(1u8);
2086            fields.extend_from_slice(&(offset as u32).to_be_bytes());
2087            fields.extend_from_slice(&0u16.to_be_bytes());
2088        }
2089        data.extend_from_slice(
2090            format!(
2091                "4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Index [2 1 4 1] \
2092                 /Prev {} /Root 1 0 R /Length {} >>\nstream\n",
2093                classic_off,
2094                fields.len()
2095            )
2096            .as_bytes(),
2097        );
2098        data.extend_from_slice(&fields);
2099        data.extend_from_slice(b"\nendstream\nendobj\n");
2100        data.extend_from_slice(format!("startxref\n{stream_off}\n%%EOF\n").as_bytes());
2101        data
2102    }
2103
2104    #[tokio::test]
2105    async fn prev_chain_merges_newest_wins() {
2106        let data = prev_chain_doc();
2107        let obj2_new = pos_of(&data, b"2 0 obj\n(new)") as u64;
2108        assert_xref_parity(data.clone()).await;
2109        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2110        assert!(matches!(
2111            doc.inner.xref.entries.get(&2),
2112            Some(XrefEntry::InFile { offset, .. }) if *offset == obj2_new
2113        ));
2114        // Two sections, in chain order: the startxref section (the xref
2115        // stream) first, then the /Prev classic table (adopted rule 4).
2116        assert_eq!(doc.inner.sections.len(), 2);
2117        assert!(doc.inner.sections[0].span.start > doc.inner.sections[1].span.start);
2118        assert_eq!(
2119            doc.inner.sections[0].kind,
2120            pdfboss_core::elements::XrefKind::Stream
2121        );
2122        assert_eq!(
2123            doc.inner.sections[1].kind,
2124            pdfboss_core::elements::XrefKind::Table
2125        );
2126        // The merged trailer's span is the newest (stream) section's own
2127        // span — stream sections have no separate trailer region.
2128        assert_eq!(doc.inner.xref.trailer_span, doc.inner.sections[0].span);
2129    }
2130
2131    /// A hybrid file: the classic table hides object 2 behind a free entry
2132    /// while /XRefStm reveals it.
2133    fn hybrid_doc() -> Vec<u8> {
2134        let mut data = b"%PDF-1.5\n".to_vec();
2135        let obj1 = data.len();
2136        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
2137        let obj2 = data.len();
2138        data.extend_from_slice(b"2 0 obj\n(hidden)\nendobj\n");
2139        let stm_off = data.len();
2140        let mut fields = Vec::new();
2141        for offset in [obj2, stm_off] {
2142            fields.push(1u8);
2143            fields.extend_from_slice(&(offset as u32).to_be_bytes());
2144            fields.extend_from_slice(&0u16.to_be_bytes());
2145        }
2146        data.extend_from_slice(
2147            format!(
2148                "3 0 obj\n<< /Type /XRef /Size 4 /W [1 4 2] /Index [2 1 3 1] \
2149                 /Root 1 0 R /Length {} >>\nstream\n",
2150                fields.len()
2151            )
2152            .as_bytes(),
2153        );
2154        data.extend_from_slice(&fields);
2155        data.extend_from_slice(b"\nendstream\nendobj\n");
2156        let classic_off = data.len();
2157        data.extend_from_slice(b"xref\n0 3\n0000000000 65535 f\r\n");
2158        data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
2159        data.extend_from_slice(b"0000000000 00001 f\r\n");
2160        data.extend_from_slice(
2161            format!("trailer\n<< /Size 4 /Root 1 0 R /XRefStm {stm_off} >>\n").as_bytes(),
2162        );
2163        data.extend_from_slice(format!("startxref\n{classic_off}\n%%EOF\n").as_bytes());
2164        data
2165    }
2166
2167    #[tokio::test]
2168    async fn hybrid_xrefstm_beats_the_tables_free_entry() {
2169        let data = hybrid_doc();
2170        let obj2 = pos_of(&data, b"2 0 obj\n(hidden)") as u64;
2171        assert_xref_parity(data.clone()).await;
2172        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2173        assert!(matches!(
2174            doc.inner.xref.entries.get(&2),
2175            Some(XrefEntry::InFile { offset, .. }) if *offset == obj2
2176        ));
2177        // Emission order must match pdfboss-core's element iterator (the
2178        // parity arbiter): the classic section first, then its hybrid
2179        // /XRefStm section — even though the hybrid's entries merge ahead
2180        // of the classic table's masking free entries (asserted above).
2181        let kinds: Vec<pdfboss_core::elements::XrefKind> =
2182            doc.inner.sections.iter().map(|s| s.kind).collect();
2183        assert_eq!(
2184            kinds,
2185            [
2186                pdfboss_core::elements::XrefKind::Table,
2187                pdfboss_core::elements::XrefKind::Stream
2188            ],
2189            "classic section first, then its hybrid /XRefStm section"
2190        );
2191    }
2192
2193    #[tokio::test]
2194    async fn open_reads_from_disk() {
2195        let path =
2196            std::env::temp_dir().join(format!("pdfboss-aio-doc-test-{}.pdf", std::process::id()));
2197        std::fs::write(&path, simple_doc("from disk")).unwrap();
2198        let doc = AsyncDocument::open(&path).await.unwrap();
2199        std::fs::remove_file(&path).ok();
2200        assert_eq!(doc.version(), (1, 7));
2201    }
2202
2203    use pdfboss_core::{ObjRef, Object};
2204
2205    #[tokio::test]
2206    async fn objects_match_the_sync_document() {
2207        for data in [simple_doc("objects"), multi_page_doc(&["a", "b"])] {
2208            let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
2209            let doc = AsyncDocument::from_bytes(data).await.unwrap();
2210            for num in 1..=8u32 {
2211                let r = ObjRef { num, gen: 0 };
2212                match sync_doc.get(r) {
2213                    Ok(expected) => {
2214                        assert_eq!(doc.get_object(r).await.unwrap(), expected, "object {num}")
2215                    }
2216                    Err(_) => assert!(doc.get_object(r).await.is_err(), "object {num}"),
2217                }
2218            }
2219        }
2220    }
2221
2222    #[tokio::test]
2223    async fn compressed_objects_are_fetched_from_their_container() {
2224        let (dict, payload) = pdfboss_testkit::objstm_payload(&[
2225            (1, "<< /Type /Catalog /Pages 2 0 R >>"),
2226            (5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"),
2227        ]);
2228        let mut b = pdfboss_testkit::PdfBuilder::new();
2229        b.stream(6, &dict, &payload);
2230        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
2231        b.object(
2232            3,
2233            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>",
2234        );
2235        b.stream(4, "", b"BT (compressed) Tj ET");
2236        let data = b.build_xref_stream(1);
2237        let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
2238        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2239        let font = doc.get_object(ObjRef { num: 5, gen: 0 }).await.unwrap();
2240        assert_eq!(font, sync_doc.get(ObjRef { num: 5, gen: 0 }).unwrap());
2241        assert_eq!(
2242            font.as_dict()
2243                .and_then(|d| d.get_name("BaseFont"))
2244                .map(|n| n.0.as_str()),
2245            Some("Helvetica")
2246        );
2247        let catalog = doc.get_object(ObjRef { num: 1, gen: 0 }).await.unwrap();
2248        assert_eq!(catalog, sync_doc.get(ObjRef { num: 1, gen: 0 }).unwrap());
2249    }
2250
2251    #[tokio::test]
2252    async fn indirect_stream_length_triggers_one_extra_fetch() {
2253        let mut b = pdfboss_testkit::PdfBuilder::new();
2254        b.object(1, "<< /Type /Catalog >>");
2255        b.object(4, "<< /Length 7 0 R >>\nstream\nBT ET\nendstream");
2256        b.object(7, "5");
2257        let data = b.build(1);
2258        let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
2259        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2260        let stream = doc.get_object(ObjRef { num: 4, gen: 0 }).await.unwrap();
2261        assert_eq!(stream, sync_doc.get(ObjRef { num: 4, gen: 0 }).unwrap());
2262        assert_eq!(stream.as_stream().unwrap().data, b"BT ET");
2263    }
2264
2265    #[tokio::test]
2266    async fn objects_larger_than_the_initial_window_grow_until_complete() {
2267        let mut b = pdfboss_testkit::PdfBuilder::new();
2268        b.object(1, "<< /Type /Catalog >>");
2269        let big = vec![b'q'; 5000];
2270        b.stream(4, "", &big);
2271        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
2272        let object = doc.get_object(ObjRef { num: 4, gen: 0 }).await.unwrap();
2273        assert_eq!(object.as_stream().unwrap().data, big);
2274    }
2275
2276    /// A `Backend` wrapper that records the cumulative bytes yielded by
2277    /// `read_at`, so a test can assert a fetch loop stayed bounded instead
2278    /// of growing to (or past) the size of a very large file.
2279    struct RecordingBackend {
2280        inner: MemBackend,
2281        bytes_read: Arc<std::sync::atomic::AtomicU64>,
2282    }
2283
2284    impl Backend for RecordingBackend {
2285        fn len(&self) -> BoxFuture<'_, std::io::Result<u64>> {
2286            self.inner.len()
2287        }
2288
2289        fn read_at<'a>(
2290            &'a self,
2291            offset: u64,
2292            buf: &'a mut [u8],
2293        ) -> BoxFuture<'a, std::io::Result<usize>> {
2294            Box::pin(async move {
2295                let n = self.inner.read_at(offset, buf).await?;
2296                self.bytes_read
2297                    .fetch_add(n as u64, std::sync::atomic::Ordering::SeqCst);
2298                Ok(n)
2299            })
2300        }
2301    }
2302
2303    /// Simulates a corrupt xref entry -- an offset that does not point at
2304    /// any real object -- inside a file comfortably larger than
2305    /// `MAX_GROWTH_WINDOW`. `PdfBuilder::build` always places the xref
2306    /// table and trailer immediately after the last object (so `find_tail`
2307    /// can find `startxref` near the true end of file), so the huge
2308    /// garbage span is assembled by hand here, positioned *before* a
2309    /// normal, valid trailer rather than through the builder.
2310    ///
2311    /// Without the cap, `parse_in_file`'s doubling window would grow all
2312    /// the way to EOF chasing this offset, reading (and, since each
2313    /// doubling re-fetches from `offset`, re-reading) most of a 300+ MiB
2314    /// file for one bogus object -- exactly the amplification
2315    /// `MAX_GROWTH_WINDOW` exists to bound.
2316    #[tokio::test]
2317    async fn corrupt_offset_in_a_huge_file_errors_within_the_growth_cap_instead_of_reading_to_eof()
2318    {
2319        let header = b"%PDF-1.7\n".to_vec();
2320        let obj1 = b"1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec();
2321        let obj1_offset = header.len() as u64;
2322
2323        let mut data = header;
2324        data.extend_from_slice(&obj1);
2325
2326        // `]` can never open a valid `N G obj` header, so the parse fails
2327        // immediately at every window size -- the loop keeps regrowing the
2328        // window uselessly, exactly like a truly corrupt offset would,
2329        // rather than eventually completing on a bigger read.
2330        let garbage_offset = data.len() as u64;
2331        let garbage_len = MAX_GROWTH_WINDOW as usize + 32 * 1024 * 1024;
2332        data.resize(data.len() + garbage_len, b']');
2333
2334        let xref_offset = data.len();
2335        data.extend_from_slice(b"xref\n0 2\n");
2336        data.extend_from_slice(b"0000000000 65535 f\r\n");
2337        data.extend_from_slice(format!("{obj1_offset:010} {:05} n\r\n", 0).as_bytes());
2338        data.extend_from_slice(b"trailer\n<< /Size 2 /Root 1 0 R >>\n");
2339        data.extend_from_slice(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
2340
2341        let bytes_read = Arc::new(std::sync::atomic::AtomicU64::new(0));
2342        let backend = RecordingBackend {
2343            inner: MemBackend::from(data),
2344            bytes_read: Arc::clone(&bytes_read),
2345        };
2346        let doc = AsyncDocument::with_backend(backend)
2347            .await
2348            .expect("a valid one-object catalog with no /Pages still opens");
2349
2350        let mut chain = Vec::new();
2351        let err = doc
2352            .parse_in_file(garbage_offset, &mut chain)
2353            .await
2354            .expect_err("a run of `]` bytes must never parse as an object");
2355        assert!(
2356            err.to_string().contains(&garbage_offset.to_string()),
2357            "error does not name the offending offset: {err}"
2358        );
2359
2360        // A doubling loop capped at `MAX_GROWTH_WINDOW` reads at most
2361        // roughly 2x the cap across all its attempts (each attempt
2362        // re-fetches from `offset` rather than accumulating). Anywhere
2363        // near the file's true size (well over 300 MiB) would mean the
2364        // cap did not apply and the loop ran all the way to EOF instead.
2365        let total = bytes_read.load(std::sync::atomic::Ordering::SeqCst);
2366        assert!(
2367            total < 3 * MAX_GROWTH_WINDOW,
2368            "read {total} bytes chasing one bogus offset -- the growth cap did not bound it"
2369        );
2370    }
2371
2372    #[tokio::test]
2373    async fn resolve_mirrors_sync_lenient_semantics() {
2374        let mut b = pdfboss_testkit::PdfBuilder::new();
2375        b.object(1, "<< /Type /Catalog >>");
2376        b.object(6, "6 0 R");
2377        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
2378        let missing = Object::Ref(ObjRef { num: 99, gen: 0 });
2379        assert_eq!(doc.resolve(&missing).await.unwrap(), Object::Null);
2380        let loops = Object::Ref(ObjRef { num: 6, gen: 0 });
2381        assert!(matches!(
2382            doc.resolve(&loops).await,
2383            Err(Error::Core(pdfboss_core::Error::CircularReference(6)))
2384        ));
2385        // Generation mismatch is tolerated (lenient), like the sync model.
2386        let catalog = doc.get_object(ObjRef { num: 1, gen: 7 }).await.unwrap();
2387        assert!(catalog.as_dict().is_some());
2388    }
2389
2390    #[tokio::test]
2391    async fn decode_stream_matches_sync_stream_data() {
2392        let data = simple_doc("stream parity");
2393        let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
2394        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2395        let object = doc.get_object(ObjRef { num: 4, gen: 0 }).await.unwrap();
2396        let stream = object.as_stream().unwrap();
2397        assert_eq!(
2398            doc.decode_stream(stream).await.unwrap(),
2399            sync_doc.stream_data(stream).unwrap()
2400        );
2401    }
2402
2403    #[tokio::test]
2404    async fn read_span_returns_raw_file_bytes() {
2405        let data = simple_doc("raw bytes");
2406        let doc = AsyncDocument::from_bytes(data.clone()).await.unwrap();
2407        let slice = doc.read_span(Span { start: 0, end: 8 }).await.unwrap();
2408        assert_eq!(slice, b"%PDF-1.7");
2409        // Spans are clamped to the file length, which is also public.
2410        let file_len = data.len() as u64;
2411        assert_eq!(doc.file_len(), file_len);
2412        let tail = doc
2413            .read_span(Span {
2414                start: file_len - 6,
2415                end: file_len + 50,
2416            })
2417            .await
2418            .unwrap();
2419        assert_eq!(tail, b"%%EOF\n");
2420        assert!(doc
2421            .read_span(Span {
2422                start: file_len + 1,
2423                end: file_len + 2
2424            })
2425            .await
2426            .unwrap()
2427            .is_empty());
2428    }
2429
2430    #[tokio::test]
2431    async fn metadata_matches_the_sync_document() {
2432        let mut b = pdfboss_testkit::PdfBuilder::new().trailer_extra("/Info 6 0 R");
2433        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
2434        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
2435        // /Title is UTF-16BE with BOM; /Author is a plain string.
2436        b.object(6, "<< /Title <FEFF00480151> /Author (plain author) >>");
2437        let data = b.build(1);
2438        let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
2439        let doc = AsyncDocument::from_bytes(data).await.unwrap();
2440        let meta = doc.metadata().await.unwrap();
2441        assert_eq!(meta, sync_doc.metadata());
2442        assert_eq!(meta.title.as_deref(), Some("H\u{151}"));
2443        assert_eq!(meta.author.as_deref(), Some("plain author"));
2444        assert_eq!(meta.subject, None);
2445    }
2446
2447    #[tokio::test]
2448    async fn metadata_without_info_is_all_none() {
2449        let doc = AsyncDocument::from_bytes(simple_doc("x")).await.unwrap();
2450        assert_eq!(
2451            doc.metadata().await.unwrap(),
2452            pdfboss_core::Metadata::default()
2453        );
2454    }
2455
2456    #[tokio::test]
2457    async fn page_count_matches_the_sync_document() {
2458        for (data, expected) in [
2459            (simple_doc("one"), 1usize),
2460            (multi_page_doc(&["a", "b", "c"]), 3usize),
2461        ] {
2462            let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
2463            let doc = AsyncDocument::from_bytes(data).await.unwrap();
2464            assert_eq!(doc.page_count(), expected);
2465            assert_eq!(doc.page_count(), sync_doc.page_count());
2466        }
2467    }
2468
2469    #[tokio::test]
2470    async fn page_records_carry_inherited_resources_and_refs() {
2471        let mut b = pdfboss_testkit::PdfBuilder::new();
2472        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
2473        b.object(
2474            2,
2475            "<< /Type /Pages /Kids [3 0 R] /Count 1 \
2476             /Resources << /Font << /F1 5 0 R >> >> >>",
2477        );
2478        b.object(3, "<< /Type /Page /Parent 2 0 R >>");
2479        b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
2480        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
2481        assert_eq!(doc.page_count(), 1);
2482        let record = doc.page_record(0).unwrap();
2483        assert_eq!(record.r, Some(ObjRef { num: 3, gen: 0 }));
2484        assert!(
2485            record.resources.get("Font").is_some(),
2486            "inherited resources"
2487        );
2488        assert!(doc.page_record(1).is_none());
2489    }
2490
2491    #[tokio::test]
2492    async fn kids_cycle_truncates_without_hanging() {
2493        let mut b = pdfboss_testkit::PdfBuilder::new();
2494        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
2495        // 2 → 3 → {4, back to 2}: the back-edge must be ignored.
2496        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
2497        b.object(3, "<< /Type /Pages /Kids [4 0 R 2 0 R] /Count 1 >>");
2498        b.object(4, "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 100 100] >>");
2499        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
2500        assert_eq!(doc.page_count(), 1, "cycle back-edge yields no extra pages");
2501    }
2502
2503    #[tokio::test]
2504    async fn page_count_is_the_flattened_length() {
2505        // The tree declares five pages but supplies one kid. The async
2506        // document always flattens at open, so — per adopted rule 6 — it
2507        // reports the authoritative flattened length (the sync document
2508        // reports the declared /Count until its tree is flattened).
2509        let mut b = pdfboss_testkit::PdfBuilder::new();
2510        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
2511        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 5 >>");
2512        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
2513        let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
2514        assert_eq!(doc.page_count(), 1);
2515    }
2516
2517    #[tokio::test]
2518    async fn encrypted_documents_are_rejected_at_open() {
2519        // The dummy /U entry cannot verify under the empty user password, so
2520        // this stands in for a genuinely password-protected file: the open
2521        // must decline rather than decrypt with a wrong key.
2522        let mut b = pdfboss_testkit::PdfBuilder::new().trailer_extra("/Encrypt 9 0 R");
2523        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
2524        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
2525        b.object(
2526            9,
2527            "<< /Filter /Standard /V 1 /R 2 /O (dummydummydummydummydummydummyd) \
2528             /U (dummydummydummydummydummydummyd) /P -3904 >>",
2529        );
2530        let data = b.build(1);
2531        assert!(
2532            matches!(
2533                AsyncDocument::from_bytes(data).await,
2534                Err(Error::Core(pdfboss_core::Error::Encrypted))
2535            ),
2536            "an encrypted document must be rejected at open, not opened with garbage reads"
2537        );
2538    }
2539}