Skip to main content

vole_document/adapter/pdf/
physical.rs

1//! Byte-authoritative physical classifier for PDF input (Phase 3.2).
2//!
3//! This pass builds on the lexical cover from [`super::lexer`] and partitions the
4//! whole input `[0, len)` into structural [`PhysicalSpan`]s. It is deliberately
5//! conservative:
6//!
7//! * Keyword recognition looks **only** at `Regular` lexemes. Because a literal
8//!   string, hex string, or comment is a single opaque span, an `endobj`,
9//!   `stream`, or `xref` spelling inside such a span can never be mistaken for
10//!   structure.
11//! * Stream data is the one region treated as raw, opaque bytes: between the EOL
12//!   following the `stream` keyword and the matching `endstream` keyword nothing
13//!   is interpreted, so `obj`/`endobj`/`stream` spellings inside payload bytes
14//!   cannot split an object.
15//! * When a construct cannot be recognised confidently it falls back to
16//!   `Unclassified` (for non-structural lexemes) or `ObjBody` (for a trailing
17//!   object with no `endobj`), never inventing or dropping bytes.
18//!
19//! The invariant is the same as the lexical layer: the emitted spans are a
20//! contiguous cover of exactly `input.len()` bytes. [`scan`] verifies this before
21//! returning, so a classifier bug becomes a classified
22//! [`crate::ErrorClass::CoverageViolation`] instead of silent loss.
23
24use crate::error::{Error, Result};
25use crate::limits::Limits;
26
27use super::cos::{
28    LengthValue, body_as_u64, dict_has_length, dict_int_or_ref, dict_length, dict_name_value,
29};
30use super::lexer::lex;
31use super::span::{Span, SpanKind};
32
33/// The physical role of a byte span in a PDF file.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PhysicalKind {
36    /// The leading `%PDF-` header comment.
37    Header,
38    /// A `%` comment line (including binary/UTF-8 markers).
39    Comment,
40    /// A run of PDF whitespace bytes.
41    Whitespace,
42    /// The `N G obj` introducer of an indirect object.
43    ObjHeader,
44    /// Indirect-object body bytes that are not stream data.
45    ObjBody,
46    /// The `endobj` keyword.
47    EndObj,
48    /// Raw bytes of a stream payload (between EOL and `endstream`).
49    StreamData,
50    /// A classic `xref`-to-`trailer` cross-reference section.
51    XrefSection,
52    /// A `trailer` keyword plus its dictionary, when present.
53    Trailer,
54    /// A `startxref` keyword plus its offset value.
55    StartXref,
56    /// A `%%EOF` marker.
57    Eof,
58    /// A lexeme that could not be confidently classified (residual authority).
59    Unclassified,
60}
61
62/// One physical span: a half-open byte range `[start, start + len)`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct PhysicalSpan {
65    /// Offset of the first byte of the span.
66    pub start: u64,
67    /// Number of bytes in the span (always > 0 in a valid cover).
68    pub len: u64,
69    /// The physical role of the span.
70    pub kind: PhysicalKind,
71}
72
73/// The structural role of an indirect object, inferred from its leading
74/// dictionary.
75///
76/// The classification is deliberately conservative: only a leading `<<...>>`
77/// dictionary whose `/Type` is a simple name is inspected, so anything ambiguous
78/// remains [`ObjRole::Generic`].
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum ObjRole {
81    /// An ordinary object, or one whose role could not be established.
82    Generic,
83    /// An object whose leading dictionary is `/Type /XRef` (a cross-reference
84    /// stream).
85    XRefStream,
86    /// An object whose leading dictionary is `/Type /ObjStm` (an object stream).
87    ObjectStream,
88}
89
90/// An indirect object discovered in file order.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct PdfObjectSpan {
93    /// The object number `N`.
94    pub number: u64,
95    /// The generation number `G`.
96    pub generation: u64,
97    /// Offset of the `N` introducer.
98    pub start: u64,
99    /// Offset just past the closing `endobj`.
100    pub end: u64,
101    /// Structural role inferred from the leading dictionary.
102    pub role: ObjRole,
103}
104
105/// How a stream's data length was determined.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum LengthSource {
108    /// A direct `/Length N` was read and verified against `endstream`.
109    Direct,
110    /// An indirect `/Length N G R` was resolved and verified.
111    Indirect,
112    /// No usable `/Length`; the conservative 3.2 keyword search was used.
113    Fallback,
114    /// No `/Length` key was present at all; the keyword search was used.
115    Missing,
116}
117
118/// The exact data span of one stream, with the provenance of its length.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct PdfStreamSpan {
121    /// Object number of the enclosing indirect object.
122    pub object: u64,
123    /// Generation number of the enclosing indirect object.
124    pub generation: u64,
125    /// Offset of the first payload byte (after the post-`stream` EOL).
126    pub data_start: u64,
127    /// Number of payload bytes.
128    pub data_len: u64,
129    /// How `data_len` was established.
130    pub length_source: LengthSource,
131}
132
133/// One incremental-update revision, delimited by a terminating `%%EOF`.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct RevisionInfo {
136    /// Zero-based position of the revision in file order.
137    pub index: u32,
138    /// First byte of the revision.
139    pub start: u64,
140    /// Byte just past the terminating `%%EOF` comment.
141    pub end: u64,
142    /// The recorded `startxref` value whose keyword lies in this revision.
143    pub startxref: Option<u64>,
144    /// The resolved `/Prev` offset of this revision's cross-reference anchor, if
145    /// any.
146    pub prev: Option<u64>,
147}
148
149/// The physical summary of a PDF input.
150#[derive(Debug, Clone, PartialEq, Eq, Default)]
151pub struct PdfPhysical {
152    /// A contiguous cover of the input, in ascending offset order.
153    pub spans: Vec<PhysicalSpan>,
154    /// Indirect objects found, in file order.
155    pub objects: Vec<PdfObjectSpan>,
156    /// Resolved stream payload spans, in file order.
157    pub streams: Vec<PdfStreamSpan>,
158    /// Revision records delimited by `%%EOF`, in file order.
159    pub revisions: Vec<RevisionInfo>,
160    /// Recorded `startxref` values, in file order.
161    pub startxref: Vec<u64>,
162    /// Offsets of `%%EOF` markers, in file order.
163    pub eofs: Vec<u64>,
164    /// `(start, len)` of the `%PDF-` header comment, if present.
165    pub header: Option<(u64, u64)>,
166}
167
168impl PdfPhysical {
169    /// Sum of all span lengths. Saturates rather than panicking.
170    pub fn total_len(&self) -> u64 {
171        self.spans
172            .iter()
173            .fold(0u64, |acc, s| acc.saturating_add(s.len))
174    }
175
176    /// Require a contiguous cover of exactly `[0, declared_len)`.
177    ///
178    /// Returns [`crate::ErrorClass::CoverageViolation`] for a gap, overlap,
179    /// wrong total, or length overflow, and
180    /// [`crate::ErrorClass::InvalidPdfStructure`] for a zero-length span.
181    pub fn validate(&self, declared_len: u64) -> Result<()> {
182        let mut cursor: u64 = 0;
183        for (i, span) in self.spans.iter().enumerate() {
184            if span.len == 0 {
185                return Err(Error::invalid_pdf_structure(format!(
186                    "physical span {i} has zero length at offset {}",
187                    span.start
188                )));
189            }
190            if span.start != cursor {
191                let why = if span.start < cursor {
192                    "overlap"
193                } else {
194                    "gap"
195                };
196                return Err(Error::coverage_violation(format!(
197                    "physical span {i} {why}: expected start {cursor}, found {}",
198                    span.start
199                )));
200            }
201            cursor = cursor.checked_add(span.len).ok_or_else(|| {
202                Error::coverage_violation("physical span lengths overflow the address space")
203            })?;
204        }
205        if cursor != declared_len {
206            return Err(Error::coverage_violation(format!(
207                "physical cover ends at {cursor}, declared length is {declared_len}"
208            )));
209        }
210        Ok(())
211    }
212}
213
214/// Build a conservative physical classification of `input` under `limits`.
215pub fn scan(input: &[u8], limits: Limits) -> Result<PdfPhysical> {
216    let declared_len = input.len() as u64;
217    let lexed = lex(input, limits)?;
218    let spans = lexed.spans.spans;
219
220    // A prior pass resolves object body ranges, so an indirect `/Length` can be
221    // resolved even when the target object appears later in the file.
222    let obj_bodies = collect_bodies(input, &spans);
223    let mut state = ScanState::new(limits, obj_bodies);
224    let mut i = 0usize;
225    while i < spans.len() {
226        let sp = spans[i];
227        let bytes = bytes_of(input, sp);
228
229        // 2. Header: the comment at offset 0 beginning `%PDF-`.
230        if state.header.is_none()
231            && sp.start == 0
232            && sp.kind == SpanKind::Comment
233            && bytes.starts_with(b"%PDF-")
234        {
235            state.push(sp.start, sp.len, PhysicalKind::Header)?;
236            state.header = Some((sp.start, sp.len));
237            i += 1;
238            continue;
239        }
240
241        // 6. End-of-file marker.
242        if sp.kind == SpanKind::Comment && bytes.starts_with(b"%%EOF") {
243            state.push(sp.start, sp.len, PhysicalKind::Eof)?;
244            state.eofs.push(sp.start);
245            i += 1;
246            continue;
247        }
248
249        // 3-5. Structural keywords are recognised only on Regular lexemes.
250        if sp.kind == SpanKind::Regular {
251            if bytes == b"startxref" {
252                if let Some(next) = state.try_startxref(input, &spans, i)? {
253                    i = next;
254                    continue;
255                }
256            } else if bytes == b"xref" {
257                i = state.emit_xref(input, &spans, i)?;
258                continue;
259            } else if bytes == b"trailer" {
260                i = state.emit_trailer(&spans, i)?;
261                continue;
262            } else if let Some((number, generation)) = obj_header_at(input, &spans, i) {
263                i = state.emit_object(input, &spans, i, number, generation)?;
264                continue;
265            }
266        }
267
268        // 7. Residual lexemes retain their lexical class or fall to Unclassified.
269        let kind = match sp.kind {
270            SpanKind::Comment => PhysicalKind::Comment,
271            SpanKind::Whitespace => PhysicalKind::Whitespace,
272            _ => PhysicalKind::Unclassified,
273        };
274        state.push(sp.start, sp.len, kind)?;
275        i += 1;
276    }
277
278    let physical = state.finish(input, &spans);
279    physical.validate(declared_len)?;
280    Ok(physical)
281}
282
283/// Mutable accumulator for [`scan`].
284struct ScanState {
285    builder: Builder,
286    objects: Vec<PdfObjectSpan>,
287    streams: Vec<PdfStreamSpan>,
288    obj_bodies: Vec<ObjBody>,
289    startxref: Vec<(u64, u64)>,
290    eofs: Vec<u64>,
291    trailer_dicts: Vec<(u64, u64)>,
292    header: Option<(u64, u64)>,
293}
294
295impl ScanState {
296    fn new(limits: Limits, obj_bodies: Vec<ObjBody>) -> Self {
297        ScanState {
298            builder: Builder::new(limits),
299            objects: Vec::new(),
300            streams: Vec::new(),
301            obj_bodies,
302            startxref: Vec::new(),
303            eofs: Vec::new(),
304            trailer_dicts: Vec::new(),
305            header: None,
306        }
307    }
308
309    fn push(&mut self, start: u64, len: u64, kind: PhysicalKind) -> Result<()> {
310        self.builder.push(start, len, kind)
311    }
312
313    fn finish(self, input: &[u8], spans: &[Span]) -> PdfPhysical {
314        let revisions = build_revisions(
315            input,
316            spans,
317            &self.eofs,
318            &self.objects,
319            &self.startxref,
320            &self.trailer_dicts,
321        );
322        PdfPhysical {
323            spans: self.builder.spans,
324            objects: self.objects,
325            streams: self.streams,
326            revisions,
327            startxref: self.startxref.iter().map(|&(_, value)| value).collect(),
328            eofs: self.eofs,
329            header: self.header,
330        }
331    }
332
333    /// Emit the classification of the indirect object whose introducer starts at
334    /// lexeme `i`; returns the index of the first lexeme after the object.
335    fn emit_object(
336        &mut self,
337        input: &[u8],
338        spans: &[Span],
339        i: usize,
340        number: u64,
341        generation: u64,
342    ) -> Result<usize> {
343        let obj_header_start = spans[i].start;
344        let obj_kw_end = spans[i + 4].start + spans[i + 4].len;
345        let role = leading_dict_role(input, spans, i + 5);
346        self.push(
347            obj_header_start,
348            obj_kw_end - obj_header_start,
349            PhysicalKind::ObjHeader,
350        )?;
351
352        // Locate the object end: the first `endobj` after the introducer, with an
353        // optional stream payload skipped wholesale. Streams use a resolved
354        // `/Length` when possible and the conservative 3.2 keyword search otherwise.
355        let mut stream: Option<ResolvedStream> = None;
356        let mut endobj: Option<usize> = None;
357        let mut j = i + 5;
358        while j < spans.len() {
359            if regular_eq(input, spans[j], b"endobj") {
360                endobj = Some(j);
361                break;
362            }
363            if stream.is_none()
364                && regular_eq(input, spans[j], b"stream")
365                && let Some(rs) = resolve_stream(input, spans, j, &self.obj_bodies, i + 5)
366            {
367                j = rs.endstream + 1;
368                stream = Some(rs);
369                continue;
370            }
371            j += 1;
372        }
373
374        match endobj {
375            Some(m) => {
376                if let Some(rs) = stream {
377                    if rs.data_start > obj_kw_end {
378                        self.push(
379                            obj_kw_end,
380                            rs.data_start - obj_kw_end,
381                            PhysicalKind::ObjBody,
382                        )?;
383                    }
384                    if rs.data_len > 0 {
385                        self.push(rs.data_start, rs.data_len, PhysicalKind::StreamData)?;
386                    }
387                    // Resume at the end of the declared payload so any optional
388                    // trailing EOL is still covered (it is not part of the span).
389                    let data_end = rs.data_start + rs.data_len;
390                    let body_end = spans[m].start;
391                    if body_end > data_end {
392                        self.push(data_end, body_end - data_end, PhysicalKind::ObjBody)?;
393                    }
394                    self.streams.push(PdfStreamSpan {
395                        object: number,
396                        generation,
397                        data_start: rs.data_start,
398                        data_len: rs.data_len,
399                        length_source: rs.source,
400                    });
401                } else {
402                    let body_end = spans[m].start;
403                    if body_end > obj_kw_end {
404                        self.push(obj_kw_end, body_end - obj_kw_end, PhysicalKind::ObjBody)?;
405                    }
406                }
407
408                self.push(spans[m].start, spans[m].len, PhysicalKind::EndObj)?;
409                self.objects.push(PdfObjectSpan {
410                    number,
411                    generation,
412                    start: obj_header_start,
413                    end: spans[m].start + spans[m].len,
414                    role,
415                });
416                Ok(m + 1)
417            }
418            None => {
419                // Malformed: no `endobj`. Keep the remainder as body bytes.
420                let end = input.len() as u64;
421                if end > obj_kw_end {
422                    self.push(obj_kw_end, end - obj_kw_end, PhysicalKind::ObjBody)?;
423                }
424                Ok(spans.len())
425            }
426        }
427    }
428
429    /// Emit a classic `xref` section, stopping at the following `trailer`,
430    /// `startxref`, or `%%EOF`. Returns the next lexeme index.
431    fn emit_xref(&mut self, input: &[u8], spans: &[Span], i: usize) -> Result<usize> {
432        let start = spans[i].start;
433        let mut end_idx = spans.len();
434        for (k, sp) in spans.iter().enumerate().skip(i + 1) {
435            let sp = *sp;
436            if regular_eq(input, sp, b"trailer") || regular_eq(input, sp, b"startxref") {
437                end_idx = k;
438                break;
439            }
440            if sp.kind == SpanKind::Comment && bytes_of(input, sp).starts_with(b"%%EOF") {
441                end_idx = k;
442                break;
443            }
444        }
445
446        let end = if end_idx < spans.len() {
447            spans[end_idx].start
448        } else {
449            input.len() as u64
450        };
451        if end > start {
452            self.push(start, end - start, PhysicalKind::XrefSection)?;
453        }
454
455        if end_idx < spans.len() && regular_eq(input, spans[end_idx], b"trailer") {
456            self.emit_trailer(spans, end_idx)
457        } else {
458            Ok(end_idx)
459        }
460    }
461
462    /// Emit a `trailer` keyword plus its first dictionary, if any. Returns the
463    /// next lexeme index.
464    fn emit_trailer(&mut self, spans: &[Span], t: usize) -> Result<usize> {
465        let start = spans[t].start;
466        let mut end = spans[t].start + spans[t].len;
467        let mut next = t + 1;
468        let dict_idx = if next < spans.len() && spans[next].kind == SpanKind::Whitespace {
469            next + 1
470        } else {
471            next
472        };
473        if dict_idx < spans.len()
474            && spans[dict_idx].kind == SpanKind::DictOpen
475            && let Some(close) = matching_dict_close(spans, dict_idx)
476        {
477            let lo = spans[dict_idx].start;
478            let hi = spans[close].start + spans[close].len;
479            self.trailer_dicts.push((lo, hi));
480            end = hi;
481            next = close + 1;
482        }
483        if end > start {
484            self.push(start, end - start, PhysicalKind::Trailer)?;
485        }
486        Ok(next)
487    }
488
489    /// Emit a `startxref` keyword plus its value if the pattern matches.
490    fn try_startxref(&mut self, input: &[u8], spans: &[Span], i: usize) -> Result<Option<usize>> {
491        if i + 2 < spans.len()
492            && spans[i + 1].kind == SpanKind::Whitespace
493            && spans[i + 2].kind == SpanKind::Regular
494            && let Some(value) = parse_uint(bytes_of(input, spans[i + 2]), 19)
495        {
496            let start = spans[i].start;
497            let end = spans[i + 2].start + spans[i + 2].len;
498            self.push(start, end - start, PhysicalKind::StartXref)?;
499            self.startxref.push((start, value));
500            return Ok(Some(i + 3));
501        }
502        Ok(None)
503    }
504}
505
506/// Append-only physical span builder that merges adjacent equal kinds and
507/// enforces the span-count bound.
508struct Builder {
509    max: u32,
510    spans: Vec<PhysicalSpan>,
511}
512
513impl Builder {
514    fn new(limits: Limits) -> Self {
515        Builder {
516            max: limits.max_pdf_spans,
517            spans: Vec::new(),
518        }
519    }
520
521    fn push(&mut self, start: u64, len: u64, kind: PhysicalKind) -> Result<()> {
522        if len == 0 {
523            return Ok(());
524        }
525        if let Some(last) = self.spans.last_mut()
526            && last.kind == kind
527            && last.start.checked_add(last.len) == Some(start)
528        {
529            last.len = last
530                .len
531                .checked_add(len)
532                .ok_or_else(|| Error::coverage_violation("physical span length overflow"))?;
533            return Ok(());
534        }
535        if self.spans.len() as u64 >= self.max as u64 {
536            return Err(Error::resource_limit(format!(
537                "pdf physical span count exceeds limit {}",
538                self.max
539            )));
540        }
541        self.spans.push(PhysicalSpan { start, len, kind });
542        Ok(())
543    }
544}
545
546/// The byte slice backing `sp`, or empty if the offset is out of range.
547fn bytes_of(input: &[u8], sp: Span) -> &[u8] {
548    let Ok(start) = usize::try_from(sp.start) else {
549        return &[];
550    };
551    let Some(end) = sp
552        .start
553        .checked_add(sp.len)
554        .and_then(|e| usize::try_from(e).ok())
555    else {
556        return &[];
557    };
558    if start > end || end > input.len() {
559        return &[];
560    }
561    &input[start..end]
562}
563
564/// Whether `sp` is a `Regular` lexeme exactly equal to `keyword`.
565fn regular_eq(input: &[u8], sp: Span, keyword: &[u8]) -> bool {
566    sp.kind == SpanKind::Regular && bytes_of(input, sp) == keyword
567}
568
569/// Parse an unsigned ASCII integer of at most `max_digits` digits.
570fn parse_uint(bytes: &[u8], max_digits: usize) -> Option<u64> {
571    if bytes.is_empty() || bytes.len() > max_digits {
572        return None;
573    }
574    let mut value: u64 = 0;
575    for &b in bytes {
576        if !b.is_ascii_digit() {
577            return None;
578        }
579        value = value.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
580    }
581    Some(value)
582}
583
584/// Offset of the first CR or LF at or after `offset`.
585fn eol_start_after(input: &[u8], offset: u64) -> Option<u64> {
586    let start = usize::try_from(offset).ok()?;
587    if start > input.len() {
588        return None;
589    }
590    (start..input.len())
591        .find(|&i| input[i] == b'\n' || input[i] == b'\r')
592        .map(|i| i as u64)
593}
594
595/// Index of the first `Regular` lexeme equal to `keyword` at or after `from`.
596fn find_regular(input: &[u8], spans: &[Span], from: usize, keyword: &[u8]) -> Option<usize> {
597    if from >= spans.len() {
598        return None;
599    }
600    spans[from..]
601        .iter()
602        .position(|sp| regular_eq(input, *sp, keyword))
603        .map(|off| from + off)
604}
605
606/// Index of the `>>` matching the `<<` at `open`, honouring nesting.
607fn matching_dict_close(spans: &[Span], open: usize) -> Option<usize> {
608    let mut depth: u64 = 0;
609    for (k, sp) in spans.iter().enumerate().skip(open) {
610        match sp.kind {
611            SpanKind::DictOpen => depth = depth.saturating_add(1),
612            SpanKind::DictClose => {
613                if depth == 0 {
614                    return None;
615                }
616                depth -= 1;
617                if depth == 0 {
618                    return Some(k);
619                }
620            }
621            _ => {}
622        }
623    }
624    None
625}
626
627/// Recognise `N G obj` starting at lexeme `i`, returning `(N, G)`.
628fn obj_header_at(input: &[u8], spans: &[Span], i: usize) -> Option<(u64, u64)> {
629    if i + 4 >= spans.len() {
630        return None;
631    }
632    if spans[i].kind != SpanKind::Regular {
633        return None;
634    }
635    let number = parse_uint(bytes_of(input, spans[i]), 10)?;
636    if spans[i + 1].kind != SpanKind::Whitespace {
637        return None;
638    }
639    if spans[i + 2].kind != SpanKind::Regular {
640        return None;
641    }
642    let generation = parse_uint(bytes_of(input, spans[i + 2]), 10)?;
643    if spans[i + 3].kind != SpanKind::Whitespace {
644        return None;
645    }
646    if !regular_eq(input, spans[i + 4], b"obj") {
647        return None;
648    }
649    Some((number, generation))
650}
651
652// ---------------------------------------------------------------------------
653// Stream length resolution.
654// ---------------------------------------------------------------------------
655
656/// A pre-resolved indirect-object body range, keyed by `(number, generation)`.
657#[derive(Debug, Clone, Copy)]
658struct ObjBody {
659    number: u64,
660    generation: u64,
661    body_lo: u64,
662    body_hi: u64,
663}
664
665/// A resolved stream payload: exact data span plus the length's provenance.
666#[derive(Debug, Clone, Copy)]
667struct ResolvedStream {
668    data_start: u64,
669    data_len: u64,
670    endstream: usize,
671    source: LengthSource,
672}
673
674/// One prior pass over the lexical cover records every object's body range using
675/// the same conservative stream skip as the main pass. This lets the main pass
676/// resolve an indirect `/Length` even when the target object appears later.
677fn collect_bodies(input: &[u8], spans: &[Span]) -> Vec<ObjBody> {
678    let mut out = Vec::new();
679    let mut i = 0usize;
680    while i < spans.len() {
681        if let Some((number, generation)) = obj_header_at(input, spans, i) {
682            let body_lo = spans[i + 4].start + spans[i + 4].len;
683            match find_endobj(input, spans, i + 5) {
684                Some(m) => {
685                    out.push(ObjBody {
686                        number,
687                        generation,
688                        body_lo,
689                        body_hi: spans[m].start,
690                    });
691                    i = m + 1;
692                }
693                None => {
694                    out.push(ObjBody {
695                        number,
696                        generation,
697                        body_lo,
698                        body_hi: input.len() as u64,
699                    });
700                    i = spans.len();
701                }
702            }
703        } else {
704            i += 1;
705        }
706    }
707    out
708}
709
710/// Index of the terminating `endobj`, skipping a stream payload wholesale with
711/// the conservative keyword rule used by the 3.2 scanner.
712fn find_endobj(input: &[u8], spans: &[Span], from: usize) -> Option<usize> {
713    let mut j = from;
714    while j < spans.len() {
715        if regular_eq(input, spans[j], b"endobj") {
716            return Some(j);
717        }
718        if regular_eq(input, spans[j], b"stream") {
719            let kw_end = spans[j].start + spans[j].len;
720            if let Some(data_start) = eol_start_after(input, kw_end)
721                && let Some(k) = find_regular(input, spans, j + 1, b"endstream")
722                && spans[k].start >= data_start
723            {
724                j = k + 1;
725                continue;
726            }
727        }
728        j += 1;
729    }
730    None
731}
732
733/// Body range of object `(number, generation)`, if present.
734fn lookup_body(bodies: &[ObjBody], number: u64, generation: u64) -> Option<(u64, u64)> {
735    bodies
736        .iter()
737        .find(|b| b.number == number && b.generation == generation)
738        .map(|b| (b.body_lo, b.body_hi))
739}
740
741/// Resolve a `stream` keyword at lexeme `s` to its exact payload span.
742///
743/// Precedence: direct `/Length`, then indirect `/Length` (resolved through the
744/// object-body index), then the conservative 3.2 keyword search. Returns `None`
745/// only when not even the keyword search finds a terminating `endstream`.
746fn resolve_stream(
747    input: &[u8],
748    spans: &[Span],
749    s: usize,
750    bodies: &[ObjBody],
751    lower: usize,
752) -> Option<ResolvedStream> {
753    let kw_end = spans[s].start + spans[s].len;
754
755    let mut resolved: Option<ResolvedStream> = None;
756    let mut source = LengthSource::Fallback;
757
758    if let Some((open, close)) = preceding_dict(spans, s, lower) {
759        let dict_lo = spans[open].start;
760        let dict_hi = spans[close].start + spans[close].len;
761        match dict_length(input, spans, dict_lo, dict_hi) {
762            Some(LengthValue::Direct(n)) => {
763                if let Some(rs) = verify_direct(input, spans, kw_end, n) {
764                    resolved = Some(rs);
765                    source = LengthSource::Direct;
766                }
767            }
768            Some(LengthValue::Indirect { number, generation }) => {
769                if let Some((lo, hi)) = lookup_body(bodies, number, generation)
770                    && let Some(n) = body_as_u64(input, spans, lo, hi)
771                    && let Some(rs) = verify_direct(input, spans, kw_end, n)
772                {
773                    resolved = Some(rs);
774                    source = LengthSource::Indirect;
775                }
776            }
777            None => {
778                if !dict_has_length(input, spans, dict_lo, dict_hi) {
779                    source = LengthSource::Missing;
780                }
781            }
782        }
783    } else {
784        source = LengthSource::Missing;
785    }
786
787    if let Some(mut rs) = resolved {
788        rs.source = source;
789        return Some(rs);
790    }
791
792    // Conservative 3.2 fallback: the payload runs from the first EOL after the
793    // `stream` keyword to the first following `endstream` keyword.
794    let data_start = eol_start_after(input, kw_end)?;
795    let k = find_regular(input, spans, s + 1, b"endstream")?;
796    if spans[k].start < data_start {
797        return None;
798    }
799    Some(ResolvedStream {
800        data_start,
801        data_len: spans[k].start - data_start,
802        endstream: k,
803        source,
804    })
805}
806
807/// The `<<...>>` dictionary immediately preceding `stream` at `s`, as
808/// `(open, close)` lexeme indices: the nearest `DictOpen` whose matching
809/// `DictClose` lies before `s`.
810fn preceding_dict(spans: &[Span], s: usize, lower: usize) -> Option<(usize, usize)> {
811    let mut j = s;
812    while j > lower {
813        j -= 1;
814        if spans[j].kind == SpanKind::DictOpen
815            && let Some(close) = matching_dict_close(spans, j)
816            && close < s
817        {
818            return Some((j, close));
819        }
820    }
821    None
822}
823
824/// Verify a direct length `n` against the bytes after `stream`: exactly one EOL
825/// must follow the keyword, and after `n` bytes an optional EOL must reach a
826/// `Regular` `endstream`. A lone CR after `stream` is not a valid EOL.
827fn verify_direct(input: &[u8], spans: &[Span], kw_end: u64, n: u64) -> Option<ResolvedStream> {
828    let eol = post_stream_eol_len(input, kw_end)?;
829    let data_start = kw_end + eol;
830    let data_end = data_start.checked_add(n)?;
831    let k = first_regular_at_or_after(input, spans, data_end, b"endstream")?;
832    let gap = spans[k].start.checked_sub(data_end)?;
833    let ok = gap == 0
834        || (gap == 1 && byte_at(input, data_end) == Some(b'\n'))
835        || (gap == 2
836            && byte_at(input, data_end) == Some(b'\r')
837            && byte_at(input, data_end + 1) == Some(b'\n'));
838    if !ok {
839        return None;
840    }
841    Some(ResolvedStream {
842        data_start,
843        data_len: n,
844        endstream: k,
845        source: LengthSource::Direct,
846    })
847}
848
849/// Length of the mandatory EOL directly after the `stream` keyword: `LF` (1),
850/// `CRLF` (2), or `None` (including a lone `CR`).
851fn post_stream_eol_len(input: &[u8], kw_end: u64) -> Option<u64> {
852    match byte_at(input, kw_end) {
853        Some(b'\n') => Some(1),
854        Some(b'\r') if byte_at(input, kw_end + 1) == Some(b'\n') => Some(2),
855        _ => None,
856    }
857}
858
859/// Index of the first `Regular` lexeme equal to `keyword` whose start is at or
860/// after `offset`.
861fn first_regular_at_or_after(
862    input: &[u8],
863    spans: &[Span],
864    offset: u64,
865    keyword: &[u8],
866) -> Option<usize> {
867    let idx = spans.partition_point(|sp| sp.start < offset);
868    spans[idx..]
869        .iter()
870        .position(|sp| regular_eq(input, *sp, keyword))
871        .map(|off| idx + off)
872}
873
874/// One byte at `offset`, if in range.
875fn byte_at(input: &[u8], offset: u64) -> Option<u8> {
876    usize::try_from(offset)
877        .ok()
878        .and_then(|i| input.get(i).copied())
879}
880
881/// Build revision records from the `%%EOF` offsets. `end` is the byte just past
882/// the comment; `start` is `0` for the first revision, otherwise the previous
883/// revision's end advanced by at most one optional EOL.
884fn build_revisions(
885    input: &[u8],
886    spans: &[Span],
887    eofs: &[u64],
888    objects: &[PdfObjectSpan],
889    startxref: &[(u64, u64)],
890    trailers: &[(u64, u64)],
891) -> Vec<RevisionInfo> {
892    let mut out = Vec::with_capacity(eofs.len());
893    let mut start = 0u64;
894    for (index, &e) in eofs.iter().enumerate() {
895        let end = span_end_at(spans, e).unwrap_or(e);
896        let startxref = startxref
897            .iter()
898            .find(|&&(keyword, _)| keyword >= start && keyword < end)
899            .map(|&(_, value)| value);
900        let prev = resolve_prev(input, spans, start, end, objects, trailers);
901        out.push(RevisionInfo {
902            index: index as u32,
903            start,
904            end,
905            startxref,
906            prev,
907        });
908        start = end + eol_len_after(input, end);
909    }
910    out
911}
912
913/// Resolve a revision's `/Prev` offset from its cross-reference anchor: the
914/// classic `trailer` dict if present in the revision, otherwise an `XRefStream`
915/// object's leading dict. A direct integer is used as-is; a reference is mapped
916/// to the referenced object's offset when that object exists. Returns `None` when
917/// there is no anchor, no `/Prev`, or an unresolvable reference.
918fn resolve_prev(
919    input: &[u8],
920    spans: &[Span],
921    rev_start: u64,
922    rev_end: u64,
923    objects: &[PdfObjectSpan],
924    trailers: &[(u64, u64)],
925) -> Option<u64> {
926    let trailer = trailers
927        .iter()
928        .rev()
929        .find(|&&(lo, _)| lo >= rev_start && lo < rev_end);
930    let (dict_lo, dict_hi) = match trailer {
931        Some(&(lo, hi)) => (lo, hi),
932        None => {
933            let object = objects.iter().find(|o| {
934                o.role == ObjRole::XRefStream && o.start >= rev_start && o.start < rev_end
935            })?;
936            leading_dict_range_at(input, spans, object.start)?
937        }
938    };
939    match dict_int_or_ref(input, spans, dict_lo, dict_hi, b"Prev") {
940        Some(LengthValue::Direct(n)) => Some(n),
941        Some(LengthValue::Indirect { number, generation }) => objects
942            .iter()
943            .find(|o| o.number == number && o.generation == generation)
944            .map(|o| o.start),
945        None => None,
946    }
947}
948
949/// Range `[lo, hi)` of the `<<...>>` dictionary immediately following the `obj`
950/// keyword (skipping whitespace/comments), or `None` if the next significant
951/// token is not a dict opener.
952fn leading_dict_range(spans: &[Span], after: usize) -> Option<(u64, u64)> {
953    let mut j = after;
954    while j < spans.len() && matches!(spans[j].kind, SpanKind::Whitespace | SpanKind::Comment) {
955        j += 1;
956    }
957    if j >= spans.len() || spans[j].kind != SpanKind::DictOpen {
958        return None;
959    }
960    let close = matching_dict_close(spans, j)?;
961    Some((spans[j].start, spans[close].start + spans[close].len))
962}
963
964/// Classify an object by its leading dictionary's `/Type`. Conservative: only a
965/// simple name value yields a non-generic role.
966fn leading_dict_role(input: &[u8], spans: &[Span], after: usize) -> ObjRole {
967    let Some((lo, hi)) = leading_dict_range(spans, after) else {
968        return ObjRole::Generic;
969    };
970    match dict_name_value(input, spans, lo, hi, b"Type") {
971        Some(name) if name == b"XRef".as_slice() => ObjRole::XRefStream,
972        Some(name) if name == b"ObjStm".as_slice() => ObjRole::ObjectStream,
973        _ => ObjRole::Generic,
974    }
975}
976
977/// Range `[lo, hi)` of the leading dictionary of the object whose introducer
978/// starts at `start`, or `None` if no object introducer begins there.
979fn leading_dict_range_at(input: &[u8], spans: &[Span], start: u64) -> Option<(u64, u64)> {
980    let idx = spans.partition_point(|sp| sp.start < start);
981    if idx >= spans.len() || spans[idx].start != start {
982        return None;
983    }
984    obj_header_at(input, spans, idx)?;
985    leading_dict_range(spans, idx + 5)
986}
987
988/// End offset of the span containing `offset`, if the offset lies within one.
989fn span_end_at(spans: &[Span], offset: u64) -> Option<u64> {
990    let idx = spans.partition_point(|sp| sp.start <= offset);
991    if idx == 0 {
992        return None;
993    }
994    let sp = spans[idx - 1];
995    if offset < sp.start.saturating_add(sp.len) {
996        Some(sp.start.saturating_add(sp.len))
997    } else {
998        None
999    }
1000}
1001
1002/// Length of one optional EOL at `offset`: `LF` (1), `CRLF` (2), lone `CR` (1),
1003/// or `0` when none is present.
1004fn eol_len_after(input: &[u8], offset: u64) -> u64 {
1005    match byte_at(input, offset) {
1006        Some(b'\n') => 1,
1007        Some(b'\r') if byte_at(input, offset + 1) == Some(b'\n') => 2,
1008        Some(b'\r') => 1,
1009        _ => 0,
1010    }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016    use crate::error::ErrorClass;
1017
1018    fn count(p: &PdfPhysical, kind: PhysicalKind) -> usize {
1019        p.spans.iter().filter(|s| s.kind == kind).count()
1020    }
1021
1022    fn canonical_pdf() -> Vec<u8> {
1023        let mut s = String::new();
1024        s.push_str("%PDF-1.7\n");
1025        s.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1026        s.push_str("2 0 obj\n<< /Length 6 >>\nstream\nhello\nendstream\nendobj\n");
1027        s.push_str("3 0 obj\n<< /Length 7 >>\nstream\nworld\nendstream\nendobj\n");
1028        s.push_str("4 0 obj\n<< /Length 4 >>\nstream\nxyz\nendstream\nendobj\n");
1029        s.push_str("xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n");
1030        s.push_str("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n321\n%%EOF");
1031        s.into_bytes()
1032    }
1033
1034    #[test]
1035    fn canonical_pdf_is_fully_classified() {
1036        let pdf = canonical_pdf();
1037        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1038
1039        assert_eq!(p.header, Some((0, 8)));
1040        assert_eq!(p.objects.len(), 4);
1041        let nums: Vec<(u64, u64)> = p.objects.iter().map(|o| (o.number, o.generation)).collect();
1042        assert_eq!(nums, [(1, 0), (2, 0), (3, 0), (4, 0)]);
1043        assert_eq!(p.startxref, [321]);
1044        assert_eq!(p.eofs.len(), 1);
1045        assert_eq!(count(&p, PhysicalKind::EndObj), 4);
1046        assert_eq!(count(&p, PhysicalKind::StreamData), 3);
1047        assert_eq!(count(&p, PhysicalKind::XrefSection), 1);
1048        assert_eq!(count(&p, PhysicalKind::Trailer), 1);
1049        assert_eq!(count(&p, PhysicalKind::ObjHeader), 4);
1050        assert_eq!(p.total_len(), pdf.len() as u64);
1051        p.validate(pdf.len() as u64).unwrap();
1052    }
1053
1054    #[test]
1055    fn endobj_inside_literal_string_does_not_split_object() {
1056        let pdf = b"%PDF-1.4\n1 0 obj\n(endobj)\nendobj".to_vec();
1057        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1058
1059        assert_eq!(p.objects.len(), 1);
1060        assert_eq!(p.objects[0].number, 1);
1061        assert_eq!(p.objects[0].generation, 0);
1062        assert_eq!(p.objects[0].end, pdf.len() as u64);
1063        assert_eq!(count(&p, PhysicalKind::EndObj), 1);
1064        assert_eq!(count(&p, PhysicalKind::StreamData), 0);
1065        p.validate(pdf.len() as u64).unwrap();
1066    }
1067
1068    #[test]
1069    fn stream_data_with_keyword_spellings_stays_opaque() {
1070        let pdf =
1071            b"%PDF-1.4\n1 0 obj\n<< /Length 30 >>\nstream\nendobj stream bytes here\nendstream\nendobj"
1072                .to_vec();
1073        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1074
1075        assert_eq!(p.objects.len(), 1);
1076        assert_eq!(p.objects[0].number, 1);
1077        assert_eq!(p.objects[0].end, pdf.len() as u64);
1078        assert_eq!(count(&p, PhysicalKind::EndObj), 1);
1079        assert_eq!(count(&p, PhysicalKind::StreamData), 1);
1080
1081        // The opaque stream payload must contain the fake keywords verbatim.
1082        let data = p
1083            .spans
1084            .iter()
1085            .find(|s| s.kind == PhysicalKind::StreamData)
1086            .unwrap();
1087        let slice = &pdf[data.start as usize..(data.start + data.len) as usize];
1088        assert!(slice.windows(6).any(|w| w == b"endobj"));
1089        assert!(slice.windows(6).any(|w| w == b"stream"));
1090        p.validate(pdf.len() as u64).unwrap();
1091    }
1092
1093    #[test]
1094    fn two_objects_with_xref_trailer_and_startxref() {
1095        let pdf = b"%PDF-1.4\n1 0 obj\n<< >>\nendobj\n2 0 obj\n<< >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 3 >>\nstartxref\n99\n%%EOF".to_vec();
1096        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1097
1098        assert_eq!(p.objects.len(), 2);
1099        assert_eq!(p.objects[0].number, 1);
1100        assert_eq!(p.objects[1].number, 2);
1101        assert!(p.objects[0].end <= p.objects[1].start);
1102        assert_eq!(count(&p, PhysicalKind::XrefSection), 1);
1103        assert_eq!(count(&p, PhysicalKind::Trailer), 1);
1104        assert_eq!(p.startxref, [99]);
1105        assert_eq!(p.eofs.len(), 1);
1106        p.validate(pdf.len() as u64).unwrap();
1107    }
1108
1109    #[test]
1110    fn malformed_object_without_endobj_is_conservative() {
1111        let pdf = b"1 0 obj".to_vec();
1112        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1113
1114        assert!(p.objects.is_empty());
1115        assert_eq!(count(&p, PhysicalKind::ObjHeader), 1);
1116        assert_eq!(count(&p, PhysicalKind::EndObj), 0);
1117        assert_eq!(p.total_len(), pdf.len() as u64);
1118        p.validate(pdf.len() as u64).unwrap();
1119    }
1120
1121    #[test]
1122    fn missing_header_leaves_cover_total() {
1123        let pdf = b"1 0 obj\nendobj\n".to_vec();
1124        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1125
1126        assert_eq!(p.header, None);
1127        assert_eq!(count(&p, PhysicalKind::EndObj), 1);
1128        p.validate(pdf.len() as u64).unwrap();
1129    }
1130
1131    fn xorshift64(state: &mut u64) -> u64 {
1132        let mut x = *state;
1133        x ^= x << 13;
1134        x ^= x >> 7;
1135        x ^= x << 17;
1136        *state = x;
1137        x
1138    }
1139
1140    #[test]
1141    fn random_bytes_never_panic_and_keep_cover() {
1142        let mut state: u64 = 0x1234_5678_9abc_def0;
1143        for _ in 0..500 {
1144            let len = (xorshift64(&mut state) % 96) as usize;
1145            let mut buf = Vec::with_capacity(len);
1146            for _ in 0..len {
1147                buf.push((xorshift64(&mut state) & 0xff) as u8);
1148            }
1149            match scan(&buf, Limits::DEFAULT) {
1150                Ok(p) => {
1151                    p.validate(buf.len() as u64).unwrap();
1152                    assert_eq!(p.total_len(), buf.len() as u64);
1153                }
1154                Err(e) => {
1155                    // Any failure must be a typed, classified error.
1156                    let _ = e.class();
1157                }
1158            }
1159        }
1160    }
1161
1162    #[test]
1163    fn tiny_span_limit_is_resource_limit() {
1164        let pdf = canonical_pdf();
1165        let limits = Limits {
1166            max_pdf_spans: 1,
1167            ..Limits::DEFAULT
1168        };
1169        let err = scan(&pdf, limits).unwrap_err();
1170        assert_eq!(err.class(), ErrorClass::ResourceLimit);
1171    }
1172
1173    fn offset_of(hay: &[u8], needle: &[u8]) -> u64 {
1174        hay.windows(needle.len())
1175            .position(|w| w == needle)
1176            .expect("needle present") as u64
1177    }
1178
1179    fn slice_of(pdf: &[u8], s: PdfStreamSpan) -> &[u8] {
1180        &pdf[s.data_start as usize..(s.data_start + s.data_len) as usize]
1181    }
1182
1183    fn only_stream(p: &PdfPhysical) -> PdfStreamSpan {
1184        assert_eq!(p.streams.len(), 1, "expected exactly one stream");
1185        p.streams[0]
1186    }
1187
1188    #[test]
1189    fn direct_length_yields_exact_span() {
1190        let pdf =
1191            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1192        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1193
1194        let s = only_stream(&p);
1195        assert_eq!(s.object, 1);
1196        assert_eq!(s.generation, 0);
1197        assert_eq!(s.length_source, LengthSource::Direct);
1198        assert_eq!(s.data_start, offset_of(&pdf, b"hello"));
1199        assert_eq!(s.data_len, 5);
1200        assert_eq!(slice_of(&pdf, s), b"hello");
1201
1202        let ds = p
1203            .spans
1204            .iter()
1205            .find(|sp| sp.kind == PhysicalKind::StreamData)
1206            .unwrap();
1207        assert_eq!(ds.start, s.data_start);
1208        assert_eq!(ds.len, s.data_len);
1209        p.validate(pdf.len() as u64).unwrap();
1210    }
1211
1212    #[test]
1213    fn length_including_trailing_eol_is_accepted() {
1214        let pdf =
1215            b"%PDF-1.5\n1 0 obj\n<< /Length 6 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1216        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1217        let s = only_stream(&p);
1218        assert_eq!(s.length_source, LengthSource::Direct);
1219        assert_eq!(slice_of(&pdf, s), b"hello\n");
1220        p.validate(pdf.len() as u64).unwrap();
1221    }
1222
1223    #[test]
1224    fn crlf_and_lf_after_stream_are_both_handled() {
1225        let crlf =
1226            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\r\nstream\r\nhello\r\nendstream\r\nendobj\n"
1227                .to_vec();
1228        let p = scan(&crlf, Limits::DEFAULT).unwrap();
1229        let s = only_stream(&p);
1230        assert_eq!(s.length_source, LengthSource::Direct);
1231        assert_eq!(slice_of(&crlf, s), b"hello");
1232        assert_eq!(s.data_start, offset_of(&crlf, b"hello"));
1233        p.validate(crlf.len() as u64).unwrap();
1234
1235        let lf = b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1236        let p = scan(&lf, Limits::DEFAULT).unwrap();
1237        let s = only_stream(&p);
1238        assert_eq!(s.length_source, LengthSource::Direct);
1239        assert_eq!(slice_of(&lf, s), b"hello");
1240        p.validate(lf.len() as u64).unwrap();
1241    }
1242
1243    #[test]
1244    fn lone_cr_after_stream_falls_back() {
1245        let pdf =
1246            b"%PDF-1.5\n1 0 obj\n<< /Length 5 >>\nstream\rhello\r\nendstream\nendobj\n".to_vec();
1247        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1248        let s = only_stream(&p);
1249        assert_eq!(s.length_source, LengthSource::Fallback);
1250        assert!(slice_of(&pdf, s).windows(5).any(|w| w == b"hello"));
1251        p.validate(pdf.len() as u64).unwrap();
1252    }
1253
1254    #[test]
1255    fn indirect_length_is_resolved_forward_reference() {
1256        // Object 5 (the `/Length` target) appears *after* the stream object.
1257        let pdf =
1258            b"%PDF-1.5\n1 0 obj\n<< /Length 5 0 R >>\nstream\nhello\nendstream\nendobj\n5 0 obj\n5\nendobj\n"
1259                .to_vec();
1260        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1261        let s = only_stream(&p);
1262        assert_eq!(s.length_source, LengthSource::Indirect);
1263        assert_eq!(s.data_start, offset_of(&pdf, b"hello"));
1264        assert_eq!(s.data_len, 5);
1265        assert_eq!(slice_of(&pdf, s), b"hello");
1266        p.validate(pdf.len() as u64).unwrap();
1267    }
1268
1269    #[test]
1270    fn missing_length_uses_keyword_fallback() {
1271        let pdf = b"%PDF-1.5\n1 0 obj\n<< /Type /X >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1272        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1273        let s = only_stream(&p);
1274        assert_eq!(s.length_source, LengthSource::Missing);
1275        assert!(slice_of(&pdf, s).windows(5).any(|w| w == b"hello"));
1276        p.validate(pdf.len() as u64).unwrap();
1277    }
1278
1279    #[test]
1280    fn wrong_length_past_endstream_falls_back() {
1281        let pdf =
1282            b"%PDF-1.5\n1 0 obj\n<< /Length 100 >>\nstream\nhello\nendstream\nendobj\n".to_vec();
1283        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1284        let s = only_stream(&p);
1285        assert_eq!(s.length_source, LengthSource::Fallback);
1286        assert!(slice_of(&pdf, s).windows(5).any(|w| w == b"hello"));
1287        p.validate(pdf.len() as u64).unwrap();
1288    }
1289
1290    #[test]
1291    fn correct_length_beats_endstream_bytes_in_payload() {
1292        // The payload contains a standalone `endstream` token. The keyword-only
1293        // fallback would stop early; a verified `/Length` must win.
1294        let payload = b"endstream\nfoo";
1295        let pdf =
1296            b"%PDF-1.5\n1 0 obj\n<< /Length 13 >>\nstream\nendstream\nfoo\nendstream\nendobj\n"
1297                .to_vec();
1298        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1299        let s = only_stream(&p);
1300        assert_eq!(s.length_source, LengthSource::Direct);
1301        assert_eq!(s.data_start, offset_of(&pdf, b"endstream\nfoo"));
1302        assert_eq!(s.data_len, payload.len() as u64);
1303        assert_eq!(slice_of(&pdf, s), payload);
1304        p.validate(pdf.len() as u64).unwrap();
1305    }
1306
1307    #[test]
1308    fn two_revisions_have_correct_boundaries() {
1309        let r1 = b"%PDF-1.4\n1 0 obj\n<< >>\nendobj\n%%EOF\n";
1310        let r2 = b"2 0 obj\n<< >>\nendobj\n%%EOF";
1311        let mut pdf = Vec::new();
1312        pdf.extend_from_slice(r1);
1313        pdf.extend_from_slice(r2);
1314
1315        let eof_positions: Vec<u64> = pdf
1316            .windows(5)
1317            .enumerate()
1318            .filter(|(_, w)| *w == b"%%EOF")
1319            .map(|(i, _)| i as u64)
1320            .collect();
1321        assert_eq!(eof_positions.len(), 2);
1322
1323        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1324        assert_eq!(p.eofs.len(), 2);
1325        assert_eq!(p.revisions.len(), 2);
1326
1327        let rev1_end = eof_positions[0] + 5;
1328        let rev2_end = eof_positions[1] + 5;
1329        assert_eq!(
1330            p.revisions,
1331            vec![
1332                RevisionInfo {
1333                    index: 0,
1334                    start: 0,
1335                    end: rev1_end,
1336                    startxref: None,
1337                    prev: None,
1338                },
1339                RevisionInfo {
1340                    index: 1,
1341                    start: rev1_end + 1,
1342                    end: rev2_end,
1343                    startxref: None,
1344                    prev: None,
1345                },
1346            ]
1347        );
1348        assert_eq!(rev2_end, pdf.len() as u64);
1349        assert_eq!(p.objects.len(), 2);
1350        p.validate(pdf.len() as u64).unwrap();
1351    }
1352
1353    #[test]
1354    fn classic_xref_revision_records_startxref_and_no_prev() {
1355        let pdf = canonical_pdf();
1356        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1357        assert_eq!(p.revisions.len(), 1);
1358        let r = p.revisions[0];
1359        assert_eq!(r.index, 0);
1360        assert_eq!(r.start, 0);
1361        assert_eq!(r.end, pdf.len() as u64);
1362        assert_eq!(r.startxref, Some(321));
1363        assert_eq!(r.prev, None);
1364        p.validate(pdf.len() as u64).unwrap();
1365    }
1366
1367    #[test]
1368    fn incremental_classic_trailer_prev_resolves_to_first_xref() {
1369        let rev1 = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\nxref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n9\n%%EOF\n";
1370        let x1 = rev1
1371            .windows(4)
1372            .position(|w| w == b"xref")
1373            .expect("xref present") as u64;
1374        let rev2 = format!(
1375            "2 0 obj\n<< /Type /Pages >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000042 00000 n \ntrailer\n<< /Size 3 /Prev {x1} /Root 1 0 R >>\nstartxref\n777\n%%EOF"
1376        );
1377        let mut pdf = Vec::new();
1378        pdf.extend_from_slice(rev1);
1379        pdf.extend_from_slice(rev2.as_bytes());
1380
1381        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1382        assert_eq!(p.revisions.len(), 2);
1383        assert_eq!(p.revisions[0].index, 0);
1384        assert_eq!(p.revisions[0].startxref, Some(9));
1385        assert_eq!(p.revisions[0].prev, None);
1386        assert_eq!(p.revisions[1].index, 1);
1387        assert_eq!(p.revisions[1].startxref, Some(777));
1388        assert_eq!(p.revisions[1].prev, Some(x1));
1389        p.validate(pdf.len() as u64).unwrap();
1390    }
1391
1392    #[test]
1393    fn xref_stream_anchor_prev_reference_is_resolved() {
1394        let rev1 = b"%PDF-1.5\n1 0 obj\n<< >>\nendobj\nstartxref\n0\n%%EOF\n";
1395        let rev2 = b"2 0 obj\n<< /Type /XRef /Prev 3 0 R >>\nendobj\n3 0 obj\n<< >>\nendobj\nstartxref\n0\n%%EOF";
1396        let mut pdf = Vec::new();
1397        pdf.extend_from_slice(rev1);
1398        pdf.extend_from_slice(rev2);
1399        let obj3_start = pdf
1400            .windows(8)
1401            .position(|w| w == b"3 0 obj\n")
1402            .expect("object 3 present") as u64;
1403
1404        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1405        assert_eq!(p.revisions.len(), 2);
1406        assert_eq!(p.revisions[0].prev, None);
1407        assert_eq!(p.revisions[1].prev, Some(obj3_start));
1408        assert_eq!(
1409            p.objects.iter().find(|o| o.number == 2).unwrap().role,
1410            ObjRole::XRefStream
1411        );
1412        p.validate(pdf.len() as u64).unwrap();
1413    }
1414
1415    #[test]
1416    fn object_roles_are_classified_from_leading_dict() {
1417        let pdf = b"%PDF-1.5\n1 0 obj\n<< /Type /Catalog >>\nendobj\n2 0 obj\n<< /Type /XRef >>\nendobj\n3 0 obj\n<< /Type /ObjStm /N 0 >>\nendobj\n4 0 obj\n<< /Foo /Bar >>\nendobj\n5 0 obj\n<< /Type 5 >>\nendobj\n6 0 obj\n42\nendobj\n7 0 obj\n<< /Foo ( /Type /XRef ) >>\nendobj\n".to_vec();
1418        let p = scan(&pdf, Limits::DEFAULT).unwrap();
1419        let role = |n: u64| p.objects.iter().find(|o| o.number == n).unwrap().role;
1420        assert_eq!(role(1), ObjRole::Generic);
1421        assert_eq!(role(2), ObjRole::XRefStream);
1422        assert_eq!(role(3), ObjRole::ObjectStream);
1423        assert_eq!(role(4), ObjRole::Generic);
1424        assert_eq!(role(5), ObjRole::Generic);
1425        assert_eq!(role(6), ObjRole::Generic);
1426        assert_eq!(role(7), ObjRole::Generic);
1427        p.validate(pdf.len() as u64).unwrap();
1428    }
1429
1430    #[test]
1431    fn random_inputs_keep_streams_and_revisions_consistent() {
1432        let mut state: u64 = 0xdead_beef_cafe_f00d;
1433        for _ in 0..500 {
1434            let len = (xorshift64(&mut state) % 128) as usize;
1435            let mut buf = Vec::with_capacity(len);
1436            for _ in 0..len {
1437                buf.push((xorshift64(&mut state) & 0xff) as u8);
1438            }
1439            let p = scan(&buf, Limits::DEFAULT).unwrap();
1440            p.validate(buf.len() as u64).unwrap();
1441            for s in &p.streams {
1442                assert!(s.data_start + s.data_len <= buf.len() as u64);
1443            }
1444            for r in &p.revisions {
1445                assert!(r.start <= r.end && r.end <= buf.len() as u64);
1446            }
1447        }
1448    }
1449}