M2: bound PDF functions and reject invalid calculator operations (#581)

Limit function construction and calculator work, detect path cycles, and handle input-derived integer and stack errors through the existing optional failure path.

Co-authored-by: OpenAI Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 18:53:42 -04:00
1 parent caeec49f5e
commit 04f6b1a634
7 files changed
+556 -49

No files matched your search

+2
View File
@@ -182,6 +182,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
- **2026-10-09 (M2, PDF-function hardening):** Vendored function construction has a shared 64-level/10,000-node limit and path cycle detection; calculator parsing and evaluation have shared depth/work budgets. Calculator integer division, logical shifts, rotations and indices avoid input-driven panics, and fixed-size stack errors propagate. Synthetic tests exercise exact/over limits, shared children and cyclic references, with invalid shading calculations preserving later page content. Limits return the existing optional failure and log their reason when logging is enabled. Milestone/overall effort estimates remain unchanged (about 30–35%).
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
+189
View File
@@ -1178,6 +1178,195 @@ mod tests {
}
}
/// Public-API regressions run in normal workspace CI; dependency unit tests do not.
fn function_limits_calculator_eval(program: &str) -> Option<Vec<f32>> {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{FromBytes, Object};
let data = format!("<< /FunctionType 4 /Domain [] /Length {} >> stream\n{program}\nendstream", program.len());
let object = Object::from_bytes(data.as_bytes()).unwrap();
Function::new(&object)?.eval(Default::default()).map(|values| values.to_vec())
}
#[test]
fn function_limits_idiv_zero_is_refused() {
assert!(function_limits_calculator_eval("{ 1 0 idiv }").is_none());
}
#[test]
fn function_limits_idiv_overflow_is_refused() {
assert!(function_limits_calculator_eval("{ -2147483648 -1 idiv }").is_none());
}
#[test]
fn function_limits_large_logical_shifts_discard_all_bits() {
for shift in [32, -32, 2147483647, -2147483648] {
assert_eq!(function_limits_calculator_eval(&format!("{{ 7 {shift} bitshift }}")), Some(vec![0.0]));
}
assert_eq!(function_limits_calculator_eval("{ 1073741824 1 bitshift }"), Some(vec![-2147483648.0]));
assert_eq!(function_limits_calculator_eval("{ -2147483648 -1 bitshift }"), Some(vec![1073741824.0]));
}
#[test]
fn function_limits_rotation_accepts_the_most_negative_integer() {
assert_eq!(function_limits_calculator_eval("{ 1 2 3 3 -2147483648 roll }"), Some(vec![3.0, 1.0, 2.0]));
}
#[test]
fn function_limits_large_indices_are_refused() {
assert!(function_limits_calculator_eval("{ 1 4294967295 index }").is_none());
}
#[test]
fn function_limits_operand_stack_boundary() {
assert_eq!(function_limits_calculator_eval(&format!("{{ 1 {} }}", "dup ".repeat(63))).unwrap().len(), 64);
assert!(function_limits_calculator_eval(&format!("{{ 1 {} }}", "dup ".repeat(64))).is_none());
}
#[test]
fn function_limits_calculator_arithmetic_and_depth() {
for (program, expected) in [
("{ -5 2 idiv }", vec![-2.0]),
("{ 7 3 bitshift }", vec![56.0]),
("{ 142 -3 bitshift }", vec![17.0]),
("{ 1 2 3 3 -1 roll }", vec![2.0, 3.0, 1.0]),
] {
assert_eq!(function_limits_calculator_eval(program), Some(expected));
}
let mut program = "{ 0 }".to_owned();
for _ in 1..64 {
program = format!("{{ true {program} if }}");
}
assert_eq!(function_limits_calculator_eval(&program), Some(vec![0.0]));
assert!(function_limits_calculator_eval(&format!("{{ true {program} if }}")).is_none());
}
#[test]
fn function_limits_calculator_work_boundary() {
// With no loops, each admitted operator can execute at most once. The exact parse
// boundary can be evaluated, while construction rejects the next operator.
assert_eq!(function_limits_calculator_eval(&format!("{{ {} }}", "0 pop ".repeat(5000))), Some(vec![]));
assert!(function_limits_calculator_eval(&format!("{{ {} 0 }}", "0 pop ".repeat(5000))).is_none());
}
#[test]
fn function_limits_calculator_work_counts_unselected_branches() {
// 9,996 branch operators + two procedure openings + true + ifelse = 10,000 tokens.
let branch = "0 pop ".repeat(2499);
assert_eq!(function_limits_calculator_eval(&format!("{{ true {{ {branch} }} {{ {branch} }} ifelse }}")), Some(vec![]));
assert!(function_limits_calculator_eval(&format!("{{ true {{ {branch} }} {{ {branch} 0 }} ifelse }}")).is_none());
}
#[test]
fn function_limits_public_construction_work_and_depth() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{FromBytes, Object};
let mut data = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned();
for _ in 1..64 {
data = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{data}] /Bounds [] /Encode [0 1] >>");
}
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert_eq!(Function::new(&object).unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]);
let over = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{data}] /Bounds [] /Encode [0 1] >>");
assert!(Function::new(&Object::from_bytes(over.as_bytes()).unwrap()).is_none());
// One root plus 10,000 leaves is one node past the common budget. This input remains
// below a megabyte and does not attempt excessive recursion or an allocation failure.
let leaf = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> ";
for children in [9999, 10_000] {
let data = format!(
"<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>",
leaf.repeat(children),
"0.5 ".repeat(children - 1),
"0 1 ".repeat(children)
);
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert_eq!(Function::new(&object).is_some(), children == 9999);
}
}
#[test]
fn function_limits_stitching_cycles_are_refused_and_shared_children_work() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{Object, ObjectIdentifier};
let pdf = |functions: &str| {
hayro_syntax::Pdf::new(
format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\
{functions}\ntrailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes(),
)
.unwrap()
};
for functions in [
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [4 0 R] /Bounds [] /Encode [0 1] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [5 0 R] /Bounds [] /Encode [0 1] >> endobj\n5 0 obj << /FunctionType 3 /Domain [0 1] /Functions [4 0 R] /Bounds [] /Encode [0 1] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [] /Bounds [] /Encode [] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [99 0 R] /Bounds [] /Encode [0 1] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [5 0 R 99 0 R] /Bounds [0.5] /Encode [0 1 0 1] >> endobj\n5 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> endobj",
] {
let parsed = pdf(functions);
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
assert!(Function::new(&object).is_none(), "accepted invalid children: {functions}");
}
let parsed = pdf(
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [5 0 R 5 0 R] /Bounds [0.5] /Encode [0 1 0 1] >> endobj\n5 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> endobj",
);
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
let function = Function::new(&object).unwrap();
let output = function.eval([0.75].into_iter().collect()).unwrap();
assert!((output[0] - 0.5).abs() < 0.001);
}
#[test]
fn function_limits_shared_references_count_toward_construction_work() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{Object, ObjectIdentifier};
for children in [9999, 10_000] {
let bytes = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\
4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >> endobj\n\
5 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"5 0 R ".repeat(children),
"0.5 ".repeat(children - 1),
"0 1 ".repeat(children)
)
.into_bytes();
let parsed = hayro_syntax::Pdf::new(bytes).unwrap();
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
assert_eq!(Function::new(&object).is_some(), children == 9999);
}
}
#[test]
fn function_limits_invalid_calculator_keeps_the_page_renderable() {
use super::*;
let program = "{ pop 1 0 idiv }";
let content = "/S sh 0 0 1 rg 0 0 20 20 re f";
let bytes = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /Shading << /S 4 0 R >> >> /Contents 6 0 R >> endobj\n\
4 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 20 0] /Function 5 0 R >> endobj\n\
5 0 obj << /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\
6 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
program.len(),
content.len()
)
.into_bytes();
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 20));
let (pixels, remainder) = page.rgba.as_chunks::<4>();
assert!(remainder.is_empty());
assert!(pixels.iter().all(|pixel| *pixel == [0, 0, 255, 255]));
}
#[test]
fn watchdog_skips_a_stuck_page_and_keeps_rendering() {
use super::*;
+1 -1
View File
@@ -7,7 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `rasLine truncated
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_patLine truncated
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over` |
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` |
+18 -1
View File
@@ -38,4 +38,21 @@ Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option.
at your option.
## PdfCraft function limits
The PdfCraft patch rejects function construction after 64 nested functions or 10,000 total
function nodes, including repeated references, and detects cycles on the current path. Shared
children in different branches remain valid. Stitching functions reject empty or unresolved
child lists instead of silently truncating them. Calculator functions have a shared limit of 64
procedure levels and 10,000 parsed tokens (operators and procedure openings); evaluation has
its own 10,000-operator limit across selected branches. These are implementation resource
limits rather than PDF format limits. Rejection returns the existing `None`, and the optional
`logging` feature reports the reason and limit; no new interface warning is introduced.
Calculator integer division without an integer result is also rejected. Bit shifting discards
bits beyond the 32-bit word and fills with zeroes, including logical right shifts of negative
words; rotation accepts the full signed shift range without negating the minimum integer.
Fixed-size operand and procedure-stack overflow errors propagate instead of truncating work.
Normal function signatures and supported color/transfer-function behavior remain unchanged.
+51 -10
View File
@@ -22,6 +22,19 @@ use std::sync::Arc;
pub(crate) type Values = SmallVec<[f32; 4]>;
type TupleVec = SmallVec<[(f32, f32); 4]>;
// PdfCraft patch: PDF functions are untrusted object graphs. A normal color/transfer function
// has only a few nodes; cap both nesting and total construction work, including repeated
// references, before creating their children. These are resource limits, not PDF format limits.
const MAX_FUNCTION_DEPTH: usize = 64;
const MAX_FUNCTION_NODES: usize = 10_000;
struct ConstructionBudget {
remaining: usize,
// Stable backing-byte identity catches cycles without rejecting shared siblings. Looking
// at pointers only compares identities; no pointer is ever dereferenced.
path: Vec<(*const u8, usize)>,
}
#[derive(Debug)]
enum FunctionType {
Type0(Type0),
@@ -37,17 +50,45 @@ pub struct Function(Arc<FunctionType>);
impl Function {
/// Create a new function.
pub fn new(obj: &Object<'_>) -> Option<Self> {
Self::new_inner(
obj,
&mut ConstructionBudget {
remaining: MAX_FUNCTION_NODES,
path: Vec::new(),
},
0,
)
}
fn new_inner(obj: &Object<'_>, budget: &mut ConstructionBudget, depth: usize) -> Option<Self> {
if depth >= MAX_FUNCTION_DEPTH {
warn!("PDF function nesting exceeds the limit of {MAX_FUNCTION_DEPTH} levels");
return None;
}
if budget.remaining == 0 {
warn!("PDF function construction exceeds the limit of {MAX_FUNCTION_NODES} nodes");
return None;
}
budget.remaining -= 1;
let (dict, stream) = dict_or_stream(obj)?;
let function_type = match dict.get::<u8>(FUNCTION_TYPE)? {
0 => FunctionType::Type0(Type0::new(stream?)?),
2 => FunctionType::Type2(Type2::new(dict)?),
3 => FunctionType::Type3(Type3::new(dict)?),
4 => FunctionType::Type4(Type4::new(stream?)?),
_ => return None,
};
Some(Self(Arc::new(function_type)))
let identity = (dict.data().as_ptr(), dict.data().len());
if budget.path.contains(&identity) {
warn!("PDF function contains a cyclic stitching-function reference");
return None;
}
budget.path.push(identity);
let result = (|| {
let function_type = match dict.get::<u8>(FUNCTION_TYPE)? {
0 => FunctionType::Type0(Type0::new(stream?)?),
2 => FunctionType::Type2(Type2::new(dict)?),
3 => FunctionType::Type3(Type3::new(dict, budget, depth + 1)?),
4 => FunctionType::Type4(Type4::new(stream?)?),
_ => return None,
};
Some(Self(Arc::new(function_type)))
})();
budget.path.pop();
result
}
/// Evaluate the function with the given input.
+69 -4
View File
@@ -16,12 +16,34 @@ pub(crate) struct Type3 {
impl Type3 {
/// Create a new type 3 function.
pub(crate) fn new(dict: &Dict<'_>) -> Option<Self> {
pub(super) fn new(
dict: &Dict<'_>,
budget: &mut super::ConstructionBudget,
depth: usize,
) -> Option<Self> {
let clamper = Clamper::new(dict)?;
let functions = dict
.get::<Array<'_>>(FUNCTIONS)
.and_then(|d| d.iter::<Object<'_>>().map(|o| Function::new(&o)).collect())?;
// PdfCraft patch: all descendants share the caller's depth, work and cycle guards.
// The resolved iterator also ends on an unresolved/cyclic reference. Require one
// resolved child per raw slot so that failure cannot silently shorten the function list.
let array = dict.get::<Array<'_>>(FUNCTIONS)?;
let mut objects = array.iter::<Object<'_>>();
let functions: Vec<Function> = array
.raw_iter()
.map(|_| {
let Some(object) = objects.next() else {
warn!(
"PDF stitching function contains an unresolved or cyclic child reference"
);
return None;
};
Function::new_inner(&object, budget, depth)
})
.collect::<Option<_>>()?;
if functions.is_empty() {
warn!("PDF stitching function has no child functions");
return None;
}
let domain = *clamper.domain.first()?;
let mut bounds = vec![domain.0 - 0.0001];
if let Some(a) = dict.get::<Array<'_>>(BOUNDS) {
@@ -132,4 +154,47 @@ mod tests {
&[0.7, 0.7, 0.7]
);
}
fn function_limits_stitching(levels: usize) -> String {
let mut function = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned();
for _ in 1..levels {
function = format!(
"<< /FunctionType 3 /Domain [0 1] /Functions [{function}] /Bounds [] /Encode [0 1] >>"
);
}
function
}
#[test]
fn function_limits_stitching_depth_is_bounded() {
let data = function_limits_stitching(64);
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert_eq!(
Function::new(&object).unwrap().eval(smallvec![0.5]),
Some(smallvec![0.5])
);
let data = function_limits_stitching(65);
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert!(Function::new(&object).is_none());
}
fn function_limits_fanout(children: usize) -> String {
let leaf = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> ";
format!(
"<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>",
leaf.repeat(children),
"0.5 ".repeat(children.saturating_sub(1)),
"0 1 ".repeat(children)
)
}
#[test]
fn function_limits_stitching_fanout_shares_a_node_budget() {
let data = function_limits_fanout(9999);
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert!(Function::new(&object).is_some());
let data = function_limits_fanout(10000);
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert!(Function::new(&object).is_none());
}
}
+226 -33
View File
@@ -8,6 +8,33 @@ use smallvec::SmallVec;
use std::array;
use std::ops::Rem;
// PdfCraft patch: calculator color/transfer functions normally contain a handful of operators.
// Bound every parsed branch and every executed operator as well as recursive procedure depth.
// A shared budget is never reset when entering an `if` or `ifelse` branch.
const MAX_CALCULATOR_DEPTH: usize = 64;
const MAX_CALCULATOR_OPS: usize = 10_000;
struct CalculatorBudget {
remaining: usize,
}
impl CalculatorBudget {
fn new() -> Self {
Self {
remaining: MAX_CALCULATOR_OPS,
}
}
fn take(&mut self) -> Option<()> {
if self.remaining == 0 {
warn!("PDF calculator function exceeds the limit of {MAX_CALCULATOR_OPS} operators");
return None;
}
self.remaining -= 1;
Some(())
}
}
/// A type 4 function (postscript function).
#[derive(Debug)]
pub(crate) struct Type4 {
@@ -34,10 +61,15 @@ impl Type4 {
let mut arg_stack = InterpreterStack::new();
for input in input {
arg_stack.push(Argument::Float(input));
arg_stack.push(Argument::Float(input))?;
}
eval_inner(&self.program, &mut arg_stack)?;
eval_inner(
&self.program,
&mut arg_stack,
&mut CalculatorBudget::new(),
0,
)?;
let mut out: SmallVec<_> = arg_stack.items().iter().map(|i| i.as_f32()).collect();
@@ -162,17 +194,26 @@ impl<T: Default, const C: usize> ArgumentsStack<T, C> {
type InterpreterStack = ArgumentsStack<Argument, 64>;
type ParseStack = ArgumentsStack<Vec<PostScriptOp>, 2>;
fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> Option<()> {
fn eval_inner(
procedure: &[PostScriptOp],
arg_stack: &mut InterpreterStack,
budget: &mut CalculatorBudget,
depth: usize,
) -> Option<()> {
if depth >= MAX_CALCULATOR_DEPTH {
warn!("PDF calculator nesting exceeds the limit of {MAX_CALCULATOR_DEPTH} levels");
return None;
}
macro_rules! zero {
($eval:expr) => {
arg_stack.push($eval);
arg_stack.push($eval)?;
};
}
macro_rules! one_f {
($eval:expr) => {
let n1 = arg_stack.pop()?;
arg_stack.push(Argument::Float($eval(n1.as_f32())));
arg_stack.push(Argument::Float($eval(n1.as_f32())))?;
};
}
@@ -180,7 +221,7 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
($eval:expr) => {
let n2 = arg_stack.pop()?;
let n1 = arg_stack.pop()?;
arg_stack.push(Argument::Float($eval(n1.as_f32(), n2.as_f32())));
arg_stack.push(Argument::Float($eval(n1.as_f32(), n2.as_f32())))?;
};
}
@@ -200,7 +241,7 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
(Argument::Bool(f1), Argument::Bool(f2)) => Argument::Bool($eval_b(f1, f2)),
};
arg_stack.push(res);
arg_stack.push(res)?;
};
}
@@ -209,6 +250,7 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
}
for op in procedure {
budget.take()?;
match op {
PostScriptOp::Number(n) => arg_stack.push(Argument::Float(n.as_f64() as f32))?,
PostScriptOp::Abs => {
@@ -249,12 +291,14 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
one_f!(|n: f32| n.floor());
}
PostScriptOp::Idiv => {
two_f!(|n1: f32, n2: f32| {
let n1 = n1 as i32;
let n2 = n2 as i32;
(n1 / n2) as f32
});
// PdfCraft patch: zero divisors and MIN / -1 have no integer result.
let n2 = arg_stack.pop()?.as_f32() as i32;
let n1 = arg_stack.pop()?.as_f32() as i32;
let quotient = n1.checked_div(n2).or_else(|| {
warn!("PDF calculator idiv has a zero divisor or an out-of-range result");
None
})?;
arg_stack.push(Argument::Float(quotient as f32))?;
}
PostScriptOp::Ln => {
one_f!(|n: f32| n.ln());
@@ -291,14 +335,17 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
}
PostScriptOp::Bitshift => {
two_f!(|n1: f32, n2: f32| {
let num = n1 as u32;
// PdfCraft patch: PLRM, bitshift, discards shifted-out bits and fills
// with zeroes. All bits are gone at >= 32; use the signed word's bit
// pattern for negative operands, and never negate MIN for right shifts.
let num = n1 as i32 as u32;
let shift = n2 as i32;
if shift >= 0 {
(num << shift) as f32
let bits = if shift >= 0 {
num.checked_shl(shift as u32).unwrap_or(0)
} else {
(num >> -shift) as f32
}
num.checked_shr(shift.unsigned_abs()).unwrap_or(0)
};
bits as i32 as f32
});
}
PostScriptOp::Eq => {
@@ -330,7 +377,7 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
Argument::Bool(b) => Argument::Bool(!b),
};
arg_stack.push(res);
arg_stack.push(res)?;
}
PostScriptOp::Or => {
four!(
@@ -351,40 +398,40 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
let cond = arg_stack.pop()?.as_bool();
if cond {
eval_inner(p, arg_stack)?;
eval_inner(p, arg_stack, budget, depth + 1)?;
}
}
PostScriptOp::IfElse(p1, p2) => {
let cond = arg_stack.pop()?.as_bool();
if cond {
eval_inner(p1, arg_stack)?;
eval_inner(p1, arg_stack, budget, depth + 1)?;
} else {
eval_inner(p2, arg_stack)?;
eval_inner(p2, arg_stack, budget, depth + 1)?;
}
}
PostScriptOp::Copy => {
let n = arg_stack.pop()?.as_f32() as u32 as usize;
let start = arg_stack.len().checked_sub(n)?;
for i in start..arg_stack.len() {
arg_stack.push(*arg_stack.at(i)?);
arg_stack.push(*arg_stack.at(i)?)?;
}
}
PostScriptOp::Dup => {
arg_stack.push(*arg_stack.last()?);
arg_stack.push(*arg_stack.last()?)?;
}
PostScriptOp::Exch => {
let n2 = arg_stack.pop()?;
let n1 = arg_stack.pop()?;
arg_stack.push(n2);
arg_stack.push(n1);
arg_stack.push(n2)?;
arg_stack.push(n1)?;
}
PostScriptOp::Index => {
let n = arg_stack.pop()?.as_f32() as u32 as usize;
let n = arg_stack.len().checked_sub(n + 1)?;
let n = arg_stack.len().checked_sub(n.checked_add(1)?)?;
arg_stack.push(*arg_stack.at(n)?);
arg_stack.push(*arg_stack.at(n)?)?;
}
PostScriptOp::Pop => {
arg_stack.pop()?;
@@ -404,7 +451,7 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
let shift = j as usize % target.len();
target.rotate_right(shift);
} else {
let shift = (-j) as usize % target.len();
let shift = j.unsigned_abs() as usize % target.len();
target.rotate_left(shift);
}
}
@@ -416,10 +463,18 @@ fn eval_inner(procedure: &[PostScriptOp], arg_stack: &mut InterpreterStack) -> O
fn parse_procedure(data: &[u8]) -> Option<Vec<PostScriptOp>> {
let mut r = Reader::new(data);
parse_procedure_inner(&mut r)
parse_procedure_inner(&mut r, &mut CalculatorBudget::new(), 0)
}
fn parse_procedure_inner(r: &mut Reader<'_>) -> Option<Vec<PostScriptOp>> {
fn parse_procedure_inner(
r: &mut Reader<'_>,
budget: &mut CalculatorBudget,
depth: usize,
) -> Option<Vec<PostScriptOp>> {
if depth >= MAX_CALCULATOR_DEPTH {
warn!("PDF calculator nesting exceeds the limit of {MAX_CALCULATOR_DEPTH} levels");
return None;
}
let mut stack = ParseStack::new();
let mut ops = vec![];
@@ -434,8 +489,10 @@ fn parse_procedure_inner(r: &mut Reader<'_>) -> Option<Vec<PostScriptOp>> {
break;
} else if r.peek_byte()? == b'{' {
stack.push(parse_procedure_inner(r)?);
budget.take()?;
stack.push(parse_procedure_inner(r, budget, depth + 1)?)?;
} else {
budget.take()?;
let op = PostScriptOp::from_reader(r, &mut stack)?;
ops.push(op);
}
@@ -907,4 +964,140 @@ mod tests {
assert_eq!(res.as_slice(), &[-5.0, -2.0, 5.0]);
}
fn function_limits_result(program: &str) -> Option<Values> {
Type4 {
program: parse_procedure(format!("{{ {program} }}").as_bytes())?,
clamper: Clamper {
domain: TupleVec::default(),
range: None,
},
}
.eval(Values::new())
}
#[test]
fn function_limits_idiv_zero_is_refused() {
assert!(function_limits_result("1 0 idiv").is_none());
}
#[test]
fn function_limits_idiv_overflow_is_refused() {
assert!(function_limits_result("-2147483648 -1 idiv").is_none());
}
#[test]
fn function_limits_large_bitshifts_discard_all_bits() {
for shift in [32, -32, 2147483647, -2147483648] {
assert_eq!(
function_limits_result(&format!("7 {shift} bitshift")),
Some(smallvec![0.0])
);
}
assert_eq!(
function_limits_result("1073741824 1 bitshift"),
Some(smallvec![-2147483648.0])
);
assert_eq!(
function_limits_result("-2147483648 -1 bitshift"),
Some(smallvec![1073741824.0])
);
}
#[test]
fn function_limits_roll_handles_the_most_negative_integer() {
assert_eq!(
function_limits_result("1 2 3 3 -2147483648 roll"),
Some(smallvec![3.0, 1.0, 2.0])
);
}
#[test]
fn function_limits_large_indices_are_refused() {
assert!(function_limits_result("1 4294967295 index").is_none());
}
#[test]
fn function_limits_operand_stack_overflow_is_refused() {
assert_eq!(
function_limits_result(&format!("1 {}", "dup ".repeat(63)))
.unwrap()
.len(),
64
);
assert!(function_limits_result(&format!("1 {}", "dup ".repeat(64))).is_none());
}
fn function_limits_nested_program(levels: usize) -> String {
let mut program = "{ 0 }".to_owned();
for _ in 1..levels {
program = format!("{{ true {program} if }}");
}
program
}
#[test]
fn function_limits_calculator_parse_depth_is_bounded() {
assert!(parse_procedure(function_limits_nested_program(64).as_bytes()).is_some());
assert!(parse_procedure(function_limits_nested_program(65).as_bytes()).is_none());
}
#[test]
fn function_limits_calculator_parse_work_is_shared() {
assert!(parse_procedure(format!("{{ {} }}", "0 pop ".repeat(5000)).as_bytes()).is_some());
assert!(parse_procedure(format!("{{ {} }}", "0 pop ".repeat(5001)).as_bytes()).is_none());
}
#[test]
fn function_limits_parse_budget_counts_unselected_branches() {
let branch = "0 pop ".repeat(2500);
let program = format!("{{ true {{ {branch} }} {{ {branch} }} ifelse }}");
assert!(parse_procedure(program.as_bytes()).is_none());
assert!(parse_procedure(b"{ {} {} }").is_some());
assert!(parse_procedure(b"{ {} {} {} }").is_none());
}
fn function_limits_eval(program: Vec<PostScriptOp>) -> Option<Values> {
Type4 {
program,
clamper: Clamper {
domain: TupleVec::default(),
range: None,
},
}
.eval(Values::new())
}
#[test]
fn function_limits_calculator_eval_work_is_bounded() {
let pair = [PostScriptOp::Number(Number::from_i32(0)), PostScriptOp::Pop];
assert_eq!(
function_limits_eval((0..5000).flat_map(|_| pair.iter().cloned()).collect()),
Some(Values::new())
);
assert!(
function_limits_eval((0..5001).flat_map(|_| pair.iter().cloned()).collect()).is_none()
);
}
#[test]
fn function_limits_eval_budget_is_shared_by_branches() {
let pair = [PostScriptOp::Number(Number::from_i32(0)), PostScriptOp::Pop];
let mut program: Vec<_> = (0..3000).flat_map(|_| pair.iter().cloned()).collect();
program.push(PostScriptOp::True);
program.push(PostScriptOp::If(
(0..2000).flat_map(|_| pair.iter().cloned()).collect(),
));
assert!(function_limits_eval(program).is_none());
}
#[test]
fn function_limits_calculator_eval_depth_is_bounded() {
let mut program = vec![PostScriptOp::Number(Number::from_i32(0))];
for _ in 1..64 {
program = vec![PostScriptOp::True, PostScriptOp::If(program)];
}
assert_eq!(function_limits_eval(program.clone()), Some(smallvec![0.0]));
assert!(
function_limits_eval(vec![PostScriptOp::True, PostScriptOp::If(program)]).is_none()
);
}
}