`cargo xtask fuzz` found a 1.1 KB page whose JBIG2 image never finished
decoding (over two minutes, flat memory). It differs from a valid file by
one byte inside a symbol dictionary's arithmetic-coded data. Past the end
of its data the arithmetic decoder keeps returning values, and two loops
in symbol.rs only move on when those values make progress:
- Export runs (6.5.10): a run of length 0 doesn't advance, and the
decoder returned 0 for ever. This was the fuzz finding.
- Height classes (6.5.5): a class whose first width is already the
end-of-class marker decodes no symbol. A read-only sweep of the crate
found this one. In a synthetic dictionary every class after the first
symbol adds 13 to the height and decodes nothing, so it only fails
when the height overflows u32, after 330 million classes (about five
seconds per image, logged); a delta of 0 would never end.
The sweep found every other loop in hayro-jbig2 bounded by a symbol,
instance or pixel cap or by the end of the data. Bounded is not cheap
everywhere: refinement bitmaps in text regions and aggregate symbols
(up to 10,000 instances of up to 2^28 pixels each) are not changed
here.
Vendored hayro-jbig2 patch (3): a valid dictionary needs at most one
height class per new symbol and `2 x symbols + 1` export runs (they
alternate, so at most one zero-length run before each symbol's). Both
loops get that budget plus `EXTRA_EMPTY_STEPS` (1024), then fail the
dictionary as invalid. Encoders don't write empty steps back to back,
so the slack only tolerates oddities.
The fuzz finding now renders in 0.2 s. Synthetic regression test in
render: two dictionaries of a few arbitrary bytes each, found by
searching for streams that decode the same way (no corpus bytes). The
height-class one is drawn as twenty images so that it stalls well past
the 20 s timeout without the patch on any machine. Each case is red
with its own budget removed and green with it.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>