# Fuzzing natif en Go : trouver les bugs imprévus

> Septième article de la série Testing Go. testing.F, f.Add et f.Fuzz expliqués sur un cas concret (un truncate UTF-8 qui crashe), le rôle du seed corpus, la minimisation automatique, le dossier testdata/fuzz pour rejouer les bugs en regression, les types supportés (et la frustration des structs), et pourquoi le fuzzing est le seul filet qui attrape les bugs que l'IA n'imagine pas.


*Ou : Comment laisser le compilateur Go générer des inputs plus pervers que ton pire utilisateur*

Tu as écrit un test. Trois cas : le happy path, un cas vide, un cas un peu chelou. Tu trouves ça raisonnable. L'IA en rajoute cinq, parce qu'elle aime les chiffres ronds. Vous avez maintenant huit cas de test. Tu as 92 % de coverage. Tu pousses. La nuit, en production, un utilisateur copie-colle le contenu d'un email avec un caractère invisible Unicode dedans. Ta fonction panic. Le service tombe. Personne n'avait imaginé ce cas. Pas toi. Pas l'IA. Pas le reviewer. C'est précisément le problème.

Tous les tests qu'on a vus dans cette série — [table-driven](/blog/table-driven-tests-go/), [subtests](/blog/subtests-trun-go/), [httptest](/blog/httptest-go-tester-apis-sans-serveur/), [mocks](/blog/interfaces-mocking-go/), [Testify](/blog/testify-assertions-lisibles-go/) — partagent le même angle mort : ils valident ce que tu **anticipes**. Ils sont aussi forts que ton imagination, et ton imagination est limitée par ton expérience. L'IA n'aide pas vraiment, parce que son corpus d'entraînement est, lui aussi, fait de cas anticipés par d'autres humains. Entraîner un modèle sur dix millions de tests humains, ça ne génère pas un test que personne n'a écrit. Ça génère un test moyen.

Le fuzzing, c'est l'inverse. Tu donnes à un moteur une fonction et quelques exemples d'inputs valides ; il génère des millions de variantes pseudo-aléatoires, guidées par la couverture de code, jusqu'à ce que l'une fasse exploser ta fonction. Et il en trouve. Toujours. Y compris dans la stdlib Go elle-même[^1]. Depuis Go 1.18 (mars 2022), tout ça est intégré au `testing` natif via `testing.F`. Pas de dépendance, pas de framework, juste `go test -fuzz`.

---

## Fuzz testing en Go avec testing.F : seed corpus, minimisation et regression

### `testing.F` : la même API qu'avant, en plus aléatoire

Une fuzz function, en Go, ressemble à un test classique sauf qu'elle prend un `*testing.F` au lieu d'un `*testing.T`. La signature, le nom, l'emplacement : tout est familier.

```go
package validator

import "testing"

func FuzzTruncate(f *testing.F) {
    // 1. Seed corpus : on donne quelques exemples valides
    f.Add("hello world", 5)
    f.Add("", 0)
    f.Add("café", 3)

    // 2. Fuzz function : appelée des millions de fois avec des variantes
    f.Fuzz(func(t *testing.T, s string, max int) {
        got := Truncate(s, max)
        if max < 0 {
            return // entrée invalide, on ignore
        }
        if !utf8.ValidString(got) {
            t.Errorf("Truncate(%q, %d) = %q, sortie UTF-8 invalide", s, max, got)
        }
    })
}
```

Trois éléments. La fonction s'appelle `FuzzTruncate` (préfixe obligatoire), elle est dans un fichier `_test.go`, et elle utilise deux méthodes du `*testing.F` : `Add` pour le seed corpus, `Fuzz` pour la fuzz function elle-même[^2].

Le contrat avec le moteur de fuzzing tient en deux phrases. **`Add`** lui dit : *« voilà des inputs valides, sers-t'en comme point de départ pour générer des variantes »*. **`Fuzz`** lui dit : *« voilà ce que tu fais avec chaque input ; si t.Errorf ou si ça panic, tu as trouvé un bug »*. Le moteur boucle, mute les inputs (flip de bits, troncature, allongement, substitution UTF-8), et regarde si la couverture de code change. Quand un nouvel input fait exécuter une nouvelle branche, il devient à son tour un seed pour de nouvelles mutations. C'est de l'**evolutionary fuzzing** guidé par la couverture, héritage direct d'AFL de Michal Zalewski et de la lib `dvyukov/go-fuzz` qui a porté l'écosystème Go pendant des années avant que la stdlib n'absorbe la feature[^3].

---

### Le truncate qui semblait innocent

Reprenons le code testé. Voici la version « écrite par l'IA en trois secondes » :

```go
package validator

func Truncate(s string, max int) string {
    if len(s) <= max {
        return s
    }
    return s[:max] + "..."
}
```

Tu lui demandes des tests, elle te génère :

```go
func TestTruncate(t *testing.T) {
    cases := []struct {
        in   string
        max  int
        want string
    }{
        {"hello", 10, "hello"},
        {"hello world", 5, "hello..."},
        {"", 0, ""},
        {"abc", 3, "abc"},
    }
    for _, c := range cases {
        if got := Truncate(c.in, c.max); got != c.want {
            t.Errorf("Truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
        }
    }
}
```

Quatre cas. Tous passent. Coverage 100 %. Code review approuvée. Merge. C'est plausible parce que c'est plausible — c'est la définition du code IA.

Maintenant tu lances :

```bash
go test -fuzz=FuzzTruncate -fuzztime=10s ./validator
```

Quinze secondes plus tard :

```
--- FAIL: FuzzTruncate (0.42s)
    --- FAIL: FuzzTruncate (0.00s)
        validator_test.go:18: Truncate("café", 3) = "ca\xc3...", sortie UTF-8 invalide

Failing input written to testdata/fuzz/FuzzTruncate/4f3a1b...
```

Le bug. `len("café") == 5` parce que `é` occupe deux octets en UTF-8. `s[:3]` coupe pile au milieu du `é` et te laisse un demi-caractère orphelin (`0xC3`), techniquement un byte mais pas un point de code valide. Ta fonction renvoie `"ca\xc3..."`, ce qui est une string Go légale (les strings Go ne sont pas garanties UTF-8) mais que `json.Marshal`, `template.Execute`, ou n'importe quel client HTTP downstream va refuser ou corrompre. Et personne n'avait écrit ce test, parce que personne ne se réveille en pensant *« ah tiens, et si je truncatais au milieu d'un emoji ? »*. Le fuzzer, lui, mute `"hello world"` un caractère à la fois, finit par essayer une string contenant un caractère multi-byte, et déclenche le bug en quelques millisecondes.

![Un nuage d'inputs aléatoires entrant dans une fonction Go ; certains rebondissent, un seul provoque une étincelle rouge à l'intérieur](/images/fuzzing-natif-go-cloud-input.original.webp "Le fuzzer mute les seeds en continu. La plupart des inputs passent. Un seul fait exploser la fonction.")

C'est le pattern de référence du fuzzing : tu écris une **propriété qui doit toujours être vraie** (« la sortie est UTF-8 valide »), tu laisses le moteur trouver l'input qui la viole. Tu n'as pas besoin de connaître le bug à l'avance — tu as besoin de connaître ce que ta fonction *promet*.

---

### Le seed corpus : pourquoi `f.Add` n'est pas optionnel

En théorie, le fuzzer fonctionne même sans aucun `f.Add`. Il commence avec des inputs vides ou de bytes aléatoires et explore à partir de là. En pratique, sans seed corpus, il met des heures à trouver le moindre cas intéressant, parce qu'il passe l'essentiel de son temps à apprendre la « grammaire » de tes inputs (quelles structures parsent, quels champs existent, quels formats sont valides).

Donner un seed, c'est lui donner le point de départ. Trois conseils :

1. **Au moins un seed par branche identifiable** de ton code. Si ta fonction a un cas spécial pour les strings vides, tu donnes une string vide. Si elle traite les chaînes UTF-8 et ASCII différemment (parfois implicitement), tu donnes une instance de chaque.
2. **Des seeds réalistes**, pas des cas pathologiques. Tu veux que le moteur découvre les pathologies tout seul. Si tu lui donnes déjà l'input qui crashe, le test passe le premier coup et tu n'as rien gagné.
3. **Pas trop de seeds**. Au-delà d'une dizaine, tu introduis surtout du bruit ; le moteur ne sait plus lesquels muter en priorité.

Une autre voie : Go cherche aussi des seeds dans `testdata/fuzz/<NomDuFuzzTest>/`. Chaque fichier dans ce dossier est un input persisté, et le moteur l'utilise comme seed à chaque run. C'est précisément là qu'il *écrit* automatiquement les inputs qui ont fait échouer un fuzzing précédent (d'où le `Failing input written to testdata/fuzz/FuzzTruncate/4f3a1b...`). Tu commits ce fichier dans Git, et il devient un test de régression : la prochaine fois que `go test ./...` tourne (sans flag `-fuzz`), il rejouera ce cas comme un test classique. Si quelqu'un réintroduit le bug, le test échoue immédiatement, sans avoir besoin de relancer le fuzzing.

---

### Les types supportés (et ceux qui ne le sont pas)

Le `f.Fuzz` accepte un nombre arbitraire d'arguments après le `*testing.T`, mais leurs types sont contraints. Liste exhaustive : `string`, `[]byte`, `bool`, `byte`, `rune`, `float32`, `float64`, `int`, `int8`, `int16`, `int32`, `int64`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`. C'est tout. Pas de `map`. Pas de `slice` autre que `[]byte`. Pas de struct. Pas d'interface. Pas de pointeur.

Cette limitation est volontaire — le moteur a besoin d'une représentation binaire stable et mutable pour chaque input, et les types arbitraires rendraient la mutation chaotique. Mais ça change ta façon d'écrire les fuzz tests. Si tu veux fuzzer une fonction qui prend une struct, tu fuzzes les **bytes qui la décrivent** et tu laisses la fuzz function la reconstruire :

```go
func FuzzParseUserJSON(f *testing.F) {
    f.Add([]byte(`{"name":"Alice","age":30}`))
    f.Add([]byte(`{}`))
    f.Add([]byte(`{"age":-1}`))

    f.Fuzz(func(t *testing.T, data []byte) {
        var u User
        err := json.Unmarshal(data, &u)
        if err != nil {
            return // un JSON invalide n'est pas un bug
        }
        // Propriété : un User unmarshalé puis remarshalé doit être idempotent
        out, _ := json.Marshal(u)
        var u2 User
        if err := json.Unmarshal(out, &u2); err != nil {
            t.Errorf("round-trip cassé : %q → %q → %v", data, out, err)
        }
    })
}
```

Le pattern « round-trip » est l'un des plus puissants du fuzzing : si `parse` puis `marshal` puis `parse` ne donne pas le même résultat que le premier `parse`, tu as un bug. Tu n'as même pas besoin de savoir lequel — le fuzzer te livre l'input minimal qui le déclenche. C'est *exactement* le pattern qui a permis de trouver des bugs dans `encoding/json` lui-même[^1] : le fuzz target produisait un JSON, le re-marshalait, et comparait. Le moteur trouvait régulièrement des inputs où la propriété d'idempotence ne tenait pas — typiquement des cas avec clés dupliquées ou caractères encodés à la limite de la spec.

---

### La minimisation : du chaos à un cas reproductible

Quand le fuzzer trouve un input qui fait échouer ton test, il ne se contente pas de te le sauvegarder tel quel. Il essaie automatiquement de le **réduire** : il enlève des bytes, modifie des caractères, raccourcit la string — tant que la fonction continue d'échouer, l'input réduit devient le nouveau cas de référence. C'est la **minimisation**, et c'est probablement la fonctionnalité la plus sous-estimée du fuzzing natif.

L'input brut qui a déclenché le bug peut faire 4 Ko, contenir des séquences UTF-8 hallucinées, des bytes nuls et trois lignes de garbage. L'input minimisé fait souvent 5 caractères et te dit immédiatement *ce que tu as cassé*. Pour le `Truncate` plus haut, le moteur va te livrer quelque chose comme `"é", max=1` ou `"€", max=1` — un cas que tu peux lire en deux secondes et comprendre en cinq.

![Une boîte de Petri isolant un seul caractère unicode au centre, surlignée comme la cause minimale d'un crash](/images/fuzzing-natif-go-minimization.original.webp "La minimisation : passer d'un input chaotique de plusieurs Ko à l'instance la plus courte qui reproduit encore le bug.")

Petit piège technique : dans la fuzz function, `(*F).Log`, `(*F).Error`, `(*F).Skip` sont **interdites**. Tu dois utiliser les méthodes équivalentes du `*testing.T` qui t'est passé en premier argument. Ne pas le savoir produit une panic plus déroutante que le bug que tu cherchais. Le compilateur ne te le signale pas, c'est un check runtime du framework. C'est typique des features ajoutées tard à un type stable : la nouveauté hérite des contraintes de l'ancien.

---

### Ce que le fuzzing ne fait pas

Le fuzzing natif Go est **simple à utiliser**. Il n'est pas, dans l'absolu, le moteur de fuzzing le plus performant qui existe. libFuzzer (LLVM), AFL++, Honggfuzz — tous ont une décennie d'avance sur l'instrumentation, la mutation et la stratégie de coverage. La conséquence pratique : sur des fonctions de parsing complexes, un fuzzer C ou Rust trouvera des bugs en quelques secondes là où Go peut tourner pendant des heures sans rien produire de neuf.

Cela dit, deux choses tempèrent. D'une part, [OSS-Fuzz](https://google.github.io/oss-fuzz/) — le service de fuzzing continu de Google qui tourne 24/7 sur les projets open source critiques — supporte les fuzz tests Go natifs depuis 2022 et les compile en binaires libFuzzer. Tu écris ton fuzz test une fois en `testing.F`, OSS-Fuzz le fait tourner pendant des semaines sur leur infrastructure ; tu reçois un mail quand il trouve quelque chose. C'est gratuit pour les projets open source. C'est *très bien*.

D'autre part, la perfection technique du moteur compte moins qu'on le croit dans la pratique. Le bug du `Truncate` plus haut se trouve en deux secondes avec n'importe quel fuzzer, parce que la propriété testée (UTF-8 valide) est facile à violer dès qu'on touche aux strings. La majorité des bugs que le fuzzing va trouver dans ton code, c'est ce genre de bugs : pas des CVE de niveau Linux kernel, juste des cas que personne n'avait imaginés et qui font crash en prod. Pour ça, le moteur natif Go suffit largement, et il a l'énorme avantage de coûter zéro friction d'installation.

---

### Là où l'IA est mise à nu

Le truc surprenant en utilisant le fuzzing sur du code généré par IA, c'est la *régularité* avec laquelle il trouve des bugs. Pas des cas exotiques. Des cas vraiment basiques : strings vides, valeurs négatives, overflow d'entiers à `MaxInt`, slice de longueur zéro, runes Unicode supplémentaires. L'IA produit du code qui passe ses propres tests parce qu'elle teste les cas qui *ressemblent* à ce qu'elle a vu pendant l'entraînement. Le fuzzer teste les cas qui *ne ressemblent à rien*.

> **Junior Jules :** J'ai demandé à Claude de me coder un parser CSV. Il l'a fait, j'ai écrit une dizaine de tests, tout passe.
>
> **Senior Sam :** Tu as fuzzé ?
>
> **Junior Jules :** Fuzzé ?
>
> **Senior Sam :** `f.Add` un CSV valide, `f.Fuzz` qui parse l'input et vérifie qu'il ne panic pas. Lance `go test -fuzz` pendant trente secondes.
>
> *(deux minutes plus tard)*
>
> **Junior Jules :** Il a trouvé un panic sur un input avec un guillemet sans fermeture suivi de plusieurs caractères Unicode RTL.
>
> **Senior Sam :** En combien de temps ?
>
> **Junior Jules :** Onze secondes.
>
> **Senior Sam :** Donc tu as gagné un parser CSV cassable par n'importe quel utilisateur turc ou arabe. Bien joué.
>
> **Junior Jules :** Comment je corrige ?
>
> **Senior Sam :** Tu commits le seed dans `testdata/fuzz/`, tu fais corriger le bug par l'IA, et tu vérifies que le test de régression passe. Ensuite tu relances le fuzzing pendant cinq minutes pour voir s'il trouve autre chose.

Le pattern, ici, n'est pas anti-IA. Au contraire : tu utilises l'IA pour générer le code et les tests rapidement, et tu utilises le fuzzer pour valider ce que ni elle ni toi n'auriez pensé à tester. Les deux outils se complètent — mais seulement si tu connais le second.

---

### Ce que tu peux faire maintenant

1. Choisis une fonction « pure » de ton projet (pas d'I/O, pas de DB) qui prend des inputs primitifs : un parser, un validateur, un encodeur, un slugifier. Écris une fuzz function avec deux ou trois `f.Add` réalistes et une assertion sur une **propriété invariante** (pas de panic, sortie UTF-8 valide, round-trip identique).
2. Lance `go test -fuzz=FuzzXxx -fuzztime=30s ./...`. Trente secondes suffisent pour 80 % des bugs faciles à trouver.
3. Si le fuzzer trouve quelque chose, le fichier dans `testdata/fuzz/` est ton nouveau test de régression. Commit-le tel quel, corrige le bug, et `go test ./...` rejouera ce cas à chaque CI sans avoir besoin du flag `-fuzz`.
4. Configure une CI qui lance un fuzzing court (deux à cinq minutes) sur les fuzz tests modifiés depuis le dernier commit. Tu n'as pas besoin de faire tourner des heures à chaque PR — quelques minutes attrapent l'essentiel des régressions.
5. Si ton projet est open source et populaire, demande l'inclusion à [OSS-Fuzz](https://google.github.io/oss-fuzz/getting-started/new-project-guide/go-lang/). Ils font tourner ton fuzzer pendant des semaines sur leur infrastructure, gratuitement.

---

### La suite

On a appris à générer des inputs imprévus pour trouver des bugs imprévus. La logique de notre code est maintenant testée sous tous les angles : cas anticipés (table-driven), cas isolés (subtests), cas HTTP (httptest), cas avec dépendances (mocks), cas par assertion (Testify), cas inimaginables (fuzzing). Reste un angle qu'on n'a pas encore touché : la **performance**. L'IA prétend régulièrement « optimiser » du code Go. Est-ce que c'est vrai ? Est-ce que sa nouvelle version est *réellement* plus rapide que l'ancienne, ou juste différente ?

Dans le prochain article, on regarde les **benchmarks et le profiling** Go natifs : `testing.B`, `b.N`, `b.ResetTimer`, `b.ReportAllocs`, `go test -bench`, `benchstat` pour comparer deux runs, et `pprof` pour voir où le CPU et la mémoire passent vraiment. Avec un exercice central : prendre une fonction, demander à l'IA de l'optimiser, et benchmarker les deux versions pour vérifier si l'optimisation est réelle ou hallucinée. Spoiler : les deux cas existent, et le seul moyen de les distinguer, c'est de mesurer.

<div class="next-article">
<a href="/blog/benchmarks-profiling-go/">
<span class="next-article__label">Article suivant de la série</span>
<p class="next-article__title">Benchmarks et profiling Go : l'IA optimise-t-elle vraiment ton code ?</p>
<p class="next-article__desc">testing.B, b.ResetTimer, benchstat, pprof — et l'exercice qui révèle si l'IA optimise réellement ou si elle hallucine la performance.</p>
</a>
</div>

[^1]: L'exemple historique le plus documenté côté `encoding/json` est un crash par dépassement de slice trouvé via fuzzing en 2019 et tracké dans [l'issue #33728](https://github.com/golang/go/issues/33728) — la régression avait été introduite par une optimisation de Go 1.13 (CL 151157), détectée par OSS-Fuzz côté Chromium, puis revertée. Pendant le prototypage de la feature « fuzzing as a first-class citizen » discutée dans [l'issue #31309](https://github.com/golang/go/issues/31309), l'équipe Go a aussi documenté plusieurs faux positifs sur le round-trip JSON — typiquement des cas avec clés dupliquées qui révélaient des ambiguïtés de spec plutôt que de vrais bugs. Toute cette exploration s'est faite avec `dvyukov/go-fuzz` avant que la stdlib n'absorbe la feature en Go 1.18.

[^2]: La signature complète documentée [ici](https://go.dev/doc/security/fuzz/) : un fuzz target est un `func FuzzXxx(f *testing.F)` dans un fichier `_test.go` ; à l'intérieur, `f.Add(args...)` prépare le seed corpus et `f.Fuzz(func(t *testing.T, args ...))` déclare la fuzz function. Les types des `args` doivent matcher exactement entre `f.Add` et la signature de la fuzz function. Une seule fuzz function par fuzz target. Les méthodes `(*F).Log`, `(*F).Error`, `(*F).Skip` sont interdites *à l'intérieur* de la fuzz function — il faut utiliser les méthodes équivalentes sur le `*T`. Cette dernière règle est un check runtime, pas une erreur de compilation, ce qui garantit que tu te feras avoir au moins une fois.

[^3]: Avant Go 1.18, le fuzzing en Go passait par [`github.com/dvyukov/go-fuzz`](https://github.com/dvyukov/go-fuzz), maintenu par Dmitry Vyukov (Google), qui s'inspirait directement d'[american fuzzy lop](https://lcamtuf.coredump.cx/afl/) (AFL) de Michal Zalewski — l'outil qui a démocratisé l'evolutionary fuzzing au début des années 2010 et trouvé des centaines de CVE dans des projets comme OpenSSL, GnuPG ou les browsers. La proposal officielle de fuzzing natif Go référence explicitement `go-fuzz` comme inspiration, et la feature est arrivée dans la stdlib après plusieurs années de discussion (issue [#44551](https://github.com/golang/go/issues/44551)). Le moteur actuel n'est pas aussi rapide que libFuzzer ou AFL++, mais l'avantage *structurel* d'être intégré à `go test` rend la barrière d'entrée presque nulle — et c'est probablement plus important pour la qualité moyenne du code Go que cinq pourcents de mutations par seconde en plus.


