Skip to main content

vole_document/adapter/pdf/
lexer.rs

1//! Byte-authoritative PDF lexical scanner.
2//!
3//! `lex` partitions an input into ordered [`Span`]s such that every byte belongs
4//! to exactly one span. It performs no structural interpretation: keywords,
5//! numbers, references, and object boundaries remain inside `Regular` and
6//! `LiteralString` spans for the Phase 3.2 parser to resolve. In particular a
7//! literal string is a single opaque span, so occurrences of `obj`, `endobj`, or
8//! `stream` *inside* a string can never be mistaken for structure.
9//!
10//! A `stream` keyword followed by an EOL switches to an **opaque payload** span
11//! that runs to the next `endstream` keyword. This is essential: compressed
12//! stream bytes are high-entropy and routinely contain unbalanced string
13//! delimiters; tokenizing them would swallow all later structure. The payload is
14//! emitted as one `Regular` span (its exact bounds are re-derived from `/Length`
15//! by the physical scanner), and `endstream` is emitted as a `Regular` keyword so
16//! the object loop can resume.
17//!
18//! Unterminated constructs are not errors here: they extend to EOF and are
19//! reported as non-fatal [`LexIssue`]s, because a byte cover must still be
20//! produced for hostile or truncated input.
21
22use crate::error::{Error, Result};
23use crate::limits::Limits;
24
25use super::span::{Span, SpanKind, SpanSet};
26
27/// A tolerated lexical anomaly. Coverage is still complete when one is present.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum LexIssue {
30    /// A `(` literal string reached EOF before its matching `)`.
31    UnterminatedLiteralString { at: u64 },
32    /// A `<` hex string reached EOF before its closing `>`.
33    UnterminatedHexString { at: u64 },
34    /// A `%` comment reached EOF without a closing CR or LF.
35    UnterminatedComment { at: u64 },
36}
37
38/// The lexical cover of an input plus any non-fatal issues encountered.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct LexResult {
41    /// The exact byte cover.
42    pub spans: SpanSet,
43    /// Anomalies tolerated while building the cover, in source order.
44    pub issues: Vec<LexIssue>,
45}
46
47/// PDF whitespace: NUL, HT, LF, FF, CR, SP.
48const fn is_whitespace(b: u8) -> bool {
49    matches!(b, 0x00 | 0x09 | 0x0A | 0x0C | 0x0D | 0x20)
50}
51
52/// PDF delimiters, none of which may appear inside a name or regular token.
53const fn is_delimiter(b: u8) -> bool {
54    matches!(
55        b,
56        b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
57    )
58}
59
60/// A regular byte is neither whitespace nor a delimiter.
61const fn is_regular(b: u8) -> bool {
62    !is_whitespace(b) && !is_delimiter(b)
63}
64
65/// Lex `input` under `limits`, returning a complete byte cover.
66///
67/// Every byte of `input` appears in exactly one span, in ascending order. The
68/// cover is validated against the input length before returning, so an internal
69/// scanner bug surfaces as [`crate::ErrorClass::CoverageViolation`] rather than
70/// silent data loss. Exceeding `limits.max_pdf_spans` returns
71/// [`crate::ErrorClass::ResourceLimit`].
72pub fn lex(input: &[u8], limits: Limits) -> Result<LexResult> {
73    let n = input.len();
74    let max = limits.max_pdf_spans;
75    let mut spans: Vec<Span> = Vec::new();
76    let mut issues: Vec<LexIssue> = Vec::new();
77    let mut pos: usize = 0;
78
79    while pos < n {
80        let b = input[pos];
81        if is_whitespace(b) {
82            let start = pos;
83            while pos < n && is_whitespace(input[pos]) {
84                pos += 1;
85            }
86            push(&mut spans, start, pos - start, SpanKind::Whitespace, max)?;
87        } else if b == b'%' {
88            let start = pos;
89            pos += 1;
90            while pos < n && input[pos] != b'\r' && input[pos] != b'\n' {
91                pos += 1;
92            }
93            if pos >= n {
94                issues.push(LexIssue::UnterminatedComment { at: start as u64 });
95            }
96            push(&mut spans, start, pos - start, SpanKind::Comment, max)?;
97        } else if b == b'(' {
98            let start = pos;
99            pos = scan_literal_string(input, start, &mut issues);
100            push(&mut spans, start, pos - start, SpanKind::LiteralString, max)?;
101        } else if b == b'<' {
102            if pos + 1 < n && input[pos + 1] == b'<' {
103                push(&mut spans, pos, 2, SpanKind::DictOpen, max)?;
104                pos += 2;
105            } else {
106                let start = pos;
107                pos += 1;
108                while pos < n && input[pos] != b'>' {
109                    pos += 1;
110                }
111                if pos < n {
112                    pos += 1; // include the closing '>'
113                } else {
114                    issues.push(LexIssue::UnterminatedHexString { at: start as u64 });
115                }
116                push(&mut spans, start, pos - start, SpanKind::HexString, max)?;
117            }
118        } else if b == b'>' {
119            if pos + 1 < n && input[pos + 1] == b'>' {
120                push(&mut spans, pos, 2, SpanKind::DictClose, max)?;
121                pos += 2;
122            } else {
123                // A lone '>' is a delimiter with no lexical role; keep it as a
124                // single-byte token so the cover stays complete.
125                push(&mut spans, pos, 1, SpanKind::Regular, max)?;
126                pos += 1;
127            }
128        } else if b == b'[' {
129            push(&mut spans, pos, 1, SpanKind::ArrayOpen, max)?;
130            pos += 1;
131        } else if b == b']' {
132            push(&mut spans, pos, 1, SpanKind::ArrayClose, max)?;
133            pos += 1;
134        } else if b == b'{' {
135            push(&mut spans, pos, 1, SpanKind::BraceOpen, max)?;
136            pos += 1;
137        } else if b == b'}' {
138            push(&mut spans, pos, 1, SpanKind::BraceClose, max)?;
139            pos += 1;
140        } else if b == b'/' {
141            let start = pos;
142            pos += 1;
143            while pos < n && is_regular(input[pos]) {
144                pos += 1;
145            }
146            push(&mut spans, start, pos - start, SpanKind::Name, max)?;
147        } else if b == b')' {
148            // Unmatched ')' cannot open a literal string; keep it as a
149            // single-byte token to preserve coverage.
150            push(&mut spans, pos, 1, SpanKind::Regular, max)?;
151            pos += 1;
152        } else {
153            let start = pos;
154            while pos < n && is_regular(input[pos]) {
155                pos += 1;
156            }
157            push(&mut spans, start, pos - start, SpanKind::Regular, max)?;
158            // A `stream` keyword followed by an EOL introduces an opaque payload.
159            if input[start..pos] == *b"stream"
160                && let Some(eol_len) = post_stream_eol_len(input, pos)
161            {
162                let eol_start = pos;
163                pos += eol_len;
164                push(&mut spans, eol_start, eol_len, SpanKind::Whitespace, max)?;
165                let data_start = pos;
166                match find_endstream(input, data_start) {
167                    Some(es) => {
168                        if es > data_start {
169                            push(
170                                &mut spans,
171                                data_start,
172                                es - data_start,
173                                SpanKind::Regular,
174                                max,
175                            )?;
176                        }
177                        push(&mut spans, es, b"endstream".len(), SpanKind::Regular, max)?;
178                        pos = es + b"endstream".len();
179                    }
180                    None => {
181                        // No terminating keyword: the rest is one opaque span.
182                        if data_start < n {
183                            push(
184                                &mut spans,
185                                data_start,
186                                n - data_start,
187                                SpanKind::Regular,
188                                max,
189                            )?;
190                        }
191                        pos = n;
192                    }
193                }
194            }
195        }
196    }
197
198    let spans = SpanSet { spans };
199    spans.validate(n as u64)?;
200    Ok(LexResult { spans, issues })
201}
202
203/// Length of the EOL directly after a `stream` keyword: `LF` (1) or `CRLF` (2).
204/// A lone `CR` is not a valid stream EOL, matching the physical scanner.
205fn post_stream_eol_len(input: &[u8], pos: usize) -> Option<usize> {
206    match input.get(pos) {
207        Some(b'\n') => Some(1),
208        Some(b'\r') if input.get(pos + 1) == Some(&b'\n') => Some(2),
209        _ => None,
210    }
211}
212
213/// First offset of the `endstream` keyword at or after `from`, or `None` if there
214/// is none.
215///
216/// A keyword is terminated on the right, so this requires the byte immediately
217/// after `endstream` to be PDF whitespace, a PDF delimiter, or EOF; the byte
218/// *before* may be anything (it is the last payload byte). Requiring a preceding
219/// EOL is wrong for real producers: Ghostscript 10.00.0 (and others) emit the
220/// stream payload immediately followed by `endstream` with no intervening EOL,
221/// so an EOL-preceded search over-reads the payload to a *later* `endstream` and
222/// corrupts all subsequent structure.
223///
224/// The false-positive risk is low: payload bytes would have to contain the
225/// literal 10-byte run `endstream` followed by a delimiter or whitespace, and the
226/// physical scanner additionally re-derives the exact bounds from `/Length` when
227/// one is present. The scan is bounded by `input.len()` and returns the first
228/// such occurrence.
229///
230/// Two implementations satisfy this contract and are proven to agree for every
231/// `(input, from)` by the differential test in this module: a scalar reference
232/// scan ([`find_endstream_scalar`]) and, with the `memmem-scan` feature, a scan
233/// over a reused [`memchr::memmem::Finder`] (SIMD prefilter + two-way). The
234/// scalar loop advances one byte at a time, but `endstream` has no self-overlap
235/// (no proper prefix equals a proper suffix), so no valid match can begin inside
236/// another; `Finder::find_iter`'s non-overlapping matches therefore visit exactly
237/// the same candidate start offsets with the same right-termination rule.
238#[cfg(feature = "memmem-scan")]
239fn find_endstream(input: &[u8], from: usize) -> Option<usize> {
240    use std::sync::OnceLock;
241
242    const NEEDLE: &[u8] = b"endstream";
243    // `Finder` is `Sync` and its prefilter build is amortized across every stream
244    // in a process; the free function `memmem::find` would rebuild it per call.
245    static FINDER: OnceLock<memchr::memmem::Finder<'static>> = OnceLock::new();
246    let finder = FINDER.get_or_init(|| memchr::memmem::Finder::new(NEEDLE));
247
248    let start = from.min(input.len());
249    finder.find_iter(&input[start..]).find_map(|rel| {
250        let i = start + rel;
251        match input.get(i + NEEDLE.len()) {
252            None => Some(i),
253            Some(&b) if is_whitespace(b) || is_delimiter(b) => Some(i),
254            _ => None,
255        }
256    })
257}
258
259/// Scalar reference scan for [`find_endstream`] (same contract). Compiled as the
260/// `--no-default-features` fallback and always under `cfg(test)`, so the
261/// differential test can compare it against the SIMD scan.
262#[cfg(any(test, not(feature = "memmem-scan")))]
263fn find_endstream_scalar(input: &[u8], from: usize) -> Option<usize> {
264    let needle = b"endstream";
265    let mut i = from;
266    while i + needle.len() <= input.len() {
267        if &input[i..i + needle.len()] == needle {
268            let terminated = match input.get(i + needle.len()) {
269                None => true,
270                Some(&b) => is_whitespace(b) || is_delimiter(b),
271            };
272            if terminated {
273                return Some(i);
274            }
275        }
276        i += 1;
277    }
278    None
279}
280
281/// Without `memmem-scan`, `find_endstream` is the scalar reference scan.
282#[cfg(not(feature = "memmem-scan"))]
283fn find_endstream(input: &[u8], from: usize) -> Option<usize> {
284    find_endstream_scalar(input, from)
285}
286
287/// Consume a `(` literal string starting at `start`; returns the first offset
288/// after the span. `\` escapes the next byte, and `\` before a CR, LF, or CRLF
289/// is a line continuation that also swallows the EOL.
290fn scan_literal_string(input: &[u8], start: usize, issues: &mut Vec<LexIssue>) -> usize {
291    let n = input.len();
292    let mut pos = start;
293    let mut depth: u64 = 0;
294    loop {
295        if pos >= n {
296            issues.push(LexIssue::UnterminatedLiteralString { at: start as u64 });
297            return pos;
298        }
299        let c = input[pos];
300        if c == b'\\' {
301            pos += 1;
302            if pos < n {
303                let escaped = input[pos];
304                pos += 1;
305                if escaped == b'\r' && pos < n && input[pos] == b'\n' {
306                    pos += 1;
307                }
308            }
309        } else if c == b'(' {
310            depth = depth.saturating_add(1);
311            pos += 1;
312        } else if c == b')' {
313            depth = depth.saturating_sub(1);
314            pos += 1;
315            if depth == 0 {
316                return pos;
317            }
318        } else {
319            pos += 1;
320        }
321    }
322}
323
324/// Append a span, enforcing the span-count bound.
325fn push(spans: &mut Vec<Span>, start: usize, len: usize, kind: SpanKind, max: u32) -> Result<()> {
326    if spans.len() as u64 >= max as u64 {
327        return Err(Error::resource_limit(format!(
328            "pdf span count exceeds limit {max}"
329        )));
330    }
331    spans.push(Span {
332        start: start as u64,
333        len: len as u64,
334        kind,
335    });
336    Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::error::ErrorClass;
343
344    fn run(input: &[u8]) -> LexResult {
345        lex(input, Limits::DEFAULT).expect("lex must succeed")
346    }
347
348    fn kinds(r: &LexResult) -> Vec<SpanKind> {
349        r.spans.spans.iter().map(|s| s.kind).collect()
350    }
351
352    #[test]
353    fn empty_input() {
354        let r = run(b"");
355        assert!(r.spans.spans.is_empty());
356        assert!(r.issues.is_empty());
357        assert!(r.spans.validate(0).is_ok());
358    }
359
360    #[test]
361    fn single_whitespace() {
362        let r = run(b" ");
363        assert_eq!(r.spans.spans.len(), 1);
364        assert_eq!(r.spans.spans[0].kind, SpanKind::Whitespace);
365        assert_eq!(r.spans.spans[0].len, 1);
366    }
367
368    #[test]
369    fn whitespace_run_coalesces() {
370        let input = b" \t\r\n\x0c\x00 ";
371        let r = run(input);
372        assert_eq!(r.spans.spans.len(), 1);
373        assert_eq!(r.spans.spans[0].kind, SpanKind::Whitespace);
374        assert_eq!(r.spans.spans[0].len, input.len() as u64);
375    }
376
377    #[test]
378    fn comment_to_eol_excludes_eol() {
379        let r = run(b"%hello\n");
380        assert_eq!(kinds(&r), vec![SpanKind::Comment, SpanKind::Whitespace]);
381        assert_eq!(r.spans.spans[0].start, 0);
382        assert_eq!(r.spans.spans[0].len, 6);
383        assert_eq!(r.spans.spans[1].start, 6);
384        assert_eq!(r.spans.spans[1].len, 1);
385        assert!(r.issues.is_empty());
386    }
387
388    #[test]
389    fn comment_stops_before_cr() {
390        let r = run(b"%x\r\n");
391        assert_eq!(kinds(&r), vec![SpanKind::Comment, SpanKind::Whitespace]);
392        assert_eq!(r.spans.spans[0].len, 2);
393        assert_eq!(r.spans.spans[1].len, 2);
394        assert!(r.issues.is_empty());
395    }
396
397    #[test]
398    fn comment_to_eof_is_issue() {
399        let r = run(b"%abc");
400        assert_eq!(kinds(&r), vec![SpanKind::Comment]);
401        assert_eq!(r.spans.spans[0].len, 4);
402        assert_eq!(r.issues, vec![LexIssue::UnterminatedComment { at: 0 }]);
403    }
404
405    #[test]
406    fn literal_string_with_escape() {
407        // ( a \ ) b )  -- the escaped ')' does not close the string.
408        let r = run(b"(a\\)b)");
409        assert_eq!(kinds(&r), vec![SpanKind::LiteralString]);
410        assert_eq!(r.spans.spans[0].len, 6);
411        assert!(r.issues.is_empty());
412    }
413
414    #[test]
415    fn literal_string_with_line_continuation() {
416        // ( a \ CR LF b ) -- backslash before CRLF swallows the EOL.
417        let r = run(b"(a\\\r\nb)");
418        assert_eq!(kinds(&r), vec![SpanKind::LiteralString]);
419        assert_eq!(r.spans.spans[0].len, 7);
420        assert!(r.issues.is_empty());
421    }
422
423    #[test]
424    fn literal_string_with_nested_parens() {
425        let r = run(b"(a(b)c)");
426        assert_eq!(kinds(&r), vec![SpanKind::LiteralString]);
427        assert_eq!(r.spans.spans[0].len, 7);
428        assert!(r.issues.is_empty());
429    }
430
431    #[test]
432    fn unterminated_literal_string_is_issue() {
433        let r = run(b"(abc");
434        assert_eq!(kinds(&r), vec![SpanKind::LiteralString]);
435        assert_eq!(r.spans.spans[0].len, 4);
436        assert_eq!(
437            r.issues,
438            vec![LexIssue::UnterminatedLiteralString { at: 0 }]
439        );
440    }
441
442    #[test]
443    fn percent_inside_literal_string_is_not_a_comment() {
444        let r = run(b"( % )");
445        assert_eq!(kinds(&r), vec![SpanKind::LiteralString]);
446        assert_eq!(r.spans.spans[0].len, 5);
447        assert!(r.issues.is_empty());
448    }
449
450    #[test]
451    fn paren_inside_hex_string_is_not_special() {
452        let r = run(b"<4(2>");
453        assert_eq!(kinds(&r), vec![SpanKind::HexString]);
454        assert_eq!(r.spans.spans[0].len, 5);
455        assert!(r.issues.is_empty());
456    }
457
458    #[test]
459    fn hex_string_basic() {
460        let r = run(b"<4142>");
461        assert_eq!(kinds(&r), vec![SpanKind::HexString]);
462        assert_eq!(r.spans.spans[0].len, 6);
463    }
464
465    #[test]
466    fn unterminated_hex_string_is_issue() {
467        let r = run(b"<41");
468        assert_eq!(kinds(&r), vec![SpanKind::HexString]);
469        assert_eq!(r.spans.spans[0].len, 3);
470        assert_eq!(r.issues, vec![LexIssue::UnterminatedHexString { at: 0 }]);
471    }
472
473    #[test]
474    fn dict_delimiters() {
475        let r = run(b"<<>>");
476        assert_eq!(kinds(&r), vec![SpanKind::DictOpen, SpanKind::DictClose]);
477        assert_eq!(r.spans.spans[0].len, 2);
478        assert_eq!(r.spans.spans[1].len, 2);
479    }
480
481    #[test]
482    fn lone_closers_are_regular_tokens() {
483        let r = run(b")>");
484        assert_eq!(kinds(&r), vec![SpanKind::Regular, SpanKind::Regular]);
485        let r = run(b">>>");
486        assert_eq!(kinds(&r), vec![SpanKind::DictClose, SpanKind::Regular]);
487    }
488
489    #[test]
490    fn name_and_empty_name() {
491        let r = run(b"/Name");
492        assert_eq!(kinds(&r), vec![SpanKind::Name]);
493        assert_eq!(r.spans.spans[0].len, 5);
494        let r = run(b"/");
495        assert_eq!(kinds(&r), vec![SpanKind::Name]);
496        assert_eq!(r.spans.spans[0].len, 1);
497    }
498
499    #[test]
500    fn arrays_and_braces() {
501        let r = run(b"[]{}");
502        assert_eq!(
503            kinds(&r),
504            vec![
505                SpanKind::ArrayOpen,
506                SpanKind::ArrayClose,
507                SpanKind::BraceOpen,
508                SpanKind::BraceClose,
509            ]
510        );
511    }
512
513    #[test]
514    fn numbers_and_reference_are_regular_tokens() {
515        let r = run(b"12 0 R");
516        assert_eq!(
517            kinds(&r),
518            vec![
519                SpanKind::Regular,
520                SpanKind::Whitespace,
521                SpanKind::Regular,
522                SpanKind::Whitespace,
523                SpanKind::Regular,
524            ]
525        );
526        let text: Vec<&[u8]> = r
527            .spans
528            .spans
529            .iter()
530            .map(|s| &b"12 0 R"[s.start as usize..(s.start + s.len) as usize])
531            .collect();
532        assert_eq!(text, vec![&b"12"[..], b" ", b"0", b" ", b"R"]);
533    }
534
535    #[test]
536    fn endobj_inside_literal_string_stays_one_span() {
537        let r = run(b"(1 0 obj endobj)5");
538        assert_eq!(kinds(&r), vec![SpanKind::LiteralString, SpanKind::Regular]);
539        assert_eq!(r.spans.spans[0].start, 0);
540        assert_eq!(r.spans.spans[0].len, 16);
541        assert_eq!(r.spans.spans[1].start, 16);
542        assert_eq!(r.spans.spans[1].len, 1);
543    }
544
545    #[test]
546    fn stream_payload_without_trailing_eol_is_one_opaque_span() {
547        // Real producers (e.g. Ghostscript 10.00.0) write the payload directly
548        // before `endstream` with no intervening EOL. The payload here also
549        // contains an unbalanced `(` and `endstream`/`stream`-like runs that must
550        // not be mistaken for the terminating keyword.
551        let payload = b"(unbalanced ( with endstreamZ and streamY bytes";
552        let mut input = Vec::new();
553        input.extend_from_slice(b"stream\n");
554        input.extend_from_slice(payload);
555        input.extend_from_slice(b"endstream\n");
556
557        let r = run(&input);
558        assert_eq!(
559            kinds(&r),
560            vec![
561                SpanKind::Regular,    // stream
562                SpanKind::Whitespace, // \n
563                SpanKind::Regular,    // opaque payload
564                SpanKind::Regular,    // endstream
565                SpanKind::Whitespace, // \n
566            ]
567        );
568        let p = &r.spans.spans[2];
569        assert_eq!(
570            &input[p.start as usize..(p.start + p.len) as usize],
571            payload
572        );
573        let es = &r.spans.spans[3];
574        assert_eq!(
575            &input[es.start as usize..(es.start + es.len) as usize],
576            b"endstream"
577        );
578        assert!(r.issues.is_empty());
579        r.spans.validate(input.len() as u64).unwrap();
580    }
581
582    #[test]
583    fn stream_payload_followed_by_eol_then_endstream_still_works() {
584        let input = b"stream\nhello\nendstream\n";
585        let r = run(input);
586        assert_eq!(
587            kinds(&r),
588            vec![
589                SpanKind::Regular,    // stream
590                SpanKind::Whitespace, // \n
591                SpanKind::Regular,    // opaque payload (hello + trailing EOL)
592                SpanKind::Regular,    // endstream
593                SpanKind::Whitespace, // \n
594            ]
595        );
596        let p = &r.spans.spans[2];
597        assert_eq!(
598            &input[p.start as usize..(p.start + p.len) as usize],
599            b"hello\n"
600        );
601        let es = &r.spans.spans[3];
602        assert_eq!(
603            &input[es.start as usize..(es.start + es.len) as usize],
604            b"endstream"
605        );
606        assert!(r.issues.is_empty());
607        r.spans.validate(input.len() as u64).unwrap();
608    }
609
610    #[test]
611    fn endstream_at_eof_terminates_payload() {
612        let input = b"stream\npayloadendstream";
613        let r = run(input);
614        assert_eq!(
615            kinds(&r),
616            vec![
617                SpanKind::Regular,    // stream
618                SpanKind::Whitespace, // \n
619                SpanKind::Regular,    // payload
620                SpanKind::Regular,    // endstream (EOF-terminated)
621            ]
622        );
623        let es = &r.spans.spans[3];
624        assert_eq!(
625            &input[es.start as usize..(es.start + es.len) as usize],
626            b"endstream"
627        );
628        assert_eq!(es.start + es.len, input.len() as u64);
629        assert!(r.issues.is_empty());
630        r.spans.validate(input.len() as u64).unwrap();
631    }
632
633    #[test]
634    fn endstream_like_run_without_right_terminator_is_not_a_keyword() {
635        // `endstreamZ` (regular byte after) must not terminate the payload, so the
636        // real `endstream\n` is the first recognised keyword.
637        let input = b"stream\nxx endstreamZ yyendstream\n";
638        let r = run(input);
639        assert_eq!(
640            kinds(&r),
641            vec![
642                SpanKind::Regular,    // stream
643                SpanKind::Whitespace, // \n
644                SpanKind::Regular,    // opaque payload
645                SpanKind::Regular,    // endstream
646                SpanKind::Whitespace, // \n
647            ]
648        );
649        let p = &r.spans.spans[2];
650        assert_eq!(
651            &input[p.start as usize..(p.start + p.len) as usize],
652            b"xx endstreamZ yy"
653        );
654        r.spans.validate(input.len() as u64).unwrap();
655    }
656
657    #[test]
658    fn span_at_over_lexed_input() {
659        let r = run(b"12 0 R");
660        assert_eq!(r.spans.span_at(0).map(|s| s.kind), Some(SpanKind::Regular));
661        assert_eq!(r.spans.span_at(1).map(|s| s.kind), Some(SpanKind::Regular));
662        assert_eq!(
663            r.spans.span_at(2).map(|s| s.kind),
664            Some(SpanKind::Whitespace)
665        );
666        assert_eq!(r.spans.span_at(3).map(|s| s.kind), Some(SpanKind::Regular));
667        assert_eq!(
668            r.spans.span_at(4).map(|s| s.kind),
669            Some(SpanKind::Whitespace)
670        );
671        assert_eq!(r.spans.span_at(5).map(|s| s.kind), Some(SpanKind::Regular));
672        assert_eq!(r.spans.span_at(6), None);
673    }
674
675    #[test]
676    fn span_limit_triggers_resource_limit() {
677        let limits = Limits {
678            max_pdf_spans: 2,
679            ..Limits::DEFAULT
680        };
681        let e = lex(b"a b c", limits).unwrap_err();
682        assert_eq!(e.class(), ErrorClass::ResourceLimit);
683    }
684
685    #[test]
686    fn cover_invariant_holds_for_a_battery() {
687        let inputs: [&[u8]; 15] = [
688            b"",
689            b" ",
690            b"%%EOF",
691            b"<< /Type /Catalog >>",
692            b"[1 2.5 -3 (str) <4142> /Name]",
693            b"(unterminated",
694            b"<414243",
695            b"%comment with ( and < and >>",
696            b"()<>[]{}",
697            b"\x00\x09\x0a\x0c\x0d\x20mixed",
698            b"trailing>",
699            b")))",
700            b"<<<<<<",
701            b"(nested (deep (deeper)) end)",
702            b"1 0 obj\n<< /A (x) >>\nendobj",
703        ];
704        for input in inputs {
705            let r = lex(input, Limits::DEFAULT).expect("lex must succeed");
706            r.spans
707                .validate(input.len() as u64)
708                .expect("cover must validate");
709        }
710    }
711
712    fn xorshift64(state: &mut u64) -> u64 {
713        let mut x = *state;
714        x ^= x << 13;
715        x ^= x >> 7;
716        x ^= x << 17;
717        *state = x;
718        x
719    }
720
721    #[test]
722    fn cover_invariant_holds_for_random_bytes() {
723        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
724        for _ in 0..500 {
725            let len = (xorshift64(&mut state) % 300) as usize;
726            let mut buf = Vec::with_capacity(len);
727            for _ in 0..len {
728                buf.push((xorshift64(&mut state) & 0xFF) as u8);
729            }
730            let r = lex(&buf, Limits::STRICT).expect("lex must not fail on bounded input");
731            r.spans
732                .validate(buf.len() as u64)
733                .expect("random cover must validate");
734            assert!(r.spans.spans.len() <= buf.len());
735        }
736    }
737
738    /// The SIMD (`memmem`) scan must return exactly the same `endstream` offset as
739    /// the scalar reference for every `from`, including offsets past the input and
740    /// matches at EOF. A mismatch would change the physical span cover, so this is
741    /// the representation-safety witness for the Part-A change.
742    #[cfg(feature = "memmem-scan")]
743    #[test]
744    fn memmem_find_endstream_matches_scalar_over_a_battery() {
745        let inputs: [&[u8]; 13] = [
746            b"",
747            b"endstream",
748            b"endstreamX",
749            b"endstream ",
750            b"endstream\r\n",
751            b"xendstream",
752            b"payload endstream)) ",
753            b"endstreamendstream",
754            b"endstreamen",
755            b"end\x00stream",
756            b"aaaaendstream/",
757            b"endstream\nendstream\r\n",
758            b"stream\nendstream\nendobj",
759        ];
760        for input in inputs {
761            for from in 0..=input.len() + 2 {
762                assert_eq!(
763                    find_endstream(input, from),
764                    find_endstream_scalar(input, from),
765                    "mismatch for from={from} input={input:?}"
766                );
767            }
768        }
769    }
770
771    /// Randomized differential over token soup that is dense in `endstream`
772    /// occurrences, terminators, and near-miss prefixes.
773    #[cfg(feature = "memmem-scan")]
774    #[test]
775    fn memmem_find_endstream_matches_scalar_random() {
776        let toks: [&[u8]; 10] = [
777            b"endstream",
778            b"end",
779            b"stream",
780            b"endstreamx",
781            b"x",
782            b" ",
783            b"/",
784            b"\n",
785            b"\r",
786            b"e",
787        ];
788        let mut state: u64 = 0xDEAD_BEEF_1234_5678;
789        for _ in 0..2000 {
790            let n = (xorshift64(&mut state) % 40) as usize;
791            let mut buf: Vec<u8> = Vec::new();
792            for _ in 0..n {
793                buf.extend_from_slice(toks[(xorshift64(&mut state) as usize) % toks.len()]);
794            }
795            for from in 0..=buf.len() {
796                assert_eq!(
797                    find_endstream(&buf, from),
798                    find_endstream_scalar(&buf, from),
799                    "mismatch for from={from} buf={buf:?}"
800                );
801            }
802        }
803    }
804}