Skip to content

[Bug]: malformed geometry panics the renderer, and two read paths are nondeterministic #997

Description

@tobocop2

Bug Description

Seven defects on the read path — reached through from_bytes, render_page and the tagged-PDF structure tree.

Five are crashes. A malformed shading function, a negative image-mask dimension or a zero-width /Mask panics inside render_page, on a file the crate opened without complaint. A caller cannot defend against that with a Result.

Two are nondeterminism. Neither errors, neither logs, and both return a plausible answer — just not the same one twice. A damaged file recovers a different page tree between reconstructions, and an annotation without a normal appearance renders one of two images depending on hash order.

Both nondeterminism cases vary within one process, not only between runs, because Rust seeds each HashMap instance separately.

Summary

# Location Trigger Today Should be
1 rendering/mesh_shading.rs:965 shading function FunctionType 3 with /Domain [] panic: index out of bounds skip the shading
2 rendering/mesh_shading.rs:1089 FunctionType 0 whose /Size exceeds its sample stream panic: multiply with overflow reject the function
3 rendering/separation_renderer.rs:3062 /ImageMask with /Width -1 /Height -1 panic: multiply with overflow reject the dimensions
4 rendering/page_renderer.rs:4178 image whose /Mask sub-image has /Width 0 panic: subtract with overflow skip the mask, paint the image
5 rendering/page_renderer.rs:7750 annotation /AP with /D or /R but no /N identical bytes render two different images draw nothing (§12.5.5)
6 xref_reconstruction.rs:545 damaged file with two /Type /Pages inside object streams different page tree run to run same bytes, same page tree
7 structure/parser.rs:538 /K 4294967301 (2³²+5) wraps to MCID 5, aliasing the real one skip the out-of-range id

Rows 1–4 panic in any build — the indexing and arithmetic here are checked regardless of profile.

Steps to Reproduce

Rows 1–4. Build each malformed construct into a one-page PDF and call render_page. Every file parses cleanly; the failure is at paint time.

Row 5. Render the same bytes 64 times in one process and count distinct outputs. You get 2. With /N present you get 1.

Row 6. Reconstruct a damaged file containing two /Type /Pages objects packed into object streams, 64 times in one process, recording which tree is chosen.

Row 7. Give a marked-content reference /K 4294967301 alongside a genuine MCID 5 on the same page, then read back the page's marked-content ids.

Observed on v0.3.77 — verbatim output

Rows 1–5, cargo test --features rendering --test malformed_render_input:

test stitching_function_with_empty_domain_renders ... FAILED
test sampled_function_size_larger_than_stream_renders ... FAILED
test zero_width_mask_sub_image_renders ... FAILED
test separation_image_mask_negative_dimensions_render ... FAILED
test annotation_appearance_without_normal_entry_draws_nothing ... FAILED
test annotation_appearance_with_normal_entry_is_deterministic ... ok

panicked at src/rendering/mesh_shading.rs:965:32
panicked at src/rendering/mesh_shading.rs:1089:13
panicked at src/rendering/page_renderer.rs:4178:68
panicked at src/rendering/separation_renderer.rs:3062:23

annotation_appearance_without_normal_entry_draws_nothing:
  identical bytes produced 2 distinct renders   left: 2  right: 1

test result: FAILED. 2 passed; 5 failed

The two passes are controls: the /N-present annotation is already deterministic, and a stencil-mask variant I could not drive to the faulty branch.

Row 6, 64 reconstructions of the same bytes in one process:

test objstm_recovery_picks_the_same_page_tree_every_run ... FAILED
recovered page tree varies between runs:
[Some(3), Some(4), Some(3), Some(3), Some(3), Some(4), Some(3), Some(3), Some(4),
 Some(4), Some(3), Some(3), Some(3), Some(4), Some(4), Some(3), Some(3), Some(4),
 Some(4), Some(3), Some(3), Some(3), Some(3), Some(3), Some(4), Some(4), Some(3),
 ... ]

Row 7, cargo test --test structure_mcid_range:

test k_mcid_above_u32_max_is_skipped_not_wrapped ... FAILED
  left: [5, 5]            right: [5]
test negative_k_mcid_is_skipped_not_wrapped ... FAILED
  left: [4294967295, 5]   right: [5]
test out_of_range_mcr_mcid_is_skipped_not_wrapped ... FAILED
  left: [5, 5]            right: [5]
test in_range_k_mcids_are_unaffected ... ok

test result: FAILED. 1 passed; 4 failed

[5, 5] is the aliasing — the out-of-range id has landed on the page's real MCID 5, so two references now point at one id.

Expected Behavior

Malformed values should not panic. Rows 1–4 all read a value straight out of the file and use it in arithmetic with no range check. An error or a skipped construct is the right outcome.

Row 5 needs the fallback removed, not ordered. ISO 32000-1 §12.5.5 makes /N the normal appearance; /D and /R show only while the pointer presses or hovers. Drawing one of those on a static page is wrong in every iteration order, so sorting the map would just freeze a specific wrong answer.

Rows 6 and 7 should be deterministic. The same bytes should recover the same page tree, and an out-of-range marked-content id should be skipped rather than truncated onto a valid one.

Actual Behavior

Rows 1–4 panic inside render_page on a file that opened without error.

Row 5 returns one of two images depending on hash order, with no way for a caller to know which.

Row 6 returns a different page tree between reconstructions of the same bytes, so a damaged file can extract different content on consecutive runs.

Row 7 truncates the id onto a real one, leaving two marked-content references pointing at the same id.

Environment

  • pdf_oxide version: 0.3.77
  • OS: Linux x86_64 (Ubuntu 20.04)
  • Rust version: stable (rustc 1.97.1)

Checklist

  • I have searched existing issues to make sure this is not a duplicate
  • I can reproduce this reliably and have given the exact steps
  • I have not attached any copyrighted or third-party PDF
  • This report is written in my own words (not AI-generated)
  • I am using the latest version of pdf_oxide

AI disclosure: drafted with AI assistance and reviewed before filing. Every Observed block is real console output captured against v0.3.77, not reconstructed. Leaving the box above unticked rather than claiming otherwise.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions