How would you write a Go fuzz target for a function that decodes and validates a request?
answer
- FuzzXxx takes a *testing.F
- seeds go in with f.Add
- no panic is the weak property
- a nil error should imply the invariants
- failing inputs land in testdata/fuzz
basics
~20 sAdd a FuzzXxx function taking *testing.F, seed it with f.Add on real payloads, and inside f.Fuzz assert properties: parsing never panics, and a nil error always yields a value satisfying the invariants callers rely on.
solid answer
~50 sPut a function `FuzzParseCreateUser(f *testing.F)` in a `_test.go` file, seed it with `f.Add` on a handful of real bodies plus a few known-bad ones, and call `f.Fuzz(func(t *testing.T, data []byte) { ... })` with the property you actually care about. Two properties are worth asserting: the parse never panics on any byte string, and whenever it returns a nil error the value it produces satisfies the invariants the rest of the service assumes - a non-empty tenant, an email that parses, a range whose start precedes its end. Asserting only "it does not panic" is weak, because a validator that accepts everything passes it. Run it with `go test -fuzz=FuzzParseCreateUser -fuzztime=1m`; a failing input is written into `testdata/fuzz/FuzzParseCreateUser/` and from then on runs as an ordinary regression test on every `go test`, with no `-fuzz` flag needed.
code
go · 13 linesfunc FuzzParseCreateUser(f *testing.F) {
f.Add([]byte(`{"tenant_id":"acme","email":"[email protected]"}`))
f.Add([]byte(`{"email":"[email protected]"}`))
f.Fuzz(func(t *testing.T, data []byte) {
u, err := ParseCreateUser(data)
if err != nil {
return
}
if u.Tenant() == "" {
t.Fatalf("accepted a request with no tenant: %q", data)
}
})
}go deeper
Know that Go has fuzzing built into the testing package: a function named FuzzXxx that takes *testing.F, seeds added with f.Add, and the body passed to f.Fuzz.
Explain the run modes - seeds only by default, generation with -fuzz and -fuzztime - and that generated arguments must be one of the supported types such as []byte or string.
Show that you choose the property, not just the plumbing: a nil error implying the value's invariants, failing inputs committed under testdata as regressions, and an honest account of what a clean run does not prove.
Own where fuzzing runs and what it costs: nightly rather than per-pull-request, which boundaries deserve a target at all, and who is on the hook when the corpus grows a failure nobody triages.
## Why fuzz a decode-and-validate function at all The function that turns request bytes into a usable value is the one place a service trusts input it did not write. Table tests cover the payloads someone thought of; the interesting failures are the ones nobody imagined - a number where a string belongs, a deeply odd but syntactically legal document, a field present but empty, a byte string that is not valid UTF-8. Coverage-guided fuzzing generates those by mutating seeds and keeping the mutations that reach new code, which is exactly the search a human is bad at. ## The mechanics Go's native fuzzing lives in `testing`. A fuzz target is a function in a `_test.go` file named `FuzzXxx` taking a single `*testing.F`: ``` func FuzzParseCreateUser(f *testing.F) { f.Add([]byte(`{"tenant_id":"acme","email":"[email protected]"}`)) f.Add([]byte(`{"email":"[email protected]"}`)) f.Fuzz(func(t *testing.T, data []byte) { u, err := ParseCreateUser(data) if err != nil { return } if u.Tenant() == "" { t.Fatalf("accepted a request with no tenant: %q", data) } }) } ``` - `f.Add` supplies the **seed corpus**: real inputs the fuzzer mutates. Seeds are also checked into the package's `testdata` directory if you want them shared. - `f.Fuzz` takes a function whose first parameter is `*testing.T`; the remaining parameters are the generated input. They must be one of a fixed set of types - `[]byte`, `string`, `bool`, the sized integer and float types, `byte` and `rune` - so a target for a decoder naturally takes `[]byte`. - The arguments of every `f.Add` call must match the fuzz function's parameters in number and type. ## Choosing the property, which is the whole game The fuzzer only tells you that an assertion failed; you decide what is worth asserting. 1. **It never panics.** Free, and it catches the real class of crash-on-input bugs. But it is satisfied by a parser that accepts every byte string, so on its own it proves very little about validation. 2. **A nil error implies the invariants hold.** This is the property that matters here: if the function claims success, then every rule the rest of the service depends on is true of the value it returned. Assert those rules directly against the returned value - not by calling the same validation function again, which would just be a tautology. 3. **Round-trip properties.** If the value can be re-encoded, decoding the re-encoded form should produce an equal value. Useful when a custom encoding is involved. 4. **Agreement between two implementations.** If a rewrite is in flight, assert old and new agree on accept-or-reject for the same bytes. ## Running it and keeping what it finds Without the `-fuzz` flag, `go test` still executes the target once per seed and per file in the package's `testdata` corpus - so a fuzz target is a normal unit test in CI and costs nothing there. Passing `-fuzz=FuzzParseCreateUser` switches on generation; it matches at most one target per package run, and `-fuzztime` bounds it (otherwise it runs until it fails or you stop it). When an input fails, the fuzzer minimises it and writes the reduced input as a file under `testdata/fuzz/FuzzParseCreateUser/`. That file is source: commit it. Every subsequent plain `go test` replays it, so the bug cannot come back silently. This is the part teams under-use - the corpus is a growing regression suite built out of real defects. ## Limits worth stating Fuzzing is dynamic. It reports failures on inputs it happened to generate; a clean run proves nothing about the paths it never reached. It is also slower and noisier than a table test, so it belongs on a scheduled or nightly job rather than on every pull request, with the discovered corpus carrying the value into the fast test run. And it tests the function you wrote, not the rules you forgot: if the requirement to have a tenant id was never expressed anywhere, no fuzzer will invent it.
- What does go test do with a FuzzXxx function when you do not pass -fuzz?It runs it as an ordinary test: the fuzz function is executed once for each seed added with `f.Add` and once for each file in the package's `testdata` corpus, then the test finishes. No inputs are generated. That is why a fuzz target is cheap to keep in CI, and why a committed failing input works as a permanent regression test.
- Why is "it never panics" a weak property for a validator?Because a function that accepts every input and returns a zero value satisfies it perfectly. Not panicking says the code survived; it says nothing about whether the value handed back is usable. The property with teeth is that a nil error implies the returned value meets the invariants the rest of the service assumes - assert those directly rather than re-running the same validation.
- What happens to an input that makes the target fail?The fuzzer minimises it and writes the reduced input as a file under the package's `testdata/fuzz/<FuzzName>/` directory, then reports the failure. That file should be committed: every later `go test` run, with no `-fuzz` flag, replays it as a normal test case, so the corpus becomes a regression suite assembled out of real defects.
saying these in an interview costs you the question
- Asserts only that the parser does not panic
- Re-calls the same validator as the fuzz property, a tautology
- Thinks a fuzz target needs -fuzz to run at all
- Deletes the failing input file after fixing the bug
- Claims a clean fuzz run proves the parser is correct