From 3fc8108a0760e4edb84693f6969a43c23a72fe19 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:45:55 +0900 Subject: [PATCH] M2.6: simple-font /Widths: bounded allocation, bad entries skipped, font kept (#276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored hayro-interpret reads /FirstChar, /LastChar and /Widths of every simple font (TrueType, Type 1, Type 3, standard 14) in read_widths. Three defects there, each checked against MuPDF, Poppler, PDFium and pdf.js on synthetic files: - One entry was pushed per code below /FirstChar before any width was read. 50,000,000 already took 276 MB; 4294967295 asks for 34 GB, and a larger value aborts on allocation (AGENTS.md §4: cap allocations sized by the file). Simple-font codes are single bytes, so only codes 0 to 255 now get an entry. - An entry that isn't a number (null, a name) ended the array, so every later code lost its width and glyphs piled up. It now leaves just that code missing (/MissingWidth); the four renderers do the same. - A /LastChar below /FirstChar, or a /FirstChar so large the count overflowed, made read_widths fail, so the font didn't load and a standard font stood in for the embedded program. The font now loads with no widths from the array; the four renderers keep the font too. Valid /Widths read exactly as before, including explicit zero widths, which all four renderers and Apple Quartz (sips) honour literally. Tests (a Type 3 font, so no font program is needed): raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font fails on main for [null 600], [/W 600], /LastChar < /FirstChar and /FirstChar 4294967295; raster::tests::huge_simple_font_first_char_terminates renders /FirstChar 4294967295 /LastChar 4294967295 quickly (not run against the old code: it would exhaust memory). Vendored patch (14). Co-authored-by: Claude Opus 5.5 --- crates/render/src/raster.rs | 73 ++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/font/true_type.rs | 21 +++--- 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 3c2c39b6..61fd3480 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1321,6 +1321,79 @@ trailer << /Root 1 0 R >> assert!(px.is_none() && text.is_none(), "{px:?} {text:?}"); } + /// "BBBA" at 20 pt in a Type 3 font whose glyph (for both codes) is a solid 300 × 700 box, + /// with the given /FirstChar, /LastChar and /Widths. No font program is needed, and Type 3 + /// widths are read like those of every other simple font. + fn type3_boxes(widths: &str) -> Vec { + let content = "BT /F1 20 Tf 20 40 Td (BBBA) Tj ET"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] {widths} + /Encoding << /Differences [65 /a /a] >> /CharProcs << /a 6 0 R >> >> endobj +6 0 obj << /Length 27 >> stream +600 0 d0 100 0 400 700 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() + } + + /// Simple-font widths (vendored hayro-interpret patch to `read_widths`): an entry of /Widths + /// that isn't a number ended the array, so every later code lost its width, and a /LastChar + /// below /FirstChar (or one that overflowed) made the whole font fail to load, replaced by a + /// standard font. MuPDF, Poppler, PDFium and pdf.js skip just that entry and keep the font. + #[test] + fn malformed_simple_font_widths_keep_later_widths_and_the_font() { + let render = |widths: &str| { + let mut r = PageRenderer::new(Arc::new(type3_boxes(widths)), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + assert!(p.error.is_none(), "{widths}: {:?}", p.error); + p + }; + // Glyph boxes span x 22..28 plus 12 pt per advance, and y 46..60 (y down). + let inked = |p: &RenderedPage, x: u32| (47..59).all(|y| p.rgba[((y * p.width + x) * 4) as usize] < 64); + for widths in [ + "/FirstChar 65 /LastChar 66 /Widths [600 600]", + "/FirstChar 65 /LastChar 66 /Widths [null 600]", + "/FirstChar 65 /LastChar 66 /Widths [/W 600]", + ] { + let p = render(widths); + assert!([25, 37, 49, 61].iter().all(|x| inked(&p, *x)), "{widths}: each B advances 600"); + assert!(!inked(&p, 31), "{widths}"); + } + for widths in ["/FirstChar 66 /LastChar 65 /Widths [600 600]", "/FirstChar 4294967295 /LastChar 66 /Widths [600 600]"] { + let p = render(widths); + // The font's own box, drawn without advances (no width applies), not a stand-in font. + assert!(inked(&p, 23) && inked(&p, 27), "{widths}: the Type 3 glyph is drawn"); + assert!(!inked(&p, 37), "{widths}: no width, no advance"); + } + } + + /// Simple-font widths (vendored hayro-interpret patch to `read_widths`): /FirstChar sized an + /// allocation, one entry per code below it, before any width was read. 50,000,000 already + /// took 276 MB; 4294967295 asks for 34 GB. Codes of a simple font stop at 255. + #[test] + fn huge_simple_font_first_char_terminates() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(type3_boxes("/FirstChar 4294967295 /LastChar 4294967295 /Widths [600]")), RenderConfig::default()); + let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); + let _ = tx.send((px, text.error)); + }); + let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge /FirstChar must not exhaust memory"); + assert!(px.error.is_none() && text.is_none(), "{:?} {text:?}", px.error); + assert!(px.rgba[((50 * px.width + 25) * 4) as usize] < 64, "the glyph is drawn"); + } + #[test] fn tiles_match_full_render() { let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); diff --git a/vendor/README.md b/vendor/README.md index 4fc8bb42..44553ca7 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -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) | (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) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn` | +| 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 | (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) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates` | | 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) | (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` | | 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) | (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 | diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index c95bc229..8034d3af 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -399,18 +399,15 @@ pub(crate) fn read_widths(dict: &Dict<'_>, descriptor: &Dict<'_>) -> Option<(Vec let missing_width = descriptor.get::(MISSING_WIDTH).unwrap_or(0.0); if let (Some(fc), Some(lc), Some(w)) = (first_char, last_char, widths_arr) { - let iter = w.iter::().take(lc.checked_sub(fc)?.checked_add(1)?); - - for _ in 0..fc { - widths.push(Width::Missing); - } - - for w in iter { - widths.push(Width::Value(w)); - } - - while widths.len() <= (u8::MAX as usize) + 1 { - widths.push(Width::Missing); + // PdfCraft patch: simple-font codes are single bytes, so only codes up to 255 get an + // entry (one per code below /FirstChar was pushed: 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 means no widths rather than no font. + widths = vec![Width::Missing; u8::MAX as usize + 1]; + for (code, entry) in (fc..=lc.min(u8::MAX as usize)).zip(w.iter::>()) { + if let (Some(slot), Object::Number(n)) = (widths.get_mut(code), entry) { + *slot = Width::Value(n.as_f32()); + } } }