Go’s fuzzing isn’t just about finding crashes; it’s a powerful tool for discovering unexpected behavior and logic errors in your code.
Here’s a look at a typical fuzzing scenario, tracing a request through a hypothetical Go service.
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
// User represents a user profile.
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}
// processUserData handles incoming user data, potentially from an untrusted source.
func processUserData(data []byte) (*User, error) {
var user User
if err := json.Unmarshal(data, &user); err != nil {
return nil, fmt.Errorf("failed to unmarshal user data: %w", err)
}
if user.ID <= 0 {
return nil, fmt.Errorf("user ID must be positive, got %d", user.ID)
}
if strings.TrimSpace(user.Username) == "" {
return nil, fmt.Errorf("username cannot be empty")
}
if !strings.Contains(user.Email, "@") {
return nil, fmt.Errorf("invalid email format for %s", user.Email)
}
// Simulate some processing that might have bugs
if len(user.Username) > 50 {
// This is a potential bug: what if a very long username causes issues elsewhere?
// For now, we'll just return it.
}
return &user, nil
}
func main() {
http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusInternalServerError)
return
}
defer r.Body.Close()
user, err := processUserData(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
responseJSON, err := json.Marshal(user)
if err != nil {
http.Error(w, "Error marshalling response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(responseJSON)
})
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
The processUserData function is where the magic (and potential bugs) happen. It takes raw []byte, attempts to unmarshal it into a User struct, and then performs several validation checks. The goal of fuzzing here is to generate a vast array of []byte inputs to processUserData to uncover edge cases that manual testing might miss.
To fuzz this function, you’d create a fuzz target file, say fuzz_test.go:
package main
import (
"testing"
)
func FuzzProcessUserData(f *testing.F) {
// Seed corpus: examples of valid and interesting inputs
f.Add([]byte(`{"id": 1, "username": "alice", "email": "alice@example.com"}`))
f.Add([]byte(`{"id": -1, "username": "", "email": "invalid"}`)) // Invalid cases to start
f.Add([]byte(`{"id": 100, "username": "a".repeat(60), "email": "bob@example.com"}`)) // Long username
f.Fuzz(func(t *testing.T, data []byte) {
_, err := processUserData(data)
// We are primarily interested in *unexpected* errors here.
// If processUserData returns an error for valid-looking input, that's a bug.
// If it panics, that's definitely a bug.
// For simplicity, we're not asserting specific error messages or return values,
// just letting the fuzzer explore.
if err != nil {
// Optionally, log the input that caused the error for debugging.
// t.Logf("Input that caused error: %s", string(data))
}
})
}
When you run go test -fuzz=FuzzProcessUserData, Go’s built-in fuzzing engine kicks in. It starts with the "seed corpus" you provided and then mutates these inputs, generating new byte slices. Each generated input is passed to your FuzzProcessUserData function. If processUserData panics or returns an error for an input that should be valid, or if the fuzzer finds a new way to trigger an existing error condition that indicates a logic flaw, it will report it. The fuzzer keeps track of inputs that produce interesting outcomes (like crashes or new error types) and saves them to a testdata/fuzz directory.
The core problem this solves is the inherent difficulty in exhaustively testing all possible input combinations, especially for functions that deal with external data like network requests, file parsing, or user input. Fuzzing automates the discovery of edge cases, malformed inputs, and unexpected states that humans might overlook. Internally, the Go fuzzing engine uses a coverage-guided approach. It monitors which parts of your code are executed by each input. Inputs that increase code coverage (i.e., reach new code paths) are prioritized and further mutated. This intelligently guides the search towards interesting, unexplored areas of your program. You control the fuzzing by providing a good seed corpus and by defining the behavior you expect (or don’t expect) in the f.Fuzz function.
A subtle but powerful aspect of fuzzing is its ability to find "impossible" states. Imagine a function that’s supposed to always return a positive integer, but after fuzzing, you discover an input that makes it return -1. This isn’t necessarily a crash, but it’s a violation of the function’s contract. The fuzzer can uncover these logical inconsistencies. It’s not just about finding panics; it’s about finding inputs that violate your assumptions about how your code should behave.
The next step after effective fuzzing is often exploring how to integrate fuzzing results into your CI/CD pipeline to prevent regressions.