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}