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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 15:54:26 -04:00
1 parent 559859ca61
commit 74be522c18
6 files changed
+316 -9

No files matched your search

+258 -1
View File
@@ -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::<Object<'_>>(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<u8> {
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<u8>) -> 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::<Object<'_>>(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::<Object<'_>>(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::<Object<'_>>(false).is_some());
reader.skip_white_spaces();
}
}
// Content-stream arrays take the Object path, rather than MaybeRef<Object>.
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::<Object<'_>>(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::<Object<'_>>().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::<Object<'_>>(false).is_some(), "{kind}: skip");
assert!(Reader::new(value.as_bytes()).skip::<Object<'_>>(true).is_some(), "{kind}: skip in content");
assert!(Reader::new(value.as_bytes()).read_without_context::<Object<'_>>().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::<Object<'_>>().is_some(), "{kind}: flex_iter");
array.iter::<Object<'_>>().next()
}
Object::Dict(dict) => dict.get::<Object<'_>>("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
+2
View File
@@ -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_retriLine truncated
| 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, Line truncated
| 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 |
+4 -2
View File
@@ -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 {
+21 -2
View File
@@ -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.
+6 -2
View File
@@ -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::<MaybeRef<Object<'_>>>(false)?;
}
}
})();
r.leave_container();
result
}
}
+25 -2
View File
@@ -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::<MaybeRef<Object<'_>>>(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]>,