Skip to content

stet 0.6.0 — hardening sweep, resource governor, decompression ceiling

Choose a tag to compare

@AndyCappDev AndyCappDev released this 27 Aug 06:36
· 39 commits to main since this release

A hardening release. The public Rust API is strictly additive — nothing was
removed and no signature changed — but the interpreter and the PDF reader now
refuse a number of inputs they previously accepted, which is why this is a
minor bump rather than a patch.

Breaking

Nothing here breaks compilation. Every item changes what happens to a file,
so audit these if you render input you do not control the shape of. All were
previously ways to abort the process, produce silently wrong output, or run
without bound; each is documented in full under Security below.

  • Numeric overflow now raises undefinedresult. 1e308 1e308 mul
    returned inf and inf 0 mul then returned NaN; both now error, per PLRM
    and matching Ghostscript at its own boundary. A literal 1e999 no longer
    scans as inf — it declines to be a number and becomes an undefined name.
    A program that relied on either value will now stop.
  • Non-finite path coordinates raise undefinedresult. Reachable through a
    CTM composed past the representable range. Previously drew arbitrary output.
  • VMerror now halts execution. errordict registered the handler under
    a name that could never match, so the interpreter printed the error and
    carried on past the failed allocation. Programs that appeared to survive an
    allocation failure will now stop at it.
  • PostScript VM is capped at 8 GiB by default (--max-vm,
    setuserparams /MaxLocalVM). Jobs above it raise VMerror instead of
    growing until the OS intervenes. Separate from the renderer's image and band
    buffers, so this does not cap rendering resolution.
  • Page size is bounded at 14400 pt (200 in) for PostScript input.
    Resolution is deliberately not capped — 1200 dpi at 11x17 and larger is
    ordinary prepress.
  • Image dimensions are bounded to 100,000 per side and 4e9 pixels total,
    on both the PostScript and PDF paths, with /BitsPerComponent limited to
    1..=16. Sized for prepress, not for the sample corpus: a 60x40 inch page at
    1200 dpi is 3.46 Gpx and is accepted.
  • Decompressed streams are bounded at 512 MiB, raised to whatever an image
    raster or an embedded file declares for itself. A chain of decompression
    filters no longer multiplies without limit.
  • i64::MIN -1 idiv (and mod) raise undefinedresult instead of
    panicking in release.

All 691 sample PDFs render byte-identically across every change above, the
6268-file PostScript corpus has the same 31 failures with an identical failing
set, and both visual suites pass.

Fixed

  • stet-core failed to compile for wasm32-unknown-unknown. The 8 GiB VM
    default is not a large usize on a 32-bit target but a const-evaluation
    error. The default is now computed in u64, falling back to usize::MAX / 4
    where 8 GiB does not fit.
  • currentuserparams reported MaxLocalVM as 0, telling a program there
    was no limit moments before it hit one.

Added

  • --timeout <SECONDS> and --max-vm <MB> CLI options.
  • Context::set_timeout, Context::check_deadline, Context::check_vm_alloc,
    Context::vm_bytes.
  • stet_graphics::image_limitsMAX_IMAGE_DIMENSION, MAX_IMAGE_PIXELS,
    MAX_BITS_PER_COMPONENT, and validators, shared by the PostScript and PDF
    paths so two prepress-calibrated numbers cannot drift apart.
  • stet_pdf_reader::filters::{DecodeBudget, decode_stream_bounded, MAX_DECODED_STREAM_BYTES}. decode_stream is unchanged.
  • PdfError::NestingTooDeep. PdfError is #[non_exhaustive], so this is
    not a breaking change.
  • stet-pdf-reader's parse_object_at_depth,
    parse_object_from_token_at_depth, parse_dict_body_at_depth, and
    MAX_OBJECT_DEPTH. The existing depth-0 entry points are unchanged.
  • scripts/check-cli-docs.sh, wired into CI and .githooks/pre-push: every
    CLI option must appear in --help and in both READMEs. The crates.io page
    had been listing ten of nineteen options.
  • crates/stet-cli/examples/profile_images.rs and profile_alloc.rs
    per-stage memory attribution for the render path.
  • Five cargo-fuzz targets in fuzz/, with seeded corpora and a CI smoke gate.
  • [profile.hardened] — release codegen with overflow checks left on.

Security

Five unbounded-recursion vectors in the PDF reader let a small crafted file
abort the process with a native stack overflow. A stack overflow is not a
panic, so none of these could be contained by catch_unwind — any program
rendering untrusted PDFs was exposed to an uncatchable denial of service.
This is the same vulnerability class as RUSTSEC-2026-0187 in lopdf.

Three were depth-based, and are now capped:

  • Nested arrays and dictionaries in an object body (lexer.rs).
    parse_object_from_token and parse_dict_body are mutually recursive with
    no bound, so [[[[… or <</A<</A… in any object exhausted the stack. Both
    now thread a depth counter and stop at MAX_OBJECT_DEPTH (256), returning
    the new PdfError::NestingTooDeep. The existing parse_object,
    parse_object_from_token, and parse_dict_body signatures are unchanged
    and enter at depth 0; parse_object_at_depth,
    parse_object_from_token_at_depth, and parse_dict_body_at_depth are new.
  • Nested arrays in a content stream (content/mod.rs). Content-stream
    operands go through a separate parser, parse_inline_array, which needed
    its own cap; it shares MAX_OBJECT_DEPTH.
  • Nested procedures in a Type 4 (PostScript calculator) function
    (resources/function.rs). parse_token_sequence recurses once per {
    body; now capped at MAX_CALC_DEPTH (64).

Two were cycle-based, which no depth cap alone can fix — the recursion is
infinite, so file size is irrelevant (both reproduce in under 1 KB):

  • A Type 3 stitching function that reaches itself through /Functions
    (resources/function.rs), directly or through a ring of siblings.
    PdfFunction::parse now carries a set of the object numbers on the current
    path and raises PdfError::CircularReference on re-entry. It is a path set,
    not a seen-set — entries are popped on the way out, so the legitimate shape
    /Functions [7 0 R 7 0 R] still parses and renders.
  • A Type 3 CharProc that shows its own glyph (content/mod.rs), directly
    or through a pair of fonts naming each other. This path incremented the
    interpreter's depth field but never tested it: the only check lived in
    handle_form_xobject. Type 3 glyphs and soft-mask groups — which likewise
    re-enter interpret_stream without passing through the Form XObject path —
    now check it too. The bound, MAX_CONTENT_NESTING, is 20, the value the
    Form XObject and pattern guards already used, so nothing that renders today
    changes.

Added PdfError::NestingTooDeep. PdfError is #[non_exhaustive], so this
is not a breaking change.

Separately, image dictionary integers are now validated before use.

  • /Width and /Height were cast with as u32 and then multiplied in
    u32.
    The product overflowed: 65537 x 65536 is 2^32 + 65536, so
    width * height came back as 65536 and the buffer allocated from it was far
    smaller than the loops that filled it — an "attempt to multiply with
    overflow" panic in debug builds, a silently undersized allocation in
    release. The truncating cast was wrong on its own too: /Width 4294967297
    became a 1-pixel image rather than an error.
  • The loop counts alone were a denial of service. Even where the
    arithmetic survived, an 800-byte file declaring a 65537 x 65536 image spent
    9-19 seconds in release. It now completes in 0.1 s.
  • Both are fixed by validating at the four points where an image dictionary is
    read (image XObject, inline image, /SMask, /Mask): dimensions must be
    positive and at most MAX_IMAGE_DIMENSION (100,000), and their product at
    most MAX_IMAGE_PIXELS (4,000,000,000). The ceiling is sized for prepress
    rather than for the sample corpus: a 40x28 inch press sheet at 600 dpi is
    403M pixels, an A0 poster at 600 dpi 558M, and 60x40 inch grand format at
    1200 dpi 3.46G, all of which a RIP must accept. It stays under 2^32 because
    a dozen sites compute width * height in u32; anything multiplying
    further by a component count uses saturating usize.
  • /BitsPerComponent is validated too, to 1..=16. It reaches
    1u32 << bpc in expand_bits_to_bytes, which panics in debug builds at 32
    or more. That function now also reserves its three-way
    width * height * components product in usize, which overflows a u32
    sooner than the two-way one does.

Filter and font parameters are now validated the same way.

  • /Columns, /Colors, and /BitsPerComponent in /DecodeParms were
    cast straight to usize and multiplied. A zero in any of them drove
    row_bytes to zero and reached slice::chunks(0) — "chunk size must be
    non-zero", which panics in release builds, not only debug. A negative
    became astronomical under the cast and aborted the process on a 2.3-exabyte
    reservation. Both are now range-checked with the row-size products computed
    via checked_mul; a malformed /DecodeParms leaves the stream unchanged
    rather than failing it, which is what the caller would have had if
    /Predictor were absent.
  • PS CIDFont header counts (/CIDCount, /SubrCount, /FDBytes,
    /GDBytes, /SDBytes). /SubrCount was passed to Vec::with_capacity
    before the bounds check that would have rejected it, so a bogus count
    panicked with "capacity overflow" in release as well as debug. Separately,
    FDBytes + GDBytes == 0 made the CID map size zero for any /CIDCount, so
    the "binary data too short" check passed and an 8 TB reservation followed
    from a 700-byte file. Counts are now bounded against the binary segment
    actually present rather than against a fixed ceiling, the byte-widths are
    capped at 8, and the reservation happens after the check.

Neither bound rejects anything real: all 691 sample PDFs were re-rendered with
the predictor fallback instrumented, and none takes it.

The font parsers in stet-fonts got the same treatment. Font programs arrive
embedded in both PDF and PostScript input, so these are attacker controlled in
the same way a PDF object is.

  • TrueType composite glyph recursion — a component naming its own glyph,
    directly or through a ring, recursed until the stack was gone. Now capped at
    depth 8 with a path set of glyph ids, popped on exit so a font that
    legitimately reuses one accent twice still renders both copies. A depth cap
    alone is not sufficient here: a composite naming many components, each itself
    such a composite, repeats no id on any path, and the work is
    fan_out ^ depth — 64 components at depth 8 is 2.8e14 expansions from a
    400-byte glyph. A shared expansion budget (4096) bounds the total work.
  • Type 1 seac re-entered through execute(), which restarts the
    subroutine depth counter at 0, so the existing depth-10 guard never fired on
    a seac naming its own glyph. The depth is now threaded through.
  • /Subrs N reserved N entries before reading any of them;
    /Subrs 999999999 panicked with "capacity overflow" (≈24 GB in release).
    Clamped to the bytes remaining after the marker, since each entry needs at
    least a dup i n RD introducer.
  • cmap format 12 walked for code in start..=end over raw u32 — 4.3
    billion iterations for a full-range group — and computed
    start_gid + (code - start_char) as an unchecked u32 add. The span is now
    clamped to 0xFFFF (past which no glyph id can land in the 16-bit range
    anyway, so nothing mappable is lost) and the add is checked.
  • Type 2 callsubr / callgsubr computed idx + bias as an unchecked
    i32 add. The number encodings top out at 32767, but Type 2 implements add,
    sub, mul, and div, so a charstring can multiply past i32::MAX, where
    the as i32 cast saturates and the bias add overflows. Now checked_add.
  • read_u16 / read_i16 / read_u32 are now internally bounds-checked,
    returning 0 past the end of the slice. No caller changes: the ~40 call sites
    already pre-check (confirmed by probing every truncation of a synthetic font
    and 408 mutations of its offset and count fields, with zero panics), but the
    invariant was manual and unenforced.

All 691 sample PDFs render byte-identically before and after these font
changes.

Decompressed stream size is now bounded, closing a decompression-bomb vector.
decode_stream applied its filter chain with no ceiling on the output, so the
amplification was unbounded and multiplicative: a single Deflate pass tops
out near 1032:1 on a run of zeros, but a 707-byte file carrying
/Filter [/FlateDecode /FlateDecode /FlateDecode] measured a 2058 MB peak RSS
here, and aborted with memory allocation of N bytes failed — a core dump, not
a catchable error — as soon as the address space could not satisfy it. Rust
aborts on allocation failure, so like the recursion vectors above this had to
be prevented rather than handled.

  • The whole chain now shares one budget, rather than each filter starting
    fresh, which is what stops nesting from multiplying. FlateDecode,
    LZWDecode, and RunLengthDecode check it from inside their decode loops —
    checking the finished buffer would mean the allocation the ceiling exists to
    prevent has already happened — and each stage's result is checked afterwards
    as well, covering the image codecs that size their own output.
  • A budget overrun is an error, never a truncation. decode_flate
    recovers from a genuinely truncated stream by retrying it as raw deflate and
    keeping the longer result; without care an overrun would have taken that
    path and come back as a silently truncated success.
  • The ceiling is raised by what the stream declares about itself. The
    general allowance, MAX_DECODED_STREAM_BYTES, is 512 MiB, which covers
    content streams, object and cross-reference streams, font programs, ICC
    profiles, and sampled-function tables with roughly 4x headroom over the
    largest of those. A dictionary that declares an image raster (/Width,
    /Height, /BitsPerComponent, /ColorSpace) or an attachment length
    (/Params /Size) gets that instead, so a 60x40 inch grand-format image at
    1200 dpi — a legitimate 13.8 GB stream — is unaffected. The declared value
    only ever raises the bound, never lowers it, so a stream that declares
    nothing, or declares something small, keeps the full general allowance.

New public API: DecodeBudget, decode_stream_bounded, and
MAX_DECODED_STREAM_BYTES in stet_pdf_reader::filters. decode_stream is
unchanged and now decodes under the general ceiling.

All 691 sample PDFs render byte-identically before and after this change.

The 8 GiB PostScript VM default broke the wasm32-unknown-unknown build.
8 * 1024 * 1024 * 1024 does not fit a 32-bit usize, and const evaluation
rejects it outright, so stet-core failed to compile for that target at all —
error[E0080]: attempt to compute 8388608_usize * 1024_usize, which would overflow. The default is now computed in u64 and falls back to
usize::MAX / 4 where 8 GiB does not fit: on a 32-bit target the whole
address space is 4 GiB, so an 8 GiB ceiling would be no ceiling at all, and a
quarter of the space leaves the rest for the renderer's buffers, the module,
and the stack. The 64-bit value is unchanged.

VMerror was raised under a name nothing could catch. errordict registered
the handler as /VMError while PsError::VMError displays as VMerror
PLRM's spelling, used 35 times there, and Ghostscript's. The lookup missed, so
the interpreter printed the error and continued past the failed
allocation
, leaving the program running as though it had succeeded. That was
harmless while the variant had no producer and became reachable the moment
--max-vm started raising it. Now stopped catches it and $error /errorname reports /VMerror.

currentuserparams reported MaxLocalVM as 0. The ceiling can be set three
ways — the built-in default, --max-vm, and setuserparams — and only the
last writes the dict the query copied, so a program asking for the limit was
told there was none moments before hitting one. It now reports the value
actually in force.

Non-finite numbers and integer-overflow traps in the PostScript interpreter.
The backlog listed this as cosmetic — "garbage output rather than a panic" —
which was wrong in both directions: one case was a release-mode crash, and the
rest were a PLRM conformance gap rather than a cosmetic one.

  • -9223372036854775808 -1 idiv panicked in release. i64::MIN / -1 is
    the one pair that overflows, and integer division overflow is a trap in
    Rust's semantics rather than something overflow-checks enables, so this
    aborted an optimised build from 30 bytes of PostScript. idiv and mod now
    use checked_div / checked_rem and raise undefinedresult.
  • Real overflow produced inf instead of an error. PLRM: "A numeric
    computation would produce a meaningless result or one that cannot be
    represented as a number. Possible causes include numeric overflow or
    underflow, division by 0…" — and every arithmetic operator that can return a
    real lists undefinedresult among its errors. 1e308 1e308 mul yielded
    inf, and inf 0 mul then yielded NaN. add, sub, mul, div, and
    exp now raise undefinedresult when the result is not finite, matching
    Ghostscript, which does the same at its own (single-precision) boundary.
    stet's boundary is f64's, as with the i64 integer width: PLRM Appendix B
    lists real limits under "Typical Limits" as properties of the host
    architecture, not as conformance requirements.
  • A literal 1e999 scanned straight to inf, introducing a non-finite
    value with no arithmetic at all — "1e999".parse::<f64>() succeeds. The
    scanner now declines such a token, which falls through to the name scanner
    exactly as 1e999x already did, so a program using one gets undefined
    rather than a value. Ghostscript raises limitcheck here and stet
    deliberately does not: that was tried first and it broke a 35 MB corpus file
    that renders correctly, whose hex image data contains byte runs such as
    5657564e574 — syntactically a real with a 580-digit exponent, scanned and
    discarded harmlessly as a name.
  • Path construction rejects non-finite device coordinates. With the two
    sources above closed, a NaN could still arrive through a CTM composed past
    the representable range (1e300 1e300 scale twice). moveto, rmoveto,
    lineto, rlineto, curveto, rcurveto, arc, arcn, arcto, and
    arct now raise undefinedresult instead, which is the error PLRM assigns
    to graphics operators under an unusable CTM. The check is on the path rather
    than on the matrix operators because composing a wild CTM is not itself an
    error — a program may scale extravagantly, draw nothing, and grestore.
    This also closes a latent hang: arc normalises with
    while stop < start { stop += 360.0 }, which never terminates for a stop
    of negative infinity.

A NaN reaching geometry never crashed — it makes every comparison against it
false, so bounds, banding, and winding quietly take the wrong branch. Silent
wrong output was the real exposure.

All 691 sample PDFs render byte-identically. The 6268-file PostScript corpus
has the same 31 failures before and after, with no file newly failing; all 86
ps_samples and the unit_tests/ suite pass unchanged.

Added

  • A ceiling on PostScript VM, via Context::max_local_vm,
    setuserparams /MaxLocalVM, and the CLI's --max-vm <MB>. Exceeding it
    raises PsError::VMError, which previously had no producer.
    The default is 8 GiB rather than unlimited: a failed allocation aborts
    the process, so there is no error to catch afterwards and an opt-in limit
    would leave the abort reachable by default. 500000000 array requested
    16 GB and took stet down; it now raises VMerror. This bounds PostScript VM
    — strings, arrays, dictionaries — which is a separate pool from the
    renderer's band and image buffers.

    The check measures reserved capacity rather than length, and bounds what may
    be requested rather than what is held: the arena stores grow geometrically,
    so one sitting at capacity asks the allocator for roughly twice that. Steady
    growth therefore stops at about half the nominal ceiling; a single large
    request is bounded by the full one. It also counts global VM, unlike PLRM's
    local-only MaxLocalVM, since a local-only ceiling is sidestepped with
    true setglobal.

  • --timeout <SECONDS> and Context::set_timeout — a wall-clock deadline
    for interpretation, raising PsError::Timeout. PostScript is
    Turing-complete, so nothing static bounds how long a program runs, and a
    deadline is the only thing that stops one which makes progress but never
    terminates. There is no limit by default, preserving existing REPL and
    CLI behaviour; set one when the input is untrusted. The check counts down a
    u32 and consults the clock every 4096 iterations, and short-circuits when
    no deadline is set, so the default path measures as free (-0.42% on a tight
    4M-iteration arithmetic loop).

Added

  • Fuzzing (fuzz/) — five cargo-fuzz targets covering the parsers that
    consume untrusted input: fuzz_pdf_parse (open + render + the structural
    API), fuzz_font_truetype, fuzz_font_cff, fuzz_font_type1, and
    fuzz_ps_tokenizer. fuzz/seed-corpus.sh seeds them from the in-tree
    samples (703 PDFs, 6410 PostScript inputs, 35 Type 1 faces) and
    fuzz/run.sh runs them with settings suited to a sanitizer build. The crate
    is excluded from the workspace, like stet-wasm, because cargo-fuzz needs
    nightly and stet is stable-only with a pinned MSRV. A weekly scheduled
    workflow (.github/workflows/fuzz.yml) runs 300s per target; it is not on
    the push path, where it gated nothing and dominated the run. See
    fuzz/README.md.

Added

  • [profile.hardened] — release codegen with overflow-checks and
    debug-assertions left on, for finding silent arithmetic wraps at release
    speed. Build with cargo build --profile hardened. It is a testing profile,
    not a shipping one: published binaries stay on release, since a trapped
    overflow is a panic and that is not what a renderer should do to a user over
    a malformed file. Gated in CI by a new Overflow checks job. The vendored
    stet-tiny-skia forks are excluded per-package — their SIMD-lane emulation
    is modular arithmetic by definition, matching the hardware instructions the
    aarch64 paths use.

Fixed

  • The PostScript image, imagemask, and colorimage operators had no
    upper bound on their dimensions.
    Only the lower bound was checked, so
    sixty bytes of PostScript could request a 4 x 10^18 byte allocation, which
    aborts the process rather than failing catchably. All three now validate
    against the shared prepress-scale limits: a non-positive dimension raises
    rangecheck and one past the ceiling raises limitcheck, per PLRM.
    A 24000 x 16800 press sheet (403M pixels) still draws normally.

  • Image size limits moved to stet_graphics::image_limits, shared by the
    PostScript operators and the PDF image handler. The two crates cannot see
    each other, and duplicating a prepress-calibrated ceiling would let the two
    copies drift.

  • setpagedevice with a degenerate /PageSize panicked the renderer.
    << /PageSize [-1 -1] >> reached Pixmap::new(0, 0), which returns None,
    through an .expect(). The allocation is now non-panicking, and
    setpagedevice clamps a file-declared page to 14400 pt per side (200 in,
    the Adobe PDF 1.7 /MediaBox implementation limit), falling back to US
    Letter for a non-finite or non-positive value. Render resolution is
    deliberately not capped
    — pixel dimensions are points * dpi / 72, and
    while the points come from the file, the DPI is the caller's explicit
    request; a 1200 dpi proof of a large-format page is a legitimate gigapixel
    render.

  • Two native-stack recursion vectors in the interpreter. A /Separation
    colour space whose tint transform sets that same colour space re-entered
    exec_sync without bound — about 200 bytes of PostScript aborted the
    process. exec_sync is now capped at depth 100 via Context::exec_sync_depth,
    raising PsError::ExecStackOverflow. Separately, parse_procedure and
    stream_parse_procedure had no {-nesting cap, so 200000 nested braces
    (a 400 KB file) aborted; both now stop at MAX_PROC_DEPTH (100), matching
    the existing MAX_BOS_DEPTH.

  • Four unchecked u16 range ends in the CFF parser (charset formats 1 and
    2, CID-map formats 1 and 2). for sid in first..=first + n_left overflows
    when a range starts near 0xFFFF — a panic under overflow checks, a wrapped
    range otherwise. Found by cargo fuzz within 60s of a cold start. The same
    pass fixed n_glyphs - 1 in both format 0 readers, which underflows usize
    for a font declaring zero glyphs.

  • Octal escapes in PDF literal strings (\ddd) accumulated into a u8,
    so a three-digit escape above \377 overflowed the accumulator. The
    rendered byte was already correct — PDF 32000-1 7.3.4.2 specifies that
    high-order overflow is ignored, which is what the release build's silent
    wrap produced — but the arithmetic was wrong and panicked under overflow
    checks. Found by sweeping the sample corpus under the new hardened
    profile (pdf_samples/142.pdf).

  • Shading color-stop sampling now sorts with f64::total_cmp instead of
    partial_cmp().unwrap(), and clamps its own sample count so the divisor in
    i / (n - 1) cannot be zero. No crafted file was found that reaches either
    path — the discontinuity filter excludes NaN and callers already clamp the
    count — so this is hardening against a future caller, not a live fix.