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