Fuzzing¶
The fuzz/ directory holds cargo-fuzz
targets for every parser in the repository that takes outside input. They
run every night in CI (.github/workflows/fuzz.yml). This
is the fuzzing item of the 1.0 plan's
Stage 4, brought forward to
0.0.23 (item 18).
This is a different tool from the differential fuzzer. That one checks that our output matches Python rich's. These targets check that no input makes a parser panic, hang or run out of memory.
The targets¶
fuzz/ is its own workspace, with its own Cargo.lock, as cargo fuzz init
makes it, so the main workspace never builds it. Each target uses the
crates' public entry points. Rendering targets cut their input to 4 KiB and
draw on a console 1 to 160 columns wide, so a big input that is legitimately
slow is not reported as a timeout. Parse-only targets read up to 64 KiB. The
bounds are in fuzz/src/lib.rs.
| Target | Crate | What it feeds |
|---|---|---|
markup |
rs-rich | Text::from_markup, markup::render, Style::parse, then a render |
ansi |
rs-rich | AnsiDecoder, then a render |
markdown |
rs-rich | Markdown::new, then a render (no syntect) |
json |
rs-rich | Json::new, then a render |
dot |
rs-rich-diagram | the DOT parser, then the layout and drawing |
mermaid |
rs-rich-mermaid | the flowchart, sequence and ER parsers, then Mermaid drawn as text |
csv |
rs-rich-data | the sniffer, CsvReader::read and source (CSV and TSV), then type inference |
jsonl |
rs-rich-data | jsonl::read and jsonl::source, then type inference |
stylesheet |
rs-rich-intuituive | Stylesheet::parse |
vt |
rs-rich-record | the VT emulator (Terminal::process, in chunks, at a size a tape may ask for), then the screen's text and snapshot |
tape |
rs-rich-record | the tape parser |
web_input |
rs-rich-web | the input decoder (input::decode) |
web_frames |
rs-rich-web | WebSocket frames read as the server reads them (tungstenite, the server's limits), each message's input decoded |
marketplace_documents |
rs-rich-marketplace | a listing (then version resolution), a lockfile, a keys-N.json, a minisign.pub, a key id |
marketplace_signature |
rs-rich-marketplace | a .minisig file, against a real index and a key rotation |
marketplace_signed |
rs-rich-marketplace | an index.json and a keys-N.json, signed with the test keys so the parsers behind the signature check are reached |
marketplace_artifact |
rs-rich-marketplace | a micro pack's zip and pack.json, a theme file, a WASM header |
plugin_manifest |
rs-rich-plugin-api | the ABI's manifest text; one that parses must read back the same after to_manifest |
plugin_wasm |
rs-rich-ext | a WASM plugin loaded with the default limits (fuel cut to 5 million per call), each capability called |
terminal_host |
rs-rich-interact | the terminal host client's reply parser (host::parse_reply), then a reply's panes (host::panes_of) |
Not reached, because they are crate-private: rs-rich-web's own message
parser (parse_message) and HTTP request-head reader. web_frames reaches
only the input decoder behind them. A native (dylib) plugin's descriptor is
raw pointers and cannot be fuzzed this way.
Each target has a small seed corpus in fuzz/seeds/<target>/, taken from
the crates' test fixtures (golden markup and Markdown, DOT and tape files,
the CSV and JSONL fixtures, the example stylesheets, the listing examples,
the example WASM plugins). The first byte of a rendering target's input is a
parameter (the width, ASCII or box drawing), so its seeds start with one.
Only seeds are committed: the corpus a run grows stays in
fuzz/corpus/, which Git ignores, and in CI's cache.
Running a target locally¶
cargo-fuzz needs a nightly toolchain. CI pins both:
rustup toolchain install nightly-2026-05-20
cargo install --locked cargo-fuzz --version 0.13.2
cargo +nightly-2026-05-20 fuzz list
cargo +nightly-2026-05-20 fuzz run markup fuzz/corpus/markup fuzz/seeds/markup -- -max_total_time=60
The first directory is where new inputs are written, the second is read
only. Without -max_total_time a run goes on until it finds something or
is stopped. Building every target with sanitizers takes about 2.5 GB in
fuzz/target.
Triaging a finding¶
A failing run writes the input to fuzz/artifacts/<target>/ (crash-…,
timeout-… or oom-…). In CI the job fails, the input is the
fuzz-artifacts-<target> artifact, and the job summary says how to
reproduce it.
- Reproduce it:
cargo +nightly-2026-05-20 fuzz run <target> fuzz/artifacts/<target>/<file>runs that one input and prints the panic and its backtrace. - Shrink it:
cargo +nightly-2026-05-20 fuzz tmin <target> <file>. - Decide where it belongs:
- A bug in our code: fix it with a regression test in the crate, with
the shrunk input as a literal. In
crates/rich, compare with Python rich at the pinned version first: the fix must keep parity, and the test says it was captured from rich 15.0.0. - A bug in a dependency (vt100, pulldown-cmark, tungstenite, wasmi, zip): report it upstream, and guard the call in our crate if that is cheap.
- A slow input that is legitimate (a timeout on a large drawing): bound the work in the crate if a user could hit it. Do not lower the target's bounds to hide it.
- Record it until it is fixed, in Known findings below, with the target, the input and the issue. A finding stays open until its fix is merged and the target runs clean on it.
Never weaken a target to make a finding go away.
The four-week clock¶
The 1.0 exit criteria ask that the
fuzz targets have "run in nightly CI for at least four weeks without an open
finding". The clock starts with the first scheduled run of fuzz.yml on
main after the pull request that brings it in merges, expected the night
of the merge (the schedule is 01:43 UTC). Record that date here when it
happens:
- Before it landed (2026-10-26, locally, nightly-2026-05-20 and cargo-fuzz 0.13.2): every target ran 60 seconds, which found the three bugs below, then 120 seconds each after the fixes, clean.
- First nightly run: 2026-10-28, on
mainat2624ce5b. Clean for six nights, to 2026-11-02. - Stopped: 2026-11-03, by the
stylesheetfinding below (fixed the same day). The clock starts again with the first clean nightly run after the fix merges. - Earliest the criterion can be met: four weeks after that date, with no open finding below. A new finding restarts the clock once it is fixed.
Known findings¶
None open.
Fixed after the targets landed, with a regression test in its crate:
stylesheet(2026-11-03, 0.0.25): a timeout, 12 seconds and 1.4 GB on 921 bytes of nested&and,lists. intuiTUIve's parser read each nested rule with each of its parent's selectors, and replaced each&with the parent's text, so selectors grew exponentially with nesting (flattenand its caller insheet.rs). Nesting is now at most 32 deep and the text it and variables expand to at most four times the stylesheet (or 64 KiB), past which it does not parse; a body is read in one pass. The input istests/fixtures/fuzz/stylesheet-timeout.css(rs-rich-intuituive'sstylesheet_boundstests) and a seed,fuzz/seeds/stylesheet/timeout-2026-11-03.tcss.
Fixed while the targets were written (0.0.23), each with a regression test in its crate:
ansi:AnsiDecoderpanicked on a multi-byte character after a charset-select escape (ESC (), slicing inside the character. It now skips one character, as upstream does (rs-rich,ansi.rs, with a test captured from rich 15.0.0).plugin_wasm: a plugin that loops overmemory.growwith a register operand overflowed the host's stack and aborted the process, in an optimised build only. wasmi 2.0.0's tail-call dispatch grew the stack on every instruction there. rs-rich-ext now builds wasmi with its portable (loop) dispatch, which is slower but cannot do that. The test fails without the fix only undercargo test --release: a debug build always used the portable dispatch. So CI'swasm-plugins-releasejob runs rs-rich-ext'swasm_pluginstests, and the CLI's tests that load WASM plugins (plugin_marketplace,official_artifacts,plugins), with--releaseon every pull request.plugin_wasm: wasmi 2.0.0 divided by zero (a panic) on a code section that declares no functions but holds bytes. rs-rich-ext now validates a module with wasmparser (the parser wasmi uses, at its version) first, which refuses that section.
Recorded, not a finding of our code:
- vt100 0.16 panics on a wide character in a one-column screen, and on a
resize that cuts a wide character at the right edge. rs-rich-record
catches the panic and reports it as an error (tested by
a_vt100_panic_is_an_error), rs-rich-embed starts a blank screen, and a tape cannot ask for fewer thantape::MIN_COLUMNS(2) columns. Thevttarget therefore uses tape sizes and does not resize. Lifting either limit needs a fixed vt100.
The two wasmi bugs are worth reporting upstream; neither is reported yet.