From 74be522c181fc1d0154020a35451ee25fc5049b8 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sun, 11 Oct 2026 04:54:26 +0900 Subject: [PATCH] hayro-syntax: cap the nesting of direct arrays and dictionaries (#772) Skipping a direct array or dictionary, and the lenient repair of a dictionary that has an object where a key belongs, recursed once per level with no bound, so deep nesting in a trailer entry or a content stream overflowed the stack. Count the depth on the Reader and fail beyond 128 levels like any other malformed object. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/render/src/raster.rs | 259 ++++++++++++++++++- vendor/README.md | 2 + vendor/hayro-interpret/src/function/type3.rs | 6 +- vendor/hayro-syntax/src/byte_reader.rs | 23 +- vendor/hayro-syntax/src/object/array.rs | 8 +- vendor/hayro-syntax/src/object/dict.rs | 27 +- 6 files changed, 316 insertions(+), 9 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 012879b46..18d19e151 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1661,7 +1661,11 @@ mod tests { 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()); + // The 64 functions above nest exactly 128 levels (each dictionary and its /Functions + // array, then the leaf's /Domain array), the parser's nesting cap. 65 nest 130, so the + // parser refuses them before the function-depth guard sees them (a refusal either way). + // `function_limits_depth_guard_refuses_a_chain_of_references` tests the guard itself. + assert!(Object::from_bytes(over.as_bytes()).is_none(), "the parser refuses direct nesting past its cap"); // 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. @@ -1677,6 +1681,43 @@ mod tests { assert_eq!(Function::new(&object).is_some(), children == 9999); } } + /// Stitching functions that each name the next through `N 0 R` nest no direct objects, so + /// the parser's nesting cap doesn't stop them and the function-depth guard does. + #[test] + fn function_limits_depth_guard_refuses_a_chain_of_references() { + use hayro::hayro_interpret::Function; + use hayro_syntax::object::{Object, ObjectIdentifier}; + for functions in [64, 65] { + // Objects 4 onwards: each stitching function names the next one; the last is a leaf. + let objects: String = (4..4 + functions) + .map(|number| { + let body = if number < 3 + functions { + format!("<< /FunctionType 3 /Domain [0 1] /Functions [{} 0 R] /Bounds [] /Encode [0 1] >>", number + 1) + } else { + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned() + }; + format!("{number} 0 obj {body} endobj\n") + }) + .collect(); + let parsed = 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\ + {objects}trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes(), + ) + .unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).unwrap(); + let function = Function::new(&object); + if functions == 64 { + assert_eq!(function.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(function.is_none(), "65 functions are one past the function-depth guard"); + } + } + } #[test] fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { use hayro::hayro_interpret::Function; @@ -3353,6 +3394,222 @@ trailer << /Root 1 0 R >> assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } + /// `depth` direct arrays (`array`), dictionaries with a key (`dict`) or both in turn (`mixed`) + /// around `null`. + fn nested(depth: usize, kind: &str) -> String { + let is_dict = |level| kind == "dict" || (kind == "mixed" && level % 2 == 0); + let mut value = String::new(); + for level in 0..depth { + value.push_str(if is_dict(level) { "<< /N " } else { "[" }); + } + value.push_str("null"); + for level in (0..depth).rev() { + value.push_str(if is_dict(level) { " >>" } else { "]" }); + } + value + } + + /// `depth` dictionaries nested without keys (`<< << >> >>`), closed or not. + fn keyless(depth: usize, closed: bool) -> String { + let mut value = "<< ".repeat(depth); + if closed { + value.push_str(&">> ".repeat(depth)); + } + value + } + + /// A 40 × 40 page that runs `content`, with a correct xref table and `trailer` at the end + /// of a trailer that also points at an `/Info` title. + fn nesting_pdf(trailer: &str, content: &str) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0]; + for (index, body) in [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << >> >>".to_string(), + format!("<< /Length {} >> stream\n{content}\nendstream", content.len()), + "<< /Title (Nested) >>".to_string(), + ] + .iter() + .enumerate() + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in &offsets[1..] { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /Info 5 0 R {trailer} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes + } + + /// Opens `bytes` and renders its one page, which must render without an error. + fn nesting_render(bytes: Vec) -> RenderedPage { + let parsed = Pdf::new(bytes.clone()).expect("xref repair can recover the catalog when the trailer is malformed"); + assert_eq!(parsed.pages().len(), 1); + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (40, 40)); + page + } + + /// Direct arrays and dictionaries used to recurse without a bound while skipping even + /// unused trailer values and content operands. Exercise the vendored patch in workspace CI. + #[test] + fn deeply_nested_direct_objects_do_not_overflow_the_stack() { + use hayro_syntax::object::Object; + use hayro_syntax::reader::{Reader, ReaderExt}; + let pdf = |unused: &str, content: &str| nesting_pdf(&format!("/Unused {unused}"), content); + let paint = "1 0 0 rg 0 0 4 4 re f"; + for kind in ["array", "dict", "mixed"] { + // Run the crash reproducer first, so the unpatched red state is a stack overflow. + let page = nesting_render(pdf(&nested(100_000, kind), paint)); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: repaired trailer"); + for depth in [20, 128, 129] { + let value = nested(depth, kind); + let siblings = format!("{value} [] []"); + let mut reader = Reader::new(siblings.as_bytes()); + assert_eq!(reader.skip::>(false).is_some(), depth <= 128, "{kind}: depth {depth}"); + if depth > 128 { + assert_eq!(reader.offset(), 0, "failed skips restore the offset"); + reader.jump(value.len()); + } + // A failed skip must unwind its depth; successful siblings must not accumulate it. + for _ in 0..2 { + reader.skip_white_spaces(); + assert!(reader.skip::>(false).is_some()); + } + let page = nesting_render(pdf(&value, paint)); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: depth {depth}"); + } + // Many siblings exercise the offset constructor and ensure depth is per nesting. + let siblings = "[] ".repeat(256); + let mut reader = Reader::new_with(siblings.as_bytes(), 0); + for _ in 0..256 { + assert!(reader.skip::>(false).is_some()); + reader.skip_white_spaces(); + } + } + // Content-stream arrays take the Object path, rather than MaybeRef. + for depth in [100_000, 20, 128, 129] { + let value = nested(depth, "array"); + let mut reader = Reader::new(value.as_bytes()); + assert_eq!(reader.skip::>(true).is_some(), depth <= 128, "content: depth {depth}"); + let content = format!("{value} discard {paint}"); + let page = nesting_render(pdf("null", &content)); + if depth <= 128 { + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "content: depth {depth}"); + } + } + } + + /// A dictionary with an object where a key belongs is repaired by reading that object, not + /// skipping it, and that read recursed without a bound when the object was another such + /// dictionary: `<<` 100,000 times aborted the process. Here as a content-stream operand. + #[test] + fn dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack() { + for closed in [true, false] { + let content = format!("1 0 0 rg 0 0 4 4 re f {} discard", keyless(100_000, closed)); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } + } + + /// The same, where a key of the trailer belongs: the xref table can't be used, and repair + /// finds the catalog. + #[test] + fn dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack() { + for closed in [true, false] { + let page = nesting_render(nesting_pdf(&keyless(100_000, closed), "1 0 0 rg 0 0 4 4 re f")); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } + } + + /// The same as the properties of marked content (`/Tag <<<<<< … BDC`). + #[test] + fn dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack() { + for closed in [true, false] { + let properties = if closed { "<<".repeat(100_000) + &">>".repeat(100_000) } else { "<<".repeat(100_000) }; + let content = format!("1 0 0 rg 0 0 4 4 re f /Tag {properties} BDC 0 0 1 rg 36 36 4 4 re f EMC"); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } + } + + /// Through the same three paths, dictionaries without keys are read up to the nesting cap + /// (128 dictionaries in all) and refused one past it. + #[test] + fn dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap() { + use hayro_syntax::object::{FromBytes, Object}; + use hayro_syntax::reader::{Reader, ReaderExt}; + let (red, blue, white) = ([255, 0, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]); + for depth in [128, 129] { + let within = depth <= 128; + let value = keyless(depth, true); + assert_eq!(Object::from_bytes(value.as_bytes()).is_some(), within, "object: {depth}"); + assert_eq!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), within, "operand: {depth}"); + // Content reading stops at an operand it can't read, so the blue square is drawn + // only when the dictionaries were read. + let properties = "<<".repeat(depth) + &">>".repeat(depth); + for (path, operand) in [("operand", format!("{value} discard")), ("BDC", format!("/Tag {properties} BDC"))] { + let content = format!("1 0 0 rg 0 0 4 4 re f {operand} 0 0 1 rg 36 36 4 4 re f"); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &red, "{path}: {depth}"); + assert_eq!(px40(&page, 38, 1), if within { &blue } else { &white }, "{path}: {depth}"); + } + // The trailer is the outermost dictionary. Within the cap it is read (with its + // /Info); past it, repair recovers the catalog but not the trailer. + let bytes = nesting_pdf(&keyless(depth - 1, true), "1 0 0 rg 0 0 4 4 re f"); + let parsed = Pdf::new(bytes.clone()).expect("the trailer or repair finds the catalog"); + assert_eq!(parsed.metadata().title, within.then(|| b"Nested".to_vec()), "trailer: {depth}"); + assert_eq!(px40(&nesting_render(bytes), 1, 38), &red, "trailer: {depth}"); + } + } + + /// The cap must also fit small stacks: skip, read, iterate down with the array iterators and + /// format (`{:?}`) the deepest nesting it admits, of arrays, dictionaries, both and + /// dictionaries without keys, on a 512 KiB thread in a debug build. Measured when this was + /// written (macOS arm64, dev profile): at most about 100 KiB, for `{:?}` of 128 dictionaries. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn nesting_at_the_cap_fits_a_512_kib_stack() { + use hayro_syntax::object::{FromBytes, Object}; + use hayro_syntax::reader::{Reader, ReaderExt}; + let worker = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| { + // (kind, arrays and dictionaries `{:?}` shows, levels walked down to the end) + for (kind, arrays, dicts, levels) in [("array", 128, 0, 129), ("dict", 0, 128, 129), ("mixed", 64, 64, 129), ("keyless", 0, 1, 1)] { + let value = if kind == "keyless" { keyless(128, true) } else { nested(128, kind) }; + assert!(Reader::new(value.as_bytes()).skip::>(false).is_some(), "{kind}: skip"); + assert!(Reader::new(value.as_bytes()).skip::>(true).is_some(), "{kind}: skip in content"); + assert!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), "{kind}: operand"); + let object = Object::from_bytes(value.as_bytes()).expect("the cap admits 128 levels"); + let shown = format!("{object:?}"); + assert_eq!((shown.matches("Array(").count(), shown.matches("Dict(").count()), (arrays, dicts), "{kind}: {{:?}}"); + let (mut walked, mut next) = (0, Some(object)); + while let Some(object) = next.take() { + walked += 1; + next = match object { + Object::Array(array) => { + assert_eq!(array.raw_iter().count(), 1, "{kind}: raw_iter"); + assert!(array.flex_iter().next::>().is_some(), "{kind}: flex_iter"); + array.iter::>().next() + } + Object::Dict(dict) => dict.get::>("N"), + _ => None, + }; + } + assert_eq!(walked, levels, "{kind}: iterate"); + } + }); + // A stack overflow aborts the process; a failed check is a panic, shown as it is. + if let Err(panic) = worker.expect("spawn a 512 KiB thread").join() { + std::panic::resume_unwind(panic); + } + } + /// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed /// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the /// data, the parser re-read the rest of the stream as content, and that re-read did the same diff --git a/vendor/README.md b/vendor/README.md index cb8e6931a..230b28611 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -13,6 +13,8 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | 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` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | +| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | diff --git a/vendor/hayro-interpret/src/function/type3.rs b/vendor/hayro-interpret/src/function/type3.rs index 2d3720d48..f63668f17 100644 --- a/vendor/hayro-interpret/src/function/type3.rs +++ b/vendor/hayro-interpret/src/function/type3.rs @@ -174,8 +174,10 @@ mod tests { 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()); + // Direct nesting beyond the hayro-syntax cap (PdfCraft patch) is refused while parsing, + // before this guard; the guard is tested with indirect references in pdfcraft-render + // (`function_limits_depth_guard_refuses_a_chain_of_references`). + assert!(Object::from_bytes(data.as_bytes()).is_none()); } fn function_limits_fanout(children: usize) -> String { diff --git a/vendor/hayro-syntax/src/byte_reader.rs b/vendor/hayro-syntax/src/byte_reader.rs index 9ea295728..f3c7a20e0 100644 --- a/vendor/hayro-syntax/src/byte_reader.rs +++ b/vendor/hayro-syntax/src/byte_reader.rs @@ -2,6 +2,10 @@ use core::ops::Range; +/// PdfCraft patch: how deeply direct arrays and dictionaries may nest (the same cap as `pdfcraft-cos`; a +/// chain of 64 direct stitching functions is exactly this deep). +const MAX_CONTAINER_DEPTH: usize = 128; + /// A reader for reading bytes and PDF objects. #[derive(Clone, Debug)] pub struct Reader<'a> { @@ -9,19 +13,34 @@ pub struct Reader<'a> { pub data: &'a [u8], /// The current byte-offset. pub offset: usize, + // PdfCraft patch: bound direct-object nesting while skipping arrays and dictionaries + // and while parsing dictionaries (`Dict::skip`, `Array::skip`, `parse_dict_with`). + skip_depth: usize, } impl<'a> Reader<'a> { /// Create a new reader. #[inline] pub fn new(data: &'a [u8]) -> Self { - Self { data, offset: 0 } + Self { data, offset: 0, skip_depth: 0 } } /// Create a new reader at the given offset. #[inline] pub fn new_with(data: &'a [u8], offset: usize) -> Self { - Self { data, offset } + Self { data, offset, skip_depth: 0 } + } + + pub(crate) fn enter_container(&mut self) -> Option<()> { + if self.skip_depth >= MAX_CONTAINER_DEPTH { + return None; + } + self.skip_depth += 1; + Some(()) + } + + pub(crate) fn leave_container(&mut self) { + self.skip_depth -= 1; } /// Returns `true` if the reader has reached the end of the data. diff --git a/vendor/hayro-syntax/src/object/array.rs b/vendor/hayro-syntax/src/object/array.rs index 332f09ab8..d39230f7d 100644 --- a/vendor/hayro-syntax/src/object/array.rs +++ b/vendor/hayro-syntax/src/object/array.rs @@ -71,8 +71,10 @@ object!(Array<'a>, Array); impl Skippable for Array<'_> { fn skip(r: &mut Reader<'_>, is_content_stream: bool) -> Option<()> { r.forward_tag(b"[")?; + r.enter_container()?; - loop { + // PdfCraft patch: leave even when an element's skip fails through `?`. + let result = (|| loop { r.skip_white_spaces_and_comments(); if let Some(()) = r.forward_tag(b"]") { @@ -82,7 +84,9 @@ impl Skippable for Array<'_> { } else { r.skip::>>(false)?; } - } + })(); + r.leave_container(); + result } } diff --git a/vendor/hayro-syntax/src/object/dict.rs b/vendor/hayro-syntax/src/object/dict.rs index 55ea355ea..1e016b567 100644 --- a/vendor/hayro-syntax/src/object/dict.rs +++ b/vendor/hayro-syntax/src/object/dict.rs @@ -177,8 +177,10 @@ impl Debug for Dict<'_> { impl Skippable for Dict<'_> { fn skip(r: &mut Reader<'_>, is_content_stream: bool) -> Option<()> { r.forward_tag(b"<<")?; + r.enter_container()?; - loop { + // PdfCraft patch: leave even when a value or garbage fails through `?`. + let result = (|| loop { r.skip_white_spaces_and_comments(); if let Some(()) = r.forward_tag(b">>") { @@ -198,7 +200,9 @@ impl Skippable for Dict<'_> { r.skip::>>(is_content_stream)?; } } - } + })(); + r.leave_container(); + result } } @@ -362,7 +366,26 @@ pub(crate) fn probe_dict<'a>( }) } +// PdfCraft patch: an object where a key belongs is read, not skipped, so a dictionary +// holding another one there (`<< << …`) recursed without a bound. Count each parsed +// dictionary like a skipped one, and leave on every exit of the parse below. fn parse_dict_with<'a, F>( + r: &mut Reader<'a>, + ctx: &ReaderContext<'a>, + start_tag: Option<&[u8]>, + end_tag: &[u8], + on_entry: F, +) -> Option<(&'a [u8], bool)> +where + F: FnMut(Name<'a>, usize, &Reader<'a>) -> Option<()>, +{ + r.enter_container()?; + let result = parse_dict_with_inner(r, ctx, start_tag, end_tag, on_entry); + r.leave_container(); + result +} + +fn parse_dict_with_inner<'a, F>( r: &mut Reader<'a>, ctx: &ReaderContext<'a>, start_tag: Option<&[u8]>,