Fuzz Infrastructure
Read what each fuzz target executes and asserts, run one under a sanitizer, and turn a detected failure into a regression.
On this page
Chio’s fuzz workspace drives 28 targets, from credential verifiers and policy compilers to protocol envelopes and Wasm guards, with coverage-guided inputs, and keeps each detected failure as a replayable regression.
A run’s result applies to its target, compile configuration, sanitizer, inputs, and time budget, and a run without a crash is evidence about the inputs it reached. Byte-level oracles come from differential tests and bounded symbolic assertions from Kani harnesses, as the figure’s handoffs show.
Coverage chooses which inputs to keep
libFuzzer calls the target with mutated inputs, and instrumentation reports coverage. Inputs that reach new execution features join the corpus for later mutation.
A target’s calls and assertions set what a run checks
libFuzzer calls the selected target with mutated inputs, and instrumentation keeps the inputs that reach new execution features. A panic, an assertion failure, or an enabled sanitizer error writes a replayable artifact. A parser returning an expected error is a healthy result, and a semantic property holds only where the target or its entry point checks it.
jwt_vc_verifydelegates to an entry point in the owning crate.capability_receipthas two independent branches: it deserializes aCapabilityTokenand verifies its signature, then deserializes aChioReceiptand verifies its signature. Both branches ignore the verification result, so the target checks for execution failures in those paths.manifest_roundtripasserts more: after a successful decode and serialization, it compares parsed structure and bytes across two successive rounds.attest_verifysplits each input into artifact and bundle bytes and calls a verifier with a fixed expected identity, returning early when verifier construction fails.canonical_jsonis a mutator driver. Its body attemptsserde_jsonparsing and ignores the result while its custom mutator reshapes parsed JSON. The canonical-byte oracle, frozen vectors, and byte-level regressions live in Differential Tests.
Six targets share that JSON mutator. It sorts Rust strings and serializes with serde_json, so its name says nothing about RFC 8785 conformance; read the input mutation, the function under test, and the oracle as three separate parts of a target.
Inspect the JWT wrapper and the capability and receipt branches
//! Trust-boundary fuzz target for `chio_credentials::verify_chio_passport_jwt_vc_json`.
#![no_main]
use chio_credentials::fuzz::fuzz_jwt_vc_verify;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
fuzz_jwt_vc_verify(data);
});//! Trust-boundary fuzz target for `chio-core-types` `CapabilityToken` and `ChioReceipt` canonical-JSON deserialization.
#![no_main]
use chio_core_types::{capability::token::CapabilityToken, receipt::body::ChioReceipt};
use chio_fuzz::canonical_json::canonical_json_mutate;
use libfuzzer_sys::{fuzz_mutator, fuzz_target};
fuzz_target!(|data: &[u8]| {
// Try CapabilityToken first; on success, drive the verify path.
if let Ok(token) = serde_json::from_slice::<CapabilityToken>(data) {
// Signature verification is fail-closed: we accept Ok(false) /
// Err(_) as expected outcomes. The point is that the call must
// not panic on any structurally valid CapabilityToken.
let _ = token.verify_signature();
}
// Try ChioReceipt independently. Same byte stream, different typed
// deserializer, different fail-closed surface.
if let Ok(receipt) = serde_json::from_slice::<ChioReceipt>(data) {
let _ = receipt.verify_signature();
}
});
fuzz_mutator!(|data: &mut [u8], size: usize, max_size: usize, seed: u32| {
canonical_json_mutate(data, size, max_size, seed)
});One target runs under a sanitizer with a time budget
The scheduled fuzz workflows run on Linux with nightly Rust, rust-src, and cargo-fuzz 0.13.1, the version the scheduled workflow pins, and record the resolved rustc version because the workflow installs an undated nightly. A credential run fuzzes jwt_vc_verify under AddressSanitizer for 60 seconds with a 25-second per-input timeout. Maintainers run a target from source as Qualification: formal gates describes.
Compilation time comes on top of the fuzzing budget. A run's log carries the selected corpus, the target, the enabled sanitizer, and the final process status. cargo-fuzz enables cfg(fuzzing), which can select different code paths, as the cargo-fuzz guide explains.
Coverage replays the corpus with source-coverage instrumentation and writes merged profile data to fuzz/coverage/jwt_vc_verify/coverage.profdata. Matching LLVM coverage tools render that profile, as the coverage guide shows. Uncovered code is a prompt to improve the inputs or the target body; keep the revision and corpus with the report.
A detected failure becomes a minimized, promoted regression
The maintainer replays the artifact path the failed run printed, minimizes it with tmin, and keeps the original artifact beside the minimized reproducer. Seed promotion then moves the input into a SHA-256-named corpus file and writes a regression in one of three modes.
libfuzzer. Writes a Rust test that calls the owning crate’s fuzz entry point by its derived name. Confirm the entry exists, enable the owner’s required features, and run the test:canonical_jsonand other targets implemented directly in the fuzz workspace expose no such wrapper.proptest. Writes the same regression plus a generated-input property when the owning crate declares Proptest, and the plain regression with a warning otherwise.adversarial. Writes a JSON case under the adversarial suite withexpected_verdict: "DENY"andpending: true. It requires a recognized class and a syntactically valid threat ID, names the file by class and a 16-hex digest prefix, and a human triager establishes the reason and verdict before clearing the pending flag. Accepting the ID’s syntax leaves its threat-model membership to that triager.
Each corpus directory under fuzz/corpus/ carries the exact binary name, including the fuzz_ prefix the four prefixed binaries use, and a custom corpus path keeps an exploratory run apart from the committed seeds. Retain the input bytes, the revision, the target, the sanitizer, the tool versions, and the run options; corpus size alone says nothing about which validators the inputs reached.
Read the promotion modes
# Reads fuzz/owners.toml to find the owning crate for the named fuzz
# target, computes sha256 of the input seed, then either:
#
# --mode libfuzzer Writes a libtest #[test] fn that calls the fuzz
# wrapper directly with the seed bytes. Output:
# crates/<owner>/tests/regression_<target>_<sha16>.rs.
# The 16-hex prefix plus target component avoids
# filename collisions when one owner crate hosts
# multiple targets or accumulates multiple promoted
# seeds.
#
# --mode proptest Writes the same regression test plus a paired
# proptest property when the owner crate has proptest
# in [dev-dependencies]. When proptest is missing,
# emits the plain regression test and prints a warning.
#
# --mode adversarial Promotes a libFuzzer crash that decodes cleanly
# through one of the trust-boundary surfaces into
# a triage-pending case under
# crates/core/chio-adversarial-suite/cases/<class>/<sha>.json
# with `expected_verdict: "DENY"` placeholder and
# `pending: true`. Requires --class (one of the
# eight attack classes) and --threat-id (a
# chio-threat-model.v1.json identifier). The
# pending flag is stripped only by a human triager.
# No regression .rs file is emitted; the
# adversarial harnesses already iterate the
# bundled cases.
#
# In all three modes the seed is moved into fuzz/corpus/<target>/<sha>.bin
# so future fuzz runs continue exercising it. Re-promoting a seed whose
# --input path already resolves to the canonical corpus location is a
# no-op (the script does not delete the corpus seed under itself).Three workflows choose which targets run and for how long
cflite_pr. Runs the targets whose trigger patterns match the changed files, 60 seconds each, or every target for 120 seconds under thefuzz: fulllabel.cflite_batch. Runs one target for 1,800 seconds, chosen by UTC day index modulo the sorted inventory, with a manual override.fuzz. Runs the native full matrix, or one manually selected target, for 1,800 seconds per target by default within a 45-minute job timeout.
The PR workflow compares changed files with trigger patterns relative to the merge base, and named infrastructure edits select the full inventory. When no target matches in changed-target mode, a following step fails and prints the changed paths; the outer workflow path filter is a separate check. The PR run omits remote corpus storage, the batch workflow selects storage by credential availability, and both select AddressSanitizer, while the project configuration also declares undefined-behavior sanitization.
All three workflows run the shared trailing-window budget check in warn mode, so the 1,800-minute budget is advisory there; the script’s default fail mode is a separate configuration. A declared schedule records intent, and a hosted run’s own log records what completed.
Inspect the workflow, sanitizer, and owner-map sources
jobs:
budget-check:
name: fuzz-budget
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Verify 30-day fuzz budget
env:
GH_TOKEN: ${{ github.token }}
GH_FUZZ_BUDGET_RATE_LIMIT_MODE: warn
GH_FUZZ_BUDGET_CAP_MODE: warn
GH_FUZZ_BUDGET_WORKFLOWS: cflite_pr.yml,cflite_batch.yml,fuzz.yml
run: |
set -euo pipefail
# PR-time CFLite is an advisory release qualification signal. Report
# budget exhaustion without blocking unrelated PRs while the shared
# trailing window is already over cap.
scripts/check-fuzz-budget.sh "${{ github.repository }}"
changed-target-sampling:
name: changed-target-sampling
needs: budget-check
runs-on: ubuntu-latest
# The 25-target full sweep has a 50-minute fuzz budget before build time.
timeout-minutes: 75- name: Compute changed targets
id: targets
if: steps.mode.outputs.full == 'false'
run: |
set -euo pipefail
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.event.pull_request.head.sha }}"
merge_base="$(git merge-base "${base_sha}" "${head_sha}")"
echo "merge-base: ${merge_base}"
git diff --name-only "${merge_base}" "${head_sha}" > changed.txt
mapfile -t targets < <(yq -p=toml -r '.targets | keys | .[]' \
fuzz/target-map.toml)
fired=()
for tgt in "${targets[@]}"; do
mapfile -t triggers < <(yq -p=toml -r \
".targets.${tgt}.triggers[]" fuzz/target-map.toml)
for trig in "${triggers[@]}"; do
regex="$(printf '%s' "${trig}" | sed -e 's|\.|\\.|g' \
-e 's|\*\*|__DSTAR__|g' -e 's|\*|[^/]*|g' \
-e 's|__DSTAR__|.*|g')"
if grep -qE "^${regex}$" changed.txt; then
fired+=("${tgt}")
break
fi
done
done
# Inventory fallback: builder, workflow, and target-map edits
# can break changed-target sampling without matching a source
# trigger. Run the full inventory for those control-plane edits.
if grep -qE '^(\.cargo/|\.clusterfuzzlite/|\.github/workflows/cflite_pr\.yml$|Cargo\.(toml|lock)$|fuzz/(Cargo\.toml|target-map\.toml)$|Makefile$|scripts/(ci-pr-tier|spec-drift-check|check-review-slices)\.(sh|py)$)' changed.txt; then
echo "fuzz inventory edit detected; firing the full inventory"
fired=("${targets[@]}")
fi
if ((${#fired[@]} == 0)); then
: > fired.txt
else
printf '%s\n' "${fired[@]}" | sort -u > fired.txt
fi
fired_count="$(wc -l < fired.txt | tr -d ' ')"
printf 'fired targets: %s\n' "${fired_count}"
cat fired.txt
{
echo "count=${fired_count}"
printf 'targets<<EOF\n%s\nEOF\n' "$(cat fired.txt)"
} >> "${GITHUB_OUTPUT}"- name: Run ClusterFuzzLite changed-target sampling without corpus storage
if: steps.mode.outputs.full == 'false' && steps.targets.outputs.count != '0'
uses: google/clusterfuzzlite/actions/run_fuzzers@82652fb49e77bc29c35da1167bb286e93c6bcc05
env:
CHIO_CFLITE_TARGETS: ${{ steps.targets.outputs.targets }}
with:
language: rust
fuzz-seconds: ${{ steps.mode.outputs.fuzz_seconds }}
mode: code-change
sanitizer: address
github-token: ${{ secrets.GITHUB_TOKEN }}
report-unreproducible-crashes: false- name: Pick today's target
id: pick
env:
FORCED_TARGET: ${{ inputs.target }}
run: |
set -euo pipefail
mapfile -t targets < <(yq -p=toml -r '.targets | keys | .[]' \
fuzz/target-map.toml | sort)
if (( ${#targets[@]} == 0 )); then
echo "fuzz/target-map.toml has no targets" >&2
exit 1
fi
if [[ -n "${FORCED_TARGET}" ]]; then
picked="${FORCED_TARGET}"
if ! printf '%s\n' "${targets[@]}" | grep -Fxq -- "${picked}"; then
echo "unknown forced fuzz target: ${picked}" >&2
exit 1
fi
else
day_index=$(( $(date -u +%s) / 86400 ))
picked="${targets[$(( day_index % ${#targets[@]} ))]}"
fi
printf 'picked: %s (rotation cycle: %d targets)\n' \
"${picked}" "${#targets[@]}"
{
echo "target=${picked}"
echo "cycle_size=${#targets[@]}"
} >> "${GITHUB_OUTPUT}"# ClusterFuzzLite project config for Chio.
#
# Storage backend: backbay-labs/chio-fuzz-corpus sibling private repo (no GCS).
# The repo is created out-of-band before the first cflite_batch.yml run.
#
# NOTE: ClusterFuzzLite reads the corpus storage-repo as an input to
# the `run_fuzzers` and `build_fuzzers` GitHub Actions, NOT from
# project.yaml. The `storage-repo:` input is wired with `FUZZ_CORPUS_PAT`
# in `.github/workflows/cflite_pr.yml` and `.github/workflows/cflite_batch.yml`.
language: rust
primary_contact: "security@backbay.io"
auto_ccs:
- "security@backbay.io"
- "fuzzing@backbay.io"
sanitizers:
- address
- undefined
architectures:
- x86_64
fuzzing_engines:
- libfuzzer
report_to_oss_fuzz: falsefn owners_toml_covers_all_matrix_targets() {
assert_eq!(
sorted(owners_toml_targets()),
sorted(workflow_fuzz_targets())
);
}The smoke suite keeps the target registries in agreement
The smoke suite is an ordinary Rust test target that runs on every pull request. It builds the shared fuzz library, runs four inventory checks, and replays the committed corpus of the 18 targets that declare in-process corpus tests.
The inventory checks compare Cargo binary names with the scheduled matrix, compare the owner map with that matrix, require at least three seed files per target, and compare the union of declared smoke postures with the matrix. The union check deduplicates names, so disjointness of the two posture lists rests on review. The other ten targets receive the inventory checks only.
fn all_matrix_targets_meet_seed_floor() {
for target in workflow_fuzz_targets() {
let count = each_seed(&target, |_| {});
assert!(
count >= MINIMUM_SEED_COUNT,
"target {target} has {count} seeds; expected at least {MINIMUM_SEED_COUNT}"
);
}
}These tests replay committed inputs and explore none. target-map.toml and the two fuzz build scripts sit outside their assertions, so the four-registry convention is wider than the suite checks. A passed inventory assertion covers the registries, and a coverage-guided campaign with a sanitizer and a time budget is a separate result.
Fuzz targets
Each entry links its wrapper source and records the committed corpus size. “Inventory checks only” means the shared smoke suite runs no in-process corpus test for that target. The 28 targets carry 173 committed seed files; owners.toml names each owning crate and target-map.toml its changed-path triggers, while the wrapper decides what executes.
28 targets. Search a name, boundary or smoke posture.
Target and corpus snapshot
| Target | Entry behavior | Committed corpus and smoke posture |
|---|---|---|
A2A envelope entry point. Target source | 8 files · In-process smoke | |
ACP envelope entry point. Target source | 7 files · In-process smoke | |
Anchor bundle verification entry point. Target source | 6 files · In-process smoke | |
Splits bytes into artifact and bundle inputs, then calls Sigstore verification with a fixed expected identity. Target source | 3 files · Inventory checks only | |
Drives the JSON mutator; the target body calls serde_json parsing and ignores the result. Target source | 5 files · Inventory checks only | |
Independently deserializes a capability and receipt, then calls signature verification on each successful parse. Target source | 14 files · Inventory checks only | |
Configuration YAML loading entry point. Target source | 7 files · In-process smoke | |
DID parser and resolution entry point. Target source | 6 files · In-process smoke | |
Calls bundle verification when the bytes decode as UTF-8. Target source | 3 files · In-process smoke | |
Exercises trust-exchange records and constructed peer handshakes. Target source | 3 files · In-process smoke | |
Parses worker protocol shapes and calls their available validators, signature checks and serialization paths. Target source | 3 files · In-process smoke | |
Exercises Merkle and checkpoint operations in the selected harness. Target source | 3 files · Inventory checks only | |
Exercises policy parsing, validation and compilation. Target source | 6 files · Inventory checks only | |
Exercises SQL parsing and guard analysis. Target source | 10 files · Inventory checks only | |
Exercises action classification and guard evaluation. Target source | 12 files · Inventory checks only | |
JWT credential verification entry point. Target source | 5 files · In-process smoke | |
Compares structural and byte equality across two successful manifest serialization rounds. Target source | 7 files · Inventory checks only | |
MCP envelope entry point. Target source | 7 files · In-process smoke | |
OID4VP presentation entry point. Target source | 4 files · In-process smoke | |
OpenAPI ingest entry point. Target source | 9 files · In-process smoke | |
Exercises bounded policy analysis and its harness assertions. Target source | 3 files · Inventory checks only | |
Receipt-log decode and replay entry point. Target source | 7 files · In-process smoke | |
Exercises sparse-Merkle revocation operations. Target source | 3 files · Inventory checks only | |
Exercises policy-input, decision and pricing data paths. Target source | 5 files · In-process smoke | |
Wasm guard escape-test entry point. Target source | 8 files · In-process smoke | |
Generated Wasm module and component entry point. Target source | 5 files · In-process smoke | |
Wasm pre- | 7 files · In-process smoke | |
Host-call boundary decoding entry point. Target source | 7 files · In-process smoke |