stet 0.6.0 — hardening sweep, resource governor, decompression ceiling
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
returnedinfandinf 0 multhen returnedNaN; both now error, per PLRM
and matching Ghostscript at its own boundary. A literal1e999no longer
scans asinf— 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. VMerrornow halts execution.errordictregistered 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 raiseVMerrorinstead 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/BitsPerComponentlimited 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(andmod) raiseundefinedresultinstead 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-corefailed to compile forwasm32-unknown-unknown. The 8 GiB VM
default is not a largeusizeon a 32-bit target but a const-evaluation
error. The default is now computed inu64, falling back tousize::MAX / 4
where 8 GiB does not fit.currentuserparamsreportedMaxLocalVMas 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_limits—MAX_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_streamis unchanged.PdfError::NestingTooDeep.PdfErroris#[non_exhaustive], so this is
not a breaking change.stet-pdf-reader'sparse_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--helpand in both READMEs. The crates.io page
had been listing ten of nineteen options.crates/stet-cli/examples/profile_images.rsandprofile_alloc.rs—
per-stage memory attribution for the render path.- Five
cargo-fuzztargets infuzz/, 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_tokenandparse_dict_bodyare mutually recursive with
no bound, so[[[[…or<</A<</A…in any object exhausted the stack. Both
now thread a depth counter and stop atMAX_OBJECT_DEPTH(256), returning
the newPdfError::NestingTooDeep. The existingparse_object,
parse_object_from_token, andparse_dict_bodysignatures are unchanged
and enter at depth 0;parse_object_at_depth,
parse_object_from_token_at_depth, andparse_dict_body_at_depthare 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 sharesMAX_OBJECT_DEPTH. - Nested procedures in a Type 4 (PostScript calculator) function
(resources/function.rs).parse_token_sequencerecurses once per{
body; now capped atMAX_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::parsenow carries a set of the object numbers on the current
path and raisesPdfError::CircularReferenceon 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'sdepthfield but never tested it: the only check lived in
handle_form_xobject. Type 3 glyphs and soft-mask groups — which likewise
re-enterinterpret_streamwithout 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.
/Widthand/Heightwere cast withas u32and then multiplied in
u32. The product overflowed: 65537 x 65536 is2^32 + 65536, so
width * heightcame 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 mostMAX_IMAGE_DIMENSION(100,000), and their product at
mostMAX_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 computewidth * heightinu32; anything multiplying
further by a component count uses saturatingusize. /BitsPerComponentis validated too, to 1..=16. It reaches
1u32 << bpcinexpand_bits_to_bytes, which panics in debug builds at 32
or more. That function now also reserves its three-way
width * height * componentsproduct inusize, which overflows au32
sooner than the two-way one does.
Filter and font parameters are now validated the same way.
/Columns,/Colors, and/BitsPerComponentin/DecodeParmswere
cast straight tousizeand multiplied. A zero in any of them drove
row_bytesto zero and reachedslice::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
viachecked_mul; a malformed/DecodeParmsleaves the stream unchanged
rather than failing it, which is what the caller would have had if
/Predictorwere absent.- PS CIDFont header counts (
/CIDCount,/SubrCount,/FDBytes,
/GDBytes,/SDBytes)./SubrCountwas passed toVec::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 == 0made 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
seacre-entered throughexecute(), which restarts the
subroutine depth counter at 0, so the existing depth-10 guard never fired on
aseacnaming its own glyph. The depth is now threaded through. /Subrs NreservedNentries before reading any of them;
/Subrs 999999999panicked with "capacity overflow" (≈24 GB in release).
Clamped to the bytes remaining after the marker, since each entry needs at
least adup i n RDintroducer.- cmap format 12 walked
for code in start..=endover 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/callgsubrcomputedidx + biasas an unchecked
i32 add. The number encodings top out at 32767, but Type 2 implementsadd,
sub,mul, anddiv, so a charstring can multiply pasti32::MAX, where
theas i32cast saturates and the bias add overflows. Nowchecked_add. read_u16/read_i16/read_u32are 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, andRunLengthDecodecheck 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 idivpanicked in release.i64::MIN / -1is
the one pair that overflows, and integer division overflow is a trap in
Rust's semantics rather than somethingoverflow-checksenables, so this
aborted an optimised build from 30 bytes of PostScript.idivandmodnow
usechecked_div/checked_remand raiseundefinedresult.- Real overflow produced
infinstead 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 listsundefinedresultamong its errors.1e308 1e308 mulyielded
inf, andinf 0 multhen yieldedNaN.add,sub,mul,div, and
expnow raiseundefinedresultwhen the result is not finite, matching
Ghostscript, which does the same at its own (single-precision) boundary.
stet's boundary isf64's, as with thei64integer width: PLRM Appendix B
lists real limits under "Typical Limits" as properties of the host
architecture, not as conformance requirements. - A literal
1e999scanned straight toinf, 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 as1e999xalready did, so a program using one getsundefined
rather than a value. Ghostscript raiseslimitcheckhere 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, aNaNcould still arrive through a CTM composed past
the representable range (1e300 1e300 scaletwice).moveto,rmoveto,
lineto,rlineto,curveto,rcurveto,arc,arcn,arcto, and
arctnow raiseundefinedresultinstead, 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 mayscaleextravagantly, draw nothing, andgrestore.
This also closes a latent hang:arcnormalises with
while stop < start { stop += 360.0 }, which never terminates for astop
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
raisesPsError::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 arrayrequested
16 GB and took stet down; it now raisesVMerror. 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-onlyMaxLocalVM, since a local-only ceiling is sidestepped with
true setglobal. -
--timeout <SECONDS>andContext::set_timeout— a wall-clock deadline
for interpretation, raisingPsError::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
u32and 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/) — fivecargo-fuzztargets 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.shseeds them from the in-tree
samples (703 PDFs, 6410 PostScript inputs, 35 Type 1 faces) and
fuzz/run.shruns them with settings suited to a sanitizer build. The crate
is excluded from the workspace, likestet-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 withoverflow-checksand
debug-assertionsleft on, for finding silent arithmetic wraps at release
speed. Build withcargo build --profile hardened. It is a testing profile,
not a shipping one: published binaries stay onrelease, 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 newOverflow checksjob. The vendored
stet-tiny-skiaforks 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, andcolorimageoperators 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
rangecheckand one past the ceiling raiseslimitcheck, 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. -
setpagedevicewith a degenerate/PageSizepanicked the renderer.
<< /PageSize [-1 -1] >>reachedPixmap::new(0, 0), which returnsNone,
through an.expect(). The allocation is now non-panicking, and
setpagedeviceclamps a file-declared page to 14400 pt per side (200 in,
the Adobe PDF 1.7/MediaBoximplementation limit), falling back to US
Letter for a non-finite or non-positive value. Render resolution is
deliberately not capped — pixel dimensions arepoints * 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_syncwithout bound — about 200 bytes of PostScript aborted the
process.exec_syncis now capped at depth 100 viaContext::exec_sync_depth,
raisingPsError::ExecStackOverflow. Separately,parse_procedureand
stream_parse_procedurehad no{-nesting cap, so 200000 nested braces
(a 400 KB file) aborted; both now stop atMAX_PROC_DEPTH(100), matching
the existingMAX_BOS_DEPTH. -
Four unchecked
u16range ends in the CFF parser (charset formats 1 and
2, CID-map formats 1 and 2).for sid in first..=first + n_leftoverflows
when a range starts near 0xFFFF — a panic under overflow checks, a wrapped
range otherwise. Found bycargo fuzzwithin 60s of a cold start. The same
pass fixedn_glyphs - 1in both format 0 readers, which underflowsusize
for a font declaring zero glyphs. -
Octal escapes in PDF literal strings (
\ddd) accumulated into au8,
so a three-digit escape above\377overflowed 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 newhardened
profile (pdf_samples/142.pdf). -
Shading color-stop sampling now sorts with
f64::total_cmpinstead 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.