CORE-010: Fuzz targets for the engine boundary and CRDT updates
| Field | Value |
|---|---|
| Phase | 0 — Bedrock |
| Stream | SECURITY / MODEL |
| Repository | bayan-core (fuzz/, .github/workflows/fuzz.yml) |
| Attach to session | bayan-core, docs |
| Size | M |
| Depends on | CORE-004, CORE-005, CORE-006, CORE-007; the written task that keeps fuzzing failures private (the follow-ups of docs#55) |
| Unblocks | ADR-0008 decision 4 (“the import path is fuzzed”); CORE-101 and CORE-125, which extend these targets |
| Status | Ready |
| Requirements | SEC-07, SEC-13, REL-05, ARC-07 |
| Decisions | ADR-0006, ADR-0008, ADR-0012, ADR-0017, ADR-0025 |
| Specs | engine-protocol.md §10, §12, §13, threat-model.md §11 (T1, T3, T4, T7) |
Context
Fuzzing feeds a parser millions of random and mutated inputs to find crashes, hangs and runaway memory. The architecture review of 2026-10-27, finding H2, found that fuzzing never ran in CI and that four kinds of untrusted input had no fuzz target at all.
What is fixed. bayan-core#21 (c19898b, 2026-10-27) added .github/workflows/fuzz.yml and fuzz/ci.py: every target is built with AddressSanitizer and runs for 150 seconds on each pull request that changes bayan-opc, bayan-xml, fuzz/, the root Cargo.toml or .cargo/, and for 15 minutes every night from a corpus that grows from night to night (the two default-limits targets twice as long); ci.py refuses to run when a target of fuzz/Cargo.toml has no settings. bayan-core#22 runs the supply-chain checks on fuzz/Cargo.lock first. There are ten targets today, all for bayan-opc and bayan-xml: zip_archive, package, content_types, relationships, core_properties, compound_file, zip_archive_default_limits, package_default_limits, xml_reader and markup_compatibility (fuzz/README.md).
What is left of H2 (checked on 3c5a18f):
- Engine protocol messages, tile requests and the configuration have no target. The engine’s hostile-input test still calls itself “a deterministic, bounded stand-in for fuzzing until the fuzzing workspace exists” (hostile_input.rs:3). The entry points are
Engine::handle,Engine::render_tile(the request behindbayan_render_tile) andConfig::from_json. - Recordings that
diag.replayreads have no target, although the spec treats them as untrusted (§10); the entry point isrecording::replay. - CRDT updates from other people have no target. The only fuzzer is the CORE-004 spike’s own mutator (spikes/crdt-model/src/fuzz.rs), written before the owner accepted libFuzzer’s license. The entry point is bayan-crdt’s
Doc::importwith its presetsImportLimits::UPDATEandImportLimits::LOCAL_SNAPSHOT.importcontains Loro’s panics and then poisons the document (ImportError::Panicked); the review’s finding M2 adds three entry points without that guard, which CORE-101 will guard. - The Fidelity Lab’s own ZIP, package and XML readers have no target. CORE-011 removes them in favour of bayan-opc and bayan-xml, which are fuzzed, and adds a target for the scanner, so this work package leaves them alone.
- Crash reports are public. H2’s recommendation 2 said that a crash is handled as a security report, never uploaded as a public artifact. bayan-core#21 chose otherwise: when a run fails,
fuzz.ymluploads the failing inputs and every target’s log as the artifactfuzzing-failuresfor 30 days (fuzz.yml:137), which anyone signed in to GitHub can download from a public repository, and before thatfuzz/ci.py’sshowprints every target’s whole log into the job log, folded, and the last 80 lines of a failure unfolded (ci.py:237), which anyone can read. The workflow’s comment calls the artifact acceptable “because every input comes from public files” (fuzz.yml:4), but a crashing input is new: found by mutation, it is a working proof of concept, and libFuzzer’s output carries small crashing inputs (as Base64) and stack traces. Most targets reach code of other projects: miniz_oxide (the ZIP and package targets), quick-xml (bayan-xml’s two targets) and cfb (compound_file), and the new targets add serde_json and Loro; onlycontent_types,relationshipsandcore_propertiesrun nothing but bayan-opc’s own XML parser, until CORE-011 moves them onto bayan-xml. A crash found at night can therefore be an undisclosed vulnerability in another project’s released software, which SECURITY.md asks everyone to keep private, and the cfb case of finding H3 stays private until the owner allows more. A short written task, “Keep fuzzing failures private” in the follow-ups of docs#55, fixes this for the existing targets and runs first; this work package applies the same rule to its new targets. - The documentation overstates. The first line of
fuzz/README.mdstill says that protocol messages have a fuzz target.
Objective
Every kind of untrusted input that the review lists has a fuzz target that runs in CI on pull requests and every night, and the new targets keep their failures private as the written task makes the existing ones do.
Scope
In scope
- New targets in
fuzz/, each with settings inTARGETSoffuzz/ci.py, seeds, and a row in the README’s table that says what it fuzzes and which properties it checks besides “no crash, no hang”:engine_messages: an engine created with a configuration whose test switches are off (sodiag.panicis refused), a validhellohandshake done by the target, then the input split into messages forEngine::handle. Properties: no panic, also none that the engine catches; every answer is a valid protocol message (the engine’sschemamodule, ashostile_input.rschecks); and a valid request after the hostile ones still succeeds.engine_tile:Engine::render_tilewith the request bytes and a size taken from the input, on an engine with the mock document open. Properties: no panic; a refused request leaves the engine usable.engine_config:Config::from_json. Property: no panic; an accepted configuration starts an engine.engine_replay:recording::replaywith a fixed configuration. Properties: no panic; the recording is refused with a documented error or replayed.crdt_import:Doc::importwith both presets, seeded from snapshots and updates that real editing produces (including a second replica’s concurrent edits), with the spike’s structure-aware mutations ported into the target, for example through libfuzzer-sys’sfuzz_mutator!(the spike stays as it is). Properties: no panic in BayanDocs code; a panic inside Loro may be contained byimport(the owner accepted Loro with that guard, ADR-0008’s amendment of 2026-10-08), after which the document is poisoned and behaves asdoc.rsdocuments; anything else fails.- Two or more of these may share a binary if that is simpler, as long as each property is checked and
ci.pynames each target.
- Panics the code under test catches. libfuzzer-sys installs a panic hook that aborts on every panic, also on one that the code would catch; check this in the pinned version’s source. That is right for the engine targets, whose handlers must never panic (bayan-ffi’s silent hook is not linked into them). For
crdt_import, install a hook that aborts unless the panic is Loro’s, so that panics contained byimportare counted but not failures, and every panic in BayanDocs code is. Define “Loro’s” by crate: Loro’s crates areloroand the packages that only Loro’s dependencies bring intofuzz/Cargo.lock(such asgeneric-btree), not packages that BayanDocs’ own crates also use, and a panic is Loro’s when, counting outward from the frame that raised it, the first frame outside the standard library (core,alloc,std) belongs to one of them. The frame that raised the panic is the one that the backtrace lists below the frames ofstd::panickingandcore::panicking; the frames before those are the hook’s own and the backtrace capture’s, in the fuzz target’s crate, so they must not count. The panic’s location alone is not enough: a panic that std, alloc or one of Loro’s own dependencies raises for Loro carries a location outside thelorocrate. A panic whose backtrace cannot be read counts as a failure. - CI. Extend the path filter of
fuzz.ymlto the crates the new targets build from their folders (at leastcrates/bayan-engine/**andcrates/bayan-crdt/**). Check the jobs’ time limits with five more targets (30 minutes on pull requests and 90 at night today) and adjust them, or split the targets, so that every target keeps its full time. - Private failures for the new targets. The new targets follow the rule that the written task sets for every target: no crashing input, and no log that may contain one, is uploaded or printed where the public can read it, and the job summary names only the failing target and the kind of failure (crash, timeout, out of memory, leak). Check that the new targets’ settings keep to it.
- Dependencies. The fuzz workspace gains path dependencies on bayan-engine and bayan-crdt. Every package they bring into
fuzz/Cargo.lockkeeps the version and checksum of the mainCargo.lock; the gate’s supply-chain checks and cargo-deny cover them. If cargo-deny needs the six advisory exceptions of Loro’s dependencies (ADR-0008’s amendment of 2026-10-08) infuzz/deny.toml, copy them with the same reasons, so that the two policies still differ only by libFuzzer’s NCSA license. - Documentation. The first line and the targets table of
fuzz/README.md, the comment ofhostile_input.rs(it stays as a deterministic test beside the fuzzing), and bayan-core’sAGENTS.md(thefuzz/row and thefuzz.ymlrow).
Out of scope
- The lab’s readers (CORE-011) and M2’s unguarded entry points (CORE-101, which extends
crdt_importto them). - Fixing a crash in another project’s code; that goes to its maintainers through the owner.
- Making the fuzzing job a required check: its path filter skips it on most pull requests.
Deliverables
- New targets in
fuzz/fuzz_targets/with their settings, seeds and, where useful, a dictionary (for example of the protocol’s message types and field names). - Changes to
fuzz/Cargo.toml,fuzz/Cargo.lock,fuzz/ci.py,fuzz/test_ci.py,fuzz/README.md,.github/workflows/fuzz.ymland, if needed,fuzz/deny.toml.
Acceptance criteria
- AC-1 The five targets (or fewer binaries that cover them) build with cargo-fuzz on the pinned nightly, and
fuzz.ymlruns them on the pull request (link to the run, with each target’s executions and coverage in the job summary). - AC-2 Each target checks the properties listed for it; for the engine targets, a test of the target’s harness shows that a panic the engine catches still fails the run, and for
crdt_import, that a panic in BayanDocs code fails it while a contained panic in Loro does not. - AC-3 Each new target ran for at least 30 minutes in the session without a failure, or each failure in BayanDocs code is fixed with a regression test in the crate’s own tests. A failure in another project’s code is reported to the orchestrator privately (never in the pull request or a public issue), and the pull request says only that a report was made.
- AC-4 A failing run on a test branch (one of the new targets made to panic on purpose) shows that the job fails, names the target and the kind of failure, and publishes neither the input nor the log, as the written task requires of every target (link to that run, then revert).
- AC-5
cargo xtask verifypasses, including the supply-chain checks and cargo-deny for the fuzz workspace; the pull request lists every package thatfuzz/Cargo.lockgains, each at the version of the mainCargo.lock. - AC-6
fuzz/test_ci.pycovers the new settings. - AC-7 The README’s first line, its table,
hostile_input.rs’s comment and bayan-core’sAGENTS.mdare correct.
Verification
cargo xtask verify; cargo +nightly-2026-10-02 fuzz build --target x86_64-unknown-linux-gnu; python3 -I fuzz/ci.py seeds fuzz/seeds and python3 -I fuzz/ci.py run <targets> --seconds 1800 --seeds fuzz/seeds; python3 -I fuzz/test_ci.py; the pull request’s fuzz.yml run and the failing run of AC-4.
Notes and pitfalls
- Seeds come from files in the repository, as today (
ci.py seeds): valid messages and recordings built from the engine’s tests or committed as small JSON files written for the purpose, and CRDT blobs made by editing a document in the target’s seed generator or a test. - The engine refuses messages above its limits (spec §13); choose
-max_lenso that inputs can reach those limits where it matters, as the default-limits targets do for bayan-opc. - The configuration must keep
test.allow_panicoff inengine_messages,engine_tileandengine_replay; otherwisediag.panicis a deliberate crash. - Loro is large; building it with AddressSanitizer on the nightly takes time. Measure the build in the job and keep the cache of the build folder if the workflow has one.
- Whether Loro’s crash findings reached its maintainers is still unconfirmed (M12). The private failure reports keep
crdt_importfrom publishing them again. - CORE-011 also changes
fuzz/(a target for the lab’s scanner) andfuzz.yml’s path filter; the two work packages should not run at the same time.
Escalate if
crdt_importfinds a panic or an abort in Loro thatimportdoes not contain, or a failure that keeps every nightly run red; propose whether to keep the target out of the nightly run until Loro is fixed or to add a guard in the adapter.- The fuzz workspace would need a license or advisory exception that the main workspace does not already have.
- The written task that keeps fuzzing failures private has not been merged when this work package starts: it is meant to run first.