Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CI

GitHub Actions workflows live in .github/workflows. Every job checks out submodules recursively, because the C reference in zstd-ffi/vendor/zstd is needed to build the test oracle. The ClusterFuzzLite jobs are the exception: their actions check out the repository themselves, and .clusterfuzzlite/build.sh fetches the submodule.

graph TD
    Push[push to main / pull request] --> CI[ci.yml]
    CI --> Check["check: fmt, clippy per feature set, rustdoc per feature set, package"]
    CI --> NoStd["no-std: thumbv7em build, alloc-only host tests"]
    CI --> Test["test: Linux x86-64, Linux arm64, macOS, and MSRV 1.89 — release mode"]
    CI --> OCF["overflow-correction: core differential suites with MZSTD_OVERFLOW_CORRECT_FREQUENTLY"]
    CI --> Fuzz["fuzz: fmt/clippy of fuzz/afl, regression replay with and without framed"]
    CI --> LibFuzzer["libfuzzer: fmt/clippy of fuzz/libfuzzer with and without framed"]
    Push --> Lean["ci-lean.yml: Lean translation drift check, Lean proofs and axiom audit"]
    PR[pull request touching code] --> CFPR["ci-cflite-pr.yml: ClusterFuzzLite on the changed code"]
    Daily[daily schedule or manual] --> CFCron["ci-cflite-cron.yml: batch, prune, coverage"]
    CFCron -. coverage report selects targets .-> CFPR
    Manual[workflow_dispatch] --> Cov["ci-coverage.yml: llvm-cov, 100% function coverage"]
    Manual --> Bench["ci-benchmarks.yml: Criterion smoke runs with differential checks"]
    Tag[v* tag or manual] --> Camp["ci-fuzz.yml: bounded AFL campaign per target"]
    Tag --> Miri["ci-miri.yml: Miri over SIMD kernels and BorrowedBuf output"]
    Tag --> ASan["ci-sanitizer.yml: AddressSanitizer over integration and kernel tests"]
    Manual --> Rel["release.yml: trusted publishing from a v* tag"]
    Push --> Docs["pages.yml: build book, check links, run recipes"]
    Docs -->|"main only"| Pages["GitHub Pages"]
    Push --> HB["hotpath-benchmark.yml: profile_compressor natively, then under Callgrind (merged)"]
    HB -->|"push: OIDC upload"| Cloud[(hotpath Cloud)]
    HB -->|"pull request: report artifact"| Relay["hotpath-relay.yml (workflow_run)"]
    Push --> HC["hotpath-criterion.yml: one Criterion id per matrix entry, natively then under Callgrind (merged)"]
    HC -->|"push: OIDC upload"| Cloud
    HC -->|"pull request: criterion-<series> artifact"| Relay
    Relay -->|"OIDC upload"| Cloud
WorkflowTriggerChecks
ci.ymlpush to main, pull requestscargo fmt --check. Clippy with -D warnings for all features, for the default features plus hotpath, and for each codec with std and with no_std. Rustdoc with -D warnings for the same configurations. cargo package. A bare-metal build. Release-mode tests on three platforms and on the MSRV, each with the default features, with all features (alloc only) and with framed (which adds the std framed adapters and seek reader). Clippy and rustdoc also cover framed with each codec. The overflow-correction mode. Fuzz package lint and regression replay. libFuzzer adapter lint.
pages.ymldocumentation/code pushes to main, matching pull requests, manualPython tooling tests, release compilation and execution of documented Rust recipes with framed, mdBook build and local-link/anchor validation, including the root README’s links into the published site; upload Pages artifact. Only the deployment job on main has pages: write and id-token: write. See documentation publishing.
ci-lean.ymlpush to main, pull requestsscripts/extract_lean.py --check: the Charon/Aeneas translation of the verified items matches the Rust source. lake exe cache get and lake build in verification/lean: the RFC 8878 proofs and the axiom audit. See formal verification.
hotpath-benchmark.ymlpush to main, pull requestsprofile_compressor built with hotpath-alloc,hotpath-callgrind,hotpath-cloud on pinned Rust 1.99.1 and Valgrind 3.26.0, run on testdata/alice29.txt in merged mode: natively for timing and allocations, then under valgrind --tool=callgrind --instr-atstart=no with HOTPATH_MERGE_REPORT pointing at the native report, which adds instruction counts. jq checks that the merged report has both icount and timing data. A push uploads the merged report to hotpath Cloud as the compressor benchmark’s baseline (id-token: write). A pull request runs read-only and leaves the JSON report as the compressor-report artifact. See hotpath Cloud.
hotpath-criterion.ymlpush to main, pull requestsOne job per matrix entry (compress/text-1MiB/mzstd/3 and decompress/text-4MiB/level-3/mzstd, 200 iterations each): the Criterion bench built with hotpath-alloc,hotpath-callgrind,hotpath-cloud on the same pinned toolchain and Valgrind, run with --bench --test --exact <id> and HOTPATH_CRITERION_ITERATIONS, natively and then under Callgrind in merged mode. jq also checks that the report names the Criterion id. A push uploads each report as its own hotpath Cloud series (compress-text-1MiB-mzstd-3, decompress-text-4MiB-level-3-mzstd); a pull request leaves it as the criterion-<series> artifact. See hotpath Cloud.
hotpath-relay.ymlworkflow_run of hotpath benchmark or hotpath criterionhotpath-rs/hotpath-relay (v1.0.0, pinned by commit) uploads a successful pull request run’s report with the base repository’s OIDC token, so the pull request jobs never need write permissions: the relay job the compressor-report, the criterion job one criterion-<series> artifact per series of the hotpath criterion matrix. The workflow has no default permissions; each job gets actions: read, id-token: write and pull-requests: read, and a newer run for the same branch cancels an older one.
ci-cflite-pr.ymlpull requests touching src/, zstd-ffi/, fuzz/, Cargo.toml, .clusterfuzzlite/, .dockerignore or the workflowClusterFuzzLite libFuzzer/ASan run on the changed code, 600 s. See ClusterFuzzLite.
ci-cflite-cron.ymldaily, manualClusterFuzzLite batch fuzzing (5.5 h), corpus pruning and a coverage report.
ci-coverage.ymlmanualcargo llvm-cov --workspace --release with --fail-under-functions 100 over repository-owned sources (zstd-ffi and the vendored C excluded); HTML report as an artifact.
ci-benchmarks.ymlmanualShort compress, decompress and mzstd-ffi one_shot runs. Each benchmark asserts byte identity with zstd before timing. The framed benches (framed_compress, framed_decompress) are run locally with --features framed.
ci-fuzz.ymlv* tags, manualprepare-seeds.sh, then campaign.sh for each target, the three framed targets included (600 s by default); findings uploaded as findings-<target>.tar.gz, since AFL file names contain :, which upload-artifact rejects.
ci-miri.ymlv* tags, manualscripts/miri.sh on nightly: exact-named tests for the vector kernels and BorrowedBuf output under Miri, interpreted for x86-64 with MIRI_TARGET_CPU=native, so the runner’s CPU picks the backends. See memory checks.
ci-sanitizer.ymlv* tags, manualNightly -Zsanitizer=address release builds, LeakSanitizer included: every integration test with framed (the two that forget an owning guard on purpose without leak detection), then the kernel and uninitialized-output unit modules on the runner’s own backends.
release.ymlmanual from a v* tagTag/version match, then cargo publish through crates.io trusted publishing.

ci.yml, ci-lean.yml, ci-coverage.yml, ci-benchmarks.yml, ci-fuzz.yml, ci-miri.yml and ci-sanitizer.yml declare permissions: contents: read at the workflow level: they only check out, build, test and upload artifacts, so their GITHUB_TOKEN needs nothing more. The ClusterFuzzLite workflows use read-all, and the hotpath and release workflows grant permissions per job.

All Cargo invocations that build the workspace pass --locked, because Cargo.lock is committed so that benchmark baselines and campaigns are reproducible.

hotpath Cloud

hotpath-benchmark.yml judges the encoder by instruction count rather than time, so a shared GitHub runner gives reproducible numbers. It runs in merged mode: the same build first runs natively and writes a JSON report with timing and allocations, then runs under Callgrind with HOTPATH_MERGE_REPORT naming that report, and the Callgrind run adds its instruction counts and writes (and, on a push, uploads) the merged report. Valgrind distorts allocation data as well as timing, so both come from the native run. Both runs share HOTPATH_BENCHMARK, HOTPATH_USER_METADATA and HOTPATH_OUTPUT_FORMAT from the job environment, so their reports describe one benchmark. Counts depend on the Valgrind build, the Rust toolchain and the runner image, which are all pinned; bumping any of them moves the baseline and belongs in a separate pull request. hotpath Cloud compares each pull request’s report with the latest main upload and judges it by hotpath/policy.toml: instruction counts and allocations are judged (0.5% and 1% thresholds, functions above 0.05% of the total), wall time is reported but not judged. The thresholds come from mbrotli’s measured run-to-run spread and have not been re-measured for this crate.

hotpath-criterion.yml applies the same merged mode to selected Criterion benchmarks. benches/compress.rs and benches/decompress.rs import Criterion through hotpath::criterion (the criterion-0-8 feature of the hotpath dev-dependency). Without HOTPATH_CRITERION_ITERATIONS the adapter forwards to Criterion unchanged; with it set, the one benchmark that --test --exact selects runs its routine exactly that many times inside one Criterion iteration, and the hotpath guard, which also starts Callgrind’s instrumentation, wraps only those iterations. The bench’s setup, including its byte-identity checks against zstd, runs earlier and is not counted. The report records the Criterion id, iteration count and Criterion version in meta.criterion. Each matrix entry is its own hotpath Cloud series, judged by the same hotpath/policy.toml. The matrix and the relay’s criterion matrix list the same series names and are kept in sync by hand.

flowchart TD
    Env{HOTPATH_CRITERION_ITERATIONS set?}
    Env -->|no| Plain[Criterion samples and reports as usual]
    Env -->|yes| Filter{exactly one id selected by --test --exact?}
    Filter -->|none ran| Fail1[exit 1: no report written]
    Filter -->|second routine starts| Fail2[panic: one benchmark per process]
    Filter -->|one| Setup[bench setup and byte-identity checks, unprofiled]
    Setup --> Guard[hotpath guard starts, Callgrind instrumentation on]
    Guard --> Loop[routine runs N times inside one Criterion iteration]
    Loop --> Report[report with meta.criterion written on guard drop]
sequenceDiagram
    participant PR as pull_request job (read-only)
    participant Relay as hotpath-relay.yml
    participant Push as push job (main)
    participant Cloud as hotpath Cloud
    Push->>Push: build profile_compressor, run natively (native.json)
    Push->>Push: run under Callgrind, merge icount into native.json
    Push->>Cloud: upload merged report (OIDC), new baseline
    PR->>PR: build profile_compressor, run natively (native.json)
    PR->>PR: run under Callgrind, merge into compressor-report.json
    PR->>PR: upload-artifact compressor-report.json
    PR-->>Relay: workflow_run completed
    Relay->>Cloud: upload report for the pull request (OIDC)
    Cloud->>Cloud: diff against baseline, apply hotpath/policy.toml

ClusterFuzzLite

Both ClusterFuzzLite workflows build .clusterfuzzlite/Dockerfile with google/clusterfuzzlite/actions/build_fuzzers@v1 and run the binaries that build.sh produces from fuzz/libfuzzer (see fuzzing). The actions do their own checkout, so the workflows have no checkout step and build.sh fetches the vendored zstd submodule itself. Runs use libFuzzer with AddressSanitizer, the only engine and sanitizer ClusterFuzzLite supports for Rust, and the coverage job builds with the coverage sanitizer. All jobs use read-only permissions; corpora, crashes and coverage reports are kept as workflow artifacts, ClusterFuzzLite’s default storage on GitHub.

sequenceDiagram
    participant PR as pull request
    participant Cron as daily schedule
    participant CF as ClusterFuzzLite actions
    participant Store as workflow artifacts
    PR->>CF: build (address), run code-change, 600 s
    Store-->>CF: latest corpus and coverage report
    CF-->>PR: job fails on a crash, with the reproducer
    Cron->>CF: batch: build (address), fuzz 18000 s, targets in parallel
    CF->>Store: grown corpus, crashes
    Cron->>CF: prune: minimize corpus, 18000 s
    CF->>Store: pruned corpus
    Cron->>CF: coverage: build (coverage), report
    CF->>Store: coverage report

ci-cflite-pr.yml runs only when a pull request touches src/, zstd-ffi/, fuzz/, Cargo.toml, .clusterfuzzlite/, .dockerignore or the workflow itself, and a newer push cancels the run in progress. Code-change mode runs only the targets whose coverage reaches the changed lines once a coverage report exists, and every target before that. The 600 seconds are split among the targets it runs.

In ci-cflite-cron.yml the jobs are chained: pruning follows batch fuzzing (ClusterFuzzLite requires pruning whenever batch fuzzing runs), and coverage follows pruning, so the report reflects the pruned corpus. A failed batch job still lets pruning and coverage run. Batch fuzzing gets 5 hours, shared by all targets, which run side by side on the runner’s cores (parallel-fuzzing: true); the job’s six-hour limit leaves the rest for the image and fuzzer builds (up to 30 minutes) and the corpus uploads. With 5.5 hours the job hit the limit on days the build was slow. Pruning gets 18000 seconds, which with a build of up to 35 minutes fits the job’s limit even if every target used its whole share. Each target’s merge must finish within its share of that budget: an even split at the start, growing by whatever earlier targets leave unused. A day’s corpus takes about an hour to merge at batch speed. parallel is the slowest, at about four inputs a second, since each input compresses nine times, and a quarter of the inputs are at levels 16 to 22, which take over half the time. Its corpus grows by about 150 inputs a day: at 3387 inputs it outgrew the 812-second share that 10800 seconds gave it, and 18000 seconds give it about 1370. With 600 seconds the first target timed out and none were pruned. Every run_fuzzers step sets language: rust, because the action defaults to C++.

.dockerignore keeps target directories and local AFL and libFuzzer artifacts out of the image, but keeps .git, which the submodule fetch needs.

Memory checks

mzstd is forbid(unsafe_code) outside tests. The unsafe with memory effects that its code reaches lives in two dependencies: fearless_simd, whose vector types, kernel! and #[simd] entries call architecture intrinsics inside a target-feature context, and borrowed-buf, which tracks the zeroed prefix of uninitialized output. The third, branches, only emits prefetch hints, which never fault; on nightly, and so under Miri, they lower to the compiler intrinsic instead of inline assembly. The FFI glue in zstd-ffi and mzstd-ffi is outside the published crate.

flowchart TD
    Unsafe[unsafe reached by mzstd] --> Simd[fearless_simd intrinsics]
    Unsafe --> Buf[borrowed-buf BorrowedBuf]
    Simd --> K1["compressor::core::opt_count: u8x16/32/64 match-length scans"]
    Simd --> K2["compressor::core::lazy::row: tag masks, NEON kernel!"]
    Simd --> K3["decompressor::core::copy: shuffle wildcopy"]
    K3 --> K4["decompressor::core::sequences: execution at fixed and growing output edges"]
    Buf --> B1["compressor::core::output: UninitOutput"]
    Buf --> B2["decompressor::core::stream: decode_to_uninit output"]
    B1 --> B3["tests/compressor_api.rs, tests/compressor_target_block.rs: compress_to_uninit end to end"]
    Simd --> K5["compressor::core::superblock: sub-block sequence encoding on every backend"]
    K1 & K2 & K3 & K4 & K5 & B1 & B2 & B3 --> Miri["scripts/miri.sh: Miri, x86-64: scalar, SSE2, SSE4.2, AVX2, AVX-512 on capable runners"]
    K1 & K2 & K3 & K4 & K5 & B1 & B2 --> ASan["ci-sanitizer.yml: native backends under ASan"]
    All[every integration test with framed] --> ASan

Miri covers only those paths. Code that is safe Rust inside a #[simd] context, such as the scalar mem counts the compiler auto-vectorizes, the Huffman and sequence bit packers and the match finders around the kernels, has no unsafe of its own and is left to the ordinary suites.

scripts/miri.sh interprets the tests for x86_64-unknown-linux-gnu on any host, with -Ctarget-cpu=$MIRI_TARGET_CPU (default x86-64-v3). Miri implements the x86 intrinsics the kernels use, AVX-512 included, but not all of NEON (vsli is missing), and it reports compile-time target features instead of executing CPUID, so the target CPU decides which backends Backend::available() returns. The default gives scalar, SSE2, SSE4.2 and AVX2 on any machine; CI passes native, which adds AVX-512 on runners with every extension fearless_simd requires for it (Ice Lake and later Xeons, not Cascade Lake or Zen 3), and prints the runner’s CPU model first. A local MIRI_TARGET_CPU=icelake-server reproduces that case on any host. Each run names its tests with --exact and fails unless that many pass, so a renamed test cannot leave a filter that silently matches nothing. Two sweeps thin out under cfg!(miri) while keeping every vector width, tail and full-length case: opt_count’s window test keeps lengths next to 16-byte boundaries, and compress_to_uninit_writes_the_one_shot_frame compresses 2 KB instead of 30 KB. Native runs are unchanged.

The AddressSanitizer job needs an explicit --target so that build scripts and proc macros stay uninstrumented. It runs every integration test, the C-reference differential suites included, and then the Miri modules natively on the runner’s own backends, AVX-512 included when the runner has it. Locally, the same commands with an aarch64-apple-darwin target cover NEON.

On Linux, ASan also runs LeakSanitizer when the process exits, so a test must not leak either: a borrowed &'static input or resolver lives in a static OnceLock rather than behind Vec::leak or Box::leak. The exceptions are forgotten_readers_keep_the_owner_protected_until_recovery (framed_seek) and batches_with_leaked_tasks_report_invalid_state (parallel), which mem::forget a seek reader or a task list that owns memory and borrows the test’s input: the leak is the behavior they check. The integration step skips them and a second step runs them with ASAN_OPTIONS=detect_leaks=0, so ASan still checks their accesses, and a renamed one would make the first step report its leak. macOS does not run LeakSanitizer unless ASAN_OPTIONS=detect_leaks=1 is set.

Known gaps

  • Miri does not interpret NEON and cannot call the C reference, so NEON and the differential suites are checked only natively, under AddressSanitizer. Whether a tag’s Miri run reaches AVX-512 depends on the runner GitHub assigns. The C oracle itself is not built with ASan.
  • Memory checks run on tags and on demand, not on pull requests. ClusterFuzzLite’s ten-minute pull request run is the only sanitizer run there, and it covers only the fuzz targets.
  • ClusterFuzzLite builds for x86-64 only, so its runs reach the x86 backends the runner supports, not NEON.
  • Benchmark numbers from shared runners are smoke tests, not evidence.