Operations · docs/fuzzing.md

Parser reliability and coverage-guided fuzzing

BlueTusk fuzzes every externally controlled parser boundary used by the V1 product chain:

3 min read547 wordsSource synchronized
View source on GitHub
ON THIS PAGE

Parser reliability and coverage-guided fuzzing

BlueTusk fuzzes every externally controlled parser boundary used by the V1 product chain:

Target Boundary
protocol-frames Segmented PostgreSQL backend frames and backend-message decoders
authentication PostgreSQL authentication messages and SCRAM server exchanges
pgoutput pgoutput protocol versions 1–4, streaming and two-phase messages
binary-copy Binary COPY field decoding through the built-in type registry
array-codec Binary and text PostgreSQL arrays
range-codec Binary and text ranges and multiranges
composite-codec Binary and text composite/record values
streams-envelope Integrity-protected Streams transaction envelopes
live-resume-token Raw, signed-malformed and structurally valid Live resume tokens

The harness accepts at most 64 KiB. A protocol input may yield at most 512 messages. Collection decoders reject counts that exceed either their remaining payload capacity or the 4,096-item parser ceiling. Streams decoding uses separate limits of 512 changes, 128 tables, 256 columns per table and 4 KiB per string. CI gives each execution 2 seconds and the .NET managed heap 1 GiB. AFL’s virtual-address-space limit is disabled because the .NET runtime reserves more address space than it commits; the GC heap hard limit supplies the bounded managed-memory control instead.

The machine-readable source contract is eng/fuzzing-contract.json. It prevents the target registry, runner, workflow matrix, encoded corpus directories and resource limits from silently drifting apart:

./eng/verify-fuzzing-contract.ps1

Structured codecs reject declared array-element and record-field lengths before slicing the remaining payload. Text arrays enforce the same six-dimension ceiling as binary arrays, CLR array-bound translation is range checked, and binary timestamps outside the representable .NET range are rejected as malformed values.

Deterministic replay

Every seed and minimized regression is stored as Base64 in tests/fuzz-corpus/<target>. The normal solution test run replays every case:

dotnet test tests/BlueTusk.Fuzzing.Tests/BlueTusk.Fuzzing.Tests.csproj `
  --configuration Release

Replay one materialized input directly with:

dotnet run --project tests/BlueTusk.Fuzzing/BlueTusk.Fuzzing.csproj `
  --configuration Release -- `
  --replay protocol-frames artifacts/fuzz/case.bin

Coverage-guided runs

Install AFL++ and restore the repository-pinned SharpFuzz tool, then run:

dotnet tool restore
./eng/run-fuzz.ps1 `
  -Target protocol-frames `
  -DurationSeconds 60 `
  -ExecutionTimeoutMilliseconds 2000 `
  -MemoryLimitMegabytes 1024 `
  -MaximumInputBytes 65536

fuzzing.yml runs a 45-second smoke for every target on pushes and pull requests, a one-hour-per-target scheduled run, and an explicitly configurable manual run. Each job archives its fuzzer state. Crash and hang inputs are also converted to replayable Base64 with source-commit and SHA-256 metadata by archive-fuzz-findings.ps1, and make the job fail.

Manual runs enforce at least 3,600 seconds per target. A successful manual run ID for the exact candidate commit is mandatory input to the protected V1 readiness workflow; a push, pull-request, scheduled, ancestor-commit or shorter run is not candidate evidence.

The current release-blocking review record is the V1 fuzz-finding handoff.

The raw AFL state is compressed before artifact upload because AFL++ queue filenames contain colons, which are valid on Linux but rejected by GitHub’s cross-platform artifact service.

Minimize a finding against the instrumented harness with:

./eng/minimize-fuzz-finding.ps1 `
  -Target protocol-frames `
  -InputPath artifacts/fuzz/protocol-frames/findings/default/crashes/id-000000 `
  -OutputPath artifacts/fuzz/minimized-protocol-frame `
  -InstrumentedDirectory artifacts/fuzz/protocol-frames/bin

After review, encode the minimized file as Base64, add it to the matching checked-in corpus directory, fix the parser defect, and rerun both deterministic replay and coverage-guided smoke.