Skip to main content

pdfrum_common/
limits.rs

1//! Hard resource limits, defaulting to PDFium-equivalent values.
2//!
3//! Every field is a cap **some reader consults**, and the defaults are the
4//! C++ constants.
5//!
6//! A knob nothing reads is not a limit, it is a promise the type cannot keep,
7//! so the rule here is that a field earns its place by having a caller. Where
8//! PDFium has no cap and we chose to add one anyway the field says so and
9//! names what it bounds (`max_decoded_stream_len`, `max_cmap_ranges`,
10//! `max_name_tree_depth`, the script budgets); where PDFium has no cap and we
11//! enforce none either, there is no field.
12//!
13//! Two fields are **off by default** rather than PDFium-equivalent, because
14//! they are a host's ceiling on untrusted input and not a parser's: the render
15//! pixel cap and the deadline. Both answer with a [`LimitExceeded`] rather
16//! than a diagnostic wherever the entry point can fail — a caller who set a
17//! ceiling wants to hear that it was hit, not a result with a hole in it —
18//! and the infallible entry points record [`DiagKind::TimeLimitReached`](crate::DiagKind::TimeLimitReached)
19//! beside the partial result they return.
20
21use std::fmt;
22use std::time::Duration;
23
24use crate::PageIndex;
25use crate::deadline::{Deadline, Operation};
26
27/// Caps applied while reading a document. Plain configuration data: pass it
28/// down, never store it in a parser struct that also owns state.
29///
30/// Which operations honour which cap: the parser's caps (nesting, array
31/// length, xref size, object numbers, the scans, word length, the page tree,
32/// stream length) are read while opening and while fetching objects; the
33/// CMap and name-tree caps while loading fonts and document-level trees; the
34/// script budgets by the script engine alone. [`Limits::max_render_pixels`]
35/// is read by the facade's render entry points, before a target is allocated.
36/// [`Limits::deadline`] is read at every boundary listed on that field.
37///
38/// ```
39/// use pdfrum_common::Limits;
40///
41/// // PDFium-equivalent defaults, with one cap tightened for a fuzz budget.
42/// let limits = Limits { max_array_len: 1 << 20, ..Limits::default() };
43/// assert_eq!(limits.max_object_nesting, 64);
44/// assert_eq!(limits.max_object_number, 25_165_824);
45/// ```
46// Deliberately *not* `#[non_exhaustive]`: makes struct-update
47// syntax over `Default` the way callers configure options, and the attribute
48// forbids exactly that outside this crate. New fields are additive here.
49//
50// `Clone` and not `Copy`: a [`Deadline`] shares a stop
51// flag between its clones, and a `Copy` of an `Arc` is not a thing. Every
52// reader takes `&Limits`; the few owners clone once.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Limits {
55    /// Maximum depth of nested arrays/dictionaries accepted while parsing an
56    /// object body. Enforced at parse time so access code may recurse freely.
57    pub max_object_nesting: u32,
58    /// Maximum number of elements in one array. PDFium has no such cap; ours
59    /// is consulted by the object parser (`pdfrum_parser::syntax`), the
60    /// `ToUnicode` CMap reader and the Type 1 charstring decoder.
61    pub max_array_len: usize,
62    /// Maximum number of entries a cross-reference section may declare;
63    /// one past the largest legal object number.
64    pub max_xref_size: u32,
65    /// Largest legal object number. PDFium: `kMaxObjectNumber` (24·2²⁰).
66    pub max_object_number: u32,
67    /// How far from the start of the file the `%PDF-` header is searched for.
68    pub header_scan: u64,
69    /// How far back from the end of the file `startxref` is searched for.
70    pub startxref_scan: u64,
71    /// Maximum number of bytes kept from one syntax token (names, keywords).
72    /// Longer tokens are truncated, matching PDFium's word buffer.
73    pub max_word_len: usize,
74    /// Maximum depth of the page tree walk before it gives up.
75    pub max_page_tree_depth: u32,
76    /// Maximum number of pages a document may report.
77    pub max_page_count: u32,
78    /// Maximum number of bytes any single stream filter may produce.
79    ///
80    /// PDFium has *no* cap here: Flate and LZW decode until they stop, and
81    /// only the size it *reports* saturates, at `kMaxTotalOutSize` = 1 GiB
82    /// (`flatemodule.cpp`), silently truncating anything larger. We decline to
83    /// inherit that zip-bomb surface and turn the same ceiling into a hard
84    /// rejection instead; past 1 GiB the oracle's reported size has already
85    /// stopped tracking its content, so no stream that decodes faithfully in
86    /// the C++ changes behavior. `RunLengthDecode` keeps its own, much
87    /// smaller and behaviorally load-bearing 20 MiB cap in `pdfrum-filters`.
88    pub max_decoded_stream_len: usize,
89    /// Maximum number of codespace ranges, and separately of wide-code CID
90    /// ranges, one embedded CMap program may declare.
91    ///
92    /// PDFium has no cap: both lists grow with the program. The default here
93    /// is one range per possible two-byte code, which no real CMap approaches
94    /// and which bounds a hostile program's memory at a few megabytes; past it
95    /// further ranges are dropped with a diagnostic rather than erroring, in
96    /// the same spirit as `max_decoded_stream_len`.
97    pub max_cmap_ranges: usize,
98
99    /// Maximum depth of a name tree, number tree, structure tree, form-field
100    /// `/Parent` walk, field-name trie, or chained `/Next` action.
101    ///
102    /// PDFium caps four of those six at 32 and leaves the number tree and the
103    /// action chain uncapped — both of which are unbounded recursion on a
104    /// cyclic file. One knob covers all six; no real document approaches it,
105    /// and exceeding it answers "not found" with a diagnostic rather than
106    /// erroring.
107    pub max_name_tree_depth: u32,
108
109    // ---- The script engine ----
110    //
111    // The first three map one-for-one onto `boa`'s `RuntimeLimits`; the
112    // fourth is ours. What they do **not** bound is heap growth and regex
113    // backtracking, which no `RuntimeLimits` field covers — and which a
114    // V8-enabled PDFium does not bound either, measured rather than assumed
115    // by a probe run against a V8-enabled build. So pdfrum is bounded where the
116    // oracle hangs, and unbounded only where the oracle is too. A host
117    // running untrusted documents in a shared process applies an external
118    // wall-clock and RSS cap, which is the only thing that works for either.
119    /// How many loop iterations one script may run before it is stopped.
120    ///
121    /// Roughly ten seconds of the tightest possible loop. No real form script
122    /// iterates a thousand times; the number is a ceiling on a hostile file,
123    /// not a budget a legitimate one has to fit inside. Exhausting it is a
124    /// `Diagnostic` and the *refusing* answer from the hook that was running
125    /// — never a hang, never a panic, and never a silent acceptance.
126    ///
127    /// PDFium has no equivalent at all: `while(true){}` under V8 runs until
128    /// the process is killed from outside.
129    pub max_script_loop_iterations: u64,
130    /// How deep one script may recurse. `boa`'s own default.
131    pub max_script_recursion: usize,
132    /// How large one script's value stack may grow. `boa`'s own default.
133    pub max_script_stack: usize,
134    /// How deep a calculation may trigger another calculation.
135    ///
136    /// **One**, because a calculation that runs during another calculation
137    /// is refused rather than counted down: the outer sweep is authoritative
138    /// and every nested call returns immediately. The field makes that depth
139    /// configurable rather than looser.
140    pub max_calculate_depth: u32,
141
142    // ---- A host's ceilings on untrusted input ----
143    /// The most pixels one render may produce: width × height of the target
144    /// under the render transform. `None` — the default — is no cap.
145    ///
146    /// PDFium has none: `pdfium_test --scale` sizes the bitmap and only
147    /// `CFX_DIBitmap`'s pitch overflow refuses it. Read by the facade before
148    /// the target is allocated, so a request above the cap costs nothing;
149    /// exceeding it is [`LimitExceeded::RenderPixels`].
150    pub max_render_pixels: Option<u64>,
151    /// When every operation on the document must have stopped. `None` — the
152    /// default — is no limit.
153    ///
154    /// A [`Deadline`] is a stop flag any thread raises
155    /// ([`Deadline::manual`] and [`Deadline::stop`], the mechanism every
156    /// target has) and, on targets with a clock, a budget that raises it for
157    /// you ([`Deadline::after`]). A host on `wasm32` has only the flag, and
158    /// its own timer.
159    ///
160    /// Honoured cooperatively, at the boundaries the engine already has:
161    /// opening (once on entry, then every 4096 tokens of a cross-reference
162    /// rebuild scan), loading a page, interpreting a content stream (every
163    /// 256 operators), rasterizing (every object), extracting a page's text
164    /// (on entry), and running a script (on entry). The fallible entries —
165    /// open, page load, render — answer [`LimitExceeded::Time`] for a spent
166    /// budget and [`LimitExceeded::Stopped`] for a raised flag; the
167    /// infallible ones — the interpreter, the extractor — stop where they
168    /// are, record [`DiagKind::TimeLimitReached`](crate::DiagKind::TimeLimitReached) and return what they have;
169    /// a script refuses as an exhausted script budget does. PDFium has no
170    /// equivalent: a host wraps the process in a timer.
171    pub deadline: Option<Deadline>,
172}
173
174impl Limits {
175    /// `Ok` unless a deadline is set and has passed — the one call every
176    /// cooperative boundary makes. Costs one branch without a deadline.
177    ///
178    /// # Errors
179    ///
180    /// [`LimitExceeded::Time`] once the deadline has passed.
181    pub fn check_deadline(&self, during: Operation) -> Result<(), LimitExceeded> {
182        match &self.deadline {
183            Some(deadline) => deadline.check(during),
184            None => Ok(()),
185        }
186    }
187}
188
189/// A caller-set ceiling was hit. The error a render or an open answers when
190/// a [`Limits`] field that defaults to *off* was set and exceeded.
191///
192/// Distinct from the parser's own caps, which are damage tolerance and answer
193/// with a diagnostic or a truncated value: these are the host's, and the host
194/// asked to be told. The message names the cap and what would satisfy it.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum LimitExceeded {
198    /// A render target of `width` × `height` pixels has more than
199    /// [`Limits::max_render_pixels`] allows.
200    RenderPixels {
201        /// The target's width in pixels.
202        width: u32,
203        /// The target's height in pixels.
204        height: u32,
205        /// The cap, in pixels.
206        allowed: u64,
207    },
208    /// [`Limits::deadline`] passed while `during` was under way.
209    Time {
210        /// How much time was allowed.
211        budget: Duration,
212        /// What was being done.
213        during: Operation,
214        /// The page it was being done to, where the caller knew one.
215        page: Option<PageIndex>,
216    },
217    /// [`Limits::deadline`] was raised by [`Deadline::stop`] while `during`
218    /// was under way.
219    Stopped {
220        /// What was being done.
221        during: Operation,
222        /// The page it was being done to, where the caller knew one.
223        page: Option<PageIndex>,
224    },
225}
226
227impl LimitExceeded {
228    /// The same error naming `page`, for the caller who knows which page the
229    /// engine below it was working on. Only [`LimitExceeded::Time`] has a
230    /// page; the pixel cap already names its size.
231    #[must_use]
232    pub fn on_page(self, page: PageIndex) -> LimitExceeded {
233        match self {
234            LimitExceeded::Time { budget, during, .. } => LimitExceeded::Time {
235                budget,
236                during,
237                page: Some(page),
238            },
239            LimitExceeded::Stopped { during, .. } => LimitExceeded::Stopped {
240                during,
241                page: Some(page),
242            },
243            other => other,
244        }
245    }
246}
247
248/// A budget as a person reads it: whole seconds as `5 s`, anything finer as
249/// milliseconds.
250fn budget_text(budget: Duration) -> String {
251    if budget.subsec_nanos() == 0 {
252        format!("{} s", budget.as_secs())
253    } else {
254        format!("{} ms", budget.as_millis())
255    }
256}
257
258/// `px` as megapixels with one decimal where it has one: `100`, `1.2`.
259fn megapixels(px: u64) -> String {
260    let whole = px / 1_000_000;
261    let tenths = (px % 1_000_000) / 100_000;
262    if tenths == 0 {
263        whole.to_string()
264    } else {
265        format!("{whole}.{tenths}")
266    }
267}
268
269impl fmt::Display for LimitExceeded {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        match self {
272            LimitExceeded::RenderPixels {
273                width,
274                height,
275                allowed,
276            } => write!(
277                f,
278                "render of {width} x {height} px ({} megapixels) is above the cap of {} \
279                 megapixels; render at a smaller scale or raise `Limits::max_render_pixels`",
280                megapixels(u64::from(*width) * u64::from(*height)),
281                megapixels(*allowed),
282            ),
283            LimitExceeded::Time {
284                budget,
285                during,
286                page,
287            } => write!(
288                f,
289                "time limit of {} exceeded while {}; allow more time or do less",
290                budget_text(*budget),
291                during.describe(*page),
292            ),
293            LimitExceeded::Stopped { during, page } => {
294                write!(f, "stopped by the caller while {}", during.describe(*page))
295            }
296        }
297    }
298}
299
300impl std::error::Error for LimitExceeded {}
301
302impl Default for Limits {
303    fn default() -> Self {
304        Self {
305            max_object_nesting: 64,
306            max_array_len: usize::MAX,
307            max_xref_size: 25_165_825,
308            max_object_number: 25_165_824,
309            header_scan: 1024,
310            startxref_scan: 4096,
311            max_word_len: 256,
312            max_page_tree_depth: 1024,
313            max_page_count: 0x000F_FFFF,
314            max_decoded_stream_len: 1024 * 1024 * 1024,
315            max_cmap_ranges: 65_536,
316            max_name_tree_depth: 32,
317            max_script_loop_iterations: 10_000_000,
318            max_script_recursion: 512,
319            max_script_stack: 10_240,
320            max_calculate_depth: 1,
321            max_render_pixels: None,
322            deadline: None,
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::{Deadline, LimitExceeded, Limits, Operation, PageIndex};
330    use std::time::Duration;
331
332    #[test]
333    fn pdfium_equivalent_defaults() {
334        let l = Limits::default();
335        assert_eq!(l.max_object_nesting, 64);
336        assert_eq!(l.max_xref_size, 25_165_825);
337        assert_eq!(l.max_object_number, 25_165_824);
338        assert_eq!(l.max_xref_size, l.max_object_number + 1);
339        assert_eq!(l.header_scan, 1024);
340        assert_eq!(l.startxref_scan, 4096);
341        assert_eq!(l.max_word_len, 256);
342        assert_eq!(l.max_page_tree_depth, 1024);
343        assert_eq!(l.max_page_count, 1_048_575);
344        assert_eq!(l.max_decoded_stream_len, 1024 * 1024 * 1024);
345        assert_eq!(l.max_cmap_ranges, 65_536);
346        assert_eq!(l.max_name_tree_depth, 32);
347        assert_eq!(l.max_array_len, usize::MAX);
348        assert_eq!(l.max_render_pixels, None, "the host's ceilings are off");
349        assert_eq!(l.deadline, None);
350    }
351
352    #[test]
353    fn no_deadline_is_never_exceeded_and_a_spent_one_always_is() {
354        assert_eq!(Limits::default().check_deadline(Operation::Open), Ok(()));
355        let spent = Limits {
356            deadline: Some(Deadline::after(Duration::ZERO)),
357            ..Limits::default()
358        };
359        assert!(matches!(
360            spent.check_deadline(Operation::Extract),
361            Err(LimitExceeded::Time {
362                during: Operation::Extract,
363                page: None,
364                ..
365            })
366        ));
367    }
368
369    #[test]
370    fn the_time_message_names_the_budget_the_work_and_the_page() {
371        let e = LimitExceeded::Time {
372            budget: Duration::from_secs(5),
373            during: Operation::Render,
374            page: Some(PageIndex::new(3)),
375        };
376        assert_eq!(
377            e.to_string(),
378            "time limit of 5 s exceeded while rendering page 3; allow more time or do less"
379        );
380        let e = LimitExceeded::Time {
381            budget: Duration::from_millis(250),
382            during: Operation::Open,
383            page: None,
384        };
385        assert_eq!(
386            e.to_string(),
387            "time limit of 250 ms exceeded while opening the document; allow more time or do less"
388        );
389        let e = LimitExceeded::Time {
390            budget: Duration::from_secs(1),
391            during: Operation::Interpret,
392            page: None,
393        };
394        assert!(e.to_string().contains("while interpreting page;"));
395        let e = LimitExceeded::Stopped {
396            during: Operation::PageLoad,
397            page: Some(PageIndex::new(7)),
398        };
399        assert_eq!(e.to_string(), "stopped by the caller while loading page 7");
400    }
401
402    #[test]
403    fn a_raised_flag_is_a_stop_and_the_page_is_added_by_the_caller() {
404        let stop = Deadline::manual();
405        let limits = Limits {
406            deadline: Some(stop.clone()),
407            ..Limits::default()
408        };
409        assert_eq!(limits.check_deadline(Operation::Render), Ok(()));
410        stop.stop();
411        assert_eq!(
412            limits
413                .check_deadline(Operation::Render)
414                .map_err(|e| e.on_page(PageIndex::new(1))),
415            Err(LimitExceeded::Stopped {
416                during: Operation::Render,
417                page: Some(PageIndex::new(1)),
418            })
419        );
420    }
421
422    #[test]
423    fn the_pixel_message_names_the_size_the_cap_and_the_remedy() {
424        let e = LimitExceeded::RenderPixels {
425            width: 20_000,
426            height: 20_000,
427            allowed: 100_000_000,
428        };
429        assert_eq!(
430            e.to_string(),
431            "render of 20000 x 20000 px (400 megapixels) is above the cap of 100 megapixels; \
432             render at a smaller scale or raise `Limits::max_render_pixels`"
433        );
434        let e = LimitExceeded::RenderPixels {
435            width: 1_234,
436            height: 1_000,
437            allowed: 1_050_000,
438        };
439        assert!(e.to_string().contains("(1.2 megapixels)"));
440        assert!(e.to_string().contains("cap of 1 megapixels"));
441    }
442
443    #[test]
444    fn struct_update_syntax_keeps_the_rest() {
445        let l = Limits {
446            max_array_len: 8,
447            ..Limits::default()
448        };
449        assert_eq!(l.max_array_len, 8);
450        assert_eq!(l.max_object_nesting, 64);
451    }
452}