Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Fuzz

on:
schedule:
# Weekly, Monday 03:00 UTC. Bounded runs keep normal CI fast.
- cron: "0 3 * * 1"
workflow_dispatch:
inputs:
seconds:
description: Bounded seconds to fuzz each target
required: false
default: "60"
type: string

permissions:
contents: read

concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: false

jobs:
smoke:
name: Fuzz smoke
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
workspaces: fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Discover fuzz targets
id: targets
run: |
set -euo pipefail
if [ ! -d fuzz/fuzz_targets ]; then
echo "::error::fuzz/fuzz_targets not found" >&2
exit 1
fi
targets=$(find fuzz/fuzz_targets -maxdepth 1 -name '*.rs' -printf '%f\n' \
| sed 's/\.rs$//' | sort)
if [ -z "$targets" ]; then
echo "::error::no fuzz targets found in fuzz/fuzz_targets" >&2
exit 1
fi
{
echo "targets<<FUZZ_TARGETS"
echo "$targets"
echo "FUZZ_TARGETS"
} >> "$GITHUB_OUTPUT"
echo "Discovered targets:"
echo "$targets"
- name: Run each target (bounded)
env:
TARGETS: ${{ steps.targets.outputs.targets }}
SECONDS_PER_TARGET: ${{ github.event.inputs.seconds || '60' }}
run: |
set -euo pipefail
status=0
while IFS= read -r target; do
[ -z "$target" ] && continue
echo "::group::fuzz $target"
cargo +nightly fuzz run "$target" -- -max_total_time="$SECONDS_PER_TARGET" \
|| status=1
echo "::endgroup::"
done <<< "$TARGETS"
exit "$status"
- name: Fail on crash artifacts
if: success()
run: |
set -euo pipefail
if [ -n "$(find fuzz/artifacts -type f 2>/dev/null)" ]; then
echo "::error::fuzz produced crash artifacts" >&2
find fuzz/artifacts -type f
exit 1
fi
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: fuzz/artifacts
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CI: format, clippy, and tests on Linux, Windows, and macOS.
- Tooling: `tools/changelog.py` generates reviewable release sections from
Conventional Commits; a dry-run workflow runs on demand and on release.
- Fuzzing: `fuzz/` cargo-fuzz targets for XDR/SCVal decoding, exercised by a
weekly bounded scheduled workflow (see `docs/TESTING.md`).
- Governance: README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, LICENSE,
pull-request and issue templates.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Exit codes: `0` success, `2` usage error, `3` runtime/decode error.
- [Architecture](docs/ARCHITECTURE.md)
- [Getting started](docs/GETTING_STARTED.md)
- [CLI reference](docs/CLI.md)
- [Testing guide](docs/TESTING.md)
- [Project status](docs/PROJECT_STATUS.md)
- [Roadmap](docs/ROADMAP.md)
- [Contributing through Drips Wave](docs/WAVE.md)
Expand Down
76 changes: 76 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Testing guide

## The local gate

Run the same checks as CI before opening a pull request:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all
```

Unit and integration tests live next to the code (`#[cfg(test)]` modules) and in
`tests/`. Decoding changes must include malformed-input tests.

## Fuzzing

Fuzz targets live in the standalone `fuzz/` crate (it is not part of the main
workspace). Targets exercise the untrusted-input decoders:

| Target | Entry point |
| ------ | ----------- |
| `decode_scval` | `devkit_xdr::inspect_scval_base64` |
| `decode_envelope` | `devkit_xdr::inspect_envelope_base64` |
| `decode_event` | `devkit_xdr::inspect_event_base64` |

Fuzzing requires a nightly toolchain and `cargo-fuzz`:

```bash
cargo install cargo-fuzz
cd fuzz
cargo +nightly fuzz run decode_scval -- -max_total_time=60
```

Targets must not touch the filesystem and must not panic on arbitrary input:
every failure should be a structured `DevkitError`.

### Adding a target

1. Add `fuzz/fuzz_targets/<name>.rs` using the `libfuzzer_sys::fuzz_target!`
macro.
2. Register a `[[bin]]` entry for it in `fuzz/Cargo.toml`.
3. The scheduled job discovers targets automatically; no workflow change needed.

## Scheduled fuzz smoke job

`.github/workflows/fuzz.yml` runs weekly (Mondays at 03:00 UTC) and on demand via
`workflow_dispatch`. It:

1. Discovers every target under `fuzz/fuzz_targets/`.
2. Runs each target for a bounded time (`60` seconds by default, overridable
from the dispatch input).
3. Fails if any target crashes or leaves a crash artifact in `fuzz/artifacts`.
4. Uploads `fuzz/artifacts` as the `fuzz-crashes` artifact when a run fails.

Runs are intentionally short so they never slow normal pull-request CI.

### Pinning the nightly

The workflow uses `dtolnay/rust-toolchain@nightly`. To pin a specific nightly,
change that reference to a dated toolchain, for example:

```yaml
- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-01-01
```

## Reproducing a scheduled failure

Download the `fuzz-crashes` artifact, then re-run the offending input:

```bash
cd fuzz
cargo +nightly fuzz run decode_scval artifacts/decode_scval/<crash-file>
```
4 changes: 4 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
35 changes: 35 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[package]
name = "devkit-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"
devkit-xdr = { path = "../crates/devkit-xdr" }

[[bin]]
name = "decode_scval"
path = "fuzz_targets/decode_scval.rs"
test = false
doc = false
bench = false

[[bin]]
name = "decode_envelope"
path = "fuzz_targets/decode_envelope.rs"
test = false
doc = false
bench = false

[[bin]]
name = "decode_event"
path = "fuzz_targets/decode_event.rs"
test = false
doc = false
bench = false

[workspace]
9 changes: 9 additions & 0 deletions fuzz/fuzz_targets/decode_envelope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(input) = std::str::from_utf8(data) {
let _ = devkit_xdr::inspect_envelope_base64(input);
}
});
9 changes: 9 additions & 0 deletions fuzz/fuzz_targets/decode_event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(input) = std::str::from_utf8(data) {
let _ = devkit_xdr::inspect_event_base64(input);
}
});
9 changes: 9 additions & 0 deletions fuzz/fuzz_targets/decode_scval.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(input) = std::str::from_utf8(data) {
let _ = devkit_xdr::inspect_scval_base64(input);
}
});
Loading