Fuzzing (ou fuzzing testing) é uma técnica de teste de software que consiste em fornecer entradas aleatórias, inválidas ou inesperadas a um programa, com o objetivo de encontrar falhas, panics, erros de lógica ou vulnerabilidades de segurança. Em Go, o suporte nativo a fuzzing foi introduzido na versão 1.18, permitindo que os desenvolvedores escrevam funções de fuzzing de forma integrada ao pacote de testes padrão. Nesta aula, vamos explorar como criar testes de fuzzing em Go, executá-los, interpretar os resultados e usar o fuzzing para melhorar a robustez do seu código.

O fuzzing em Go é especialmente útil para testar funções que processam entrada de dados externos, como parsers, decodificadores, funções de rede ou qualquer código que manipule dados de usuários. Ele complementa os testes unitários tradicionais, encontrando casos extremos que muitas vezes passam despercebidos. Vamos mergulhar nos detalhes práticos, desde a estrutura básica até estratégias avançadas de uso.

func FuzzXxx

Em Go, um teste de fuzzing é definido por uma função que começa com o prefixo Fuzz e recebe um parâmetro do tipo *testing.F. Essa função é responsável por registrar funções de fuzzing (chamadas de seed corpus) e por definir a função alvo (fuzz target) que será executada com entradas geradas automaticamente. A assinatura típica é:

func FuzzXxx(f *testing.F) {
    // Seeds (entradas iniciais)
    f.Add(seed1, seed2, ...)

    // Função alvo
    f.Fuzz(func(t *testing.T, data []byte) {
        // Código a ser testado com data
        MyFunction(data)
    })
}

O método f.Add adiciona entradas fixas (seeds) ao corpus inicial. Essas seeds são importantes porque fornecem exemplos válidos e conhecidos para o fuzzer, garantindo que os casos básicos sempre sejam testados. O método f.Fuzz recebe uma função anônima (ou nomeada) que será chamada repetidamente com dados gerados aleatoriamente. O primeiro parâmetro dessa função é sempre *testing.T (para relatar falhas), e os demais parâmetros são os dados de entrada, que podem ser de tipos primitivos como string, []byte, int, float64, etc. O fuzzer gera valores desses tipos e os passa para a função.

Vamos a um exemplo concreto: vamos criar um fuzzing para uma função que converte uma string em um inteiro, mas que pode falhar para entradas inesperadas. Primeiro, criamos um arquivo de teste chamado fuzz_test.go:

package main

import (
    "strconv"
    "testing"
)

// Função que queremos testar (exemplo)
func ParseInt(s string) (int, error) {
    return strconv.Atoi(s)
}

func FuzzParseInt(f *testing.F) {
    // Seeds: entradas comuns
    f.Add("123")
    f.Add("-42")
    f.Add("0")
    f.Add("  42") // com espaços

    f.Fuzz(func(t *testing.T, s string) {
        // Chamamos a função alvo
        _, err := ParseInt(s)
        // Se não houver erro, ok; se houver, também ok (esperado para entradas inválidas)
        // Mas se houver um panic, o teste falha automaticamente
        if err != nil {
            return
        }
        // Se a função retornar sem erro, podemos verificar propriedades adicionais
        // Por exemplo, se a string é numérica, o resultado deve ser igual ao esperado
        // Aqui, apenas garantimos que não panic
    })
}

Nesse exemplo, a função FuzzParseInt registra seeds como "123", "-42", etc., e define um target que chama ParseInt com qualquer string gerada. O fuzzer tentará encontrar entradas que causem panic (por exemplo, strings muito longas que causam estouro de memória) ou outros comportamentos inesperados.

É importante notar que a função FuzzXxx deve estar em um arquivo de teste (terminando em _test.go) e ser executada com a flag -fuzz no comando go test.

go test -fuzz

Para executar testes de fuzzing, usamos o comando go test com a flag -fuzz. O formato básico é:

go test -fuzz=FuzzFunctionName

Onde FuzzFunctionName é o nome da função de fuzzing (sem o prefixo Fuzz? Na verdade, a flag aceita o nome completo ou um padrão). Por exemplo, para executar o fuzzing da função FuzzParseInt no exemplo acima, usaríamos:

go test -fuzz=FuzzParseInt

Se quisermos rodar todos os fuzz tests do pacote, podemos usar -fuzz sem argumento (mas isso pode ser demorado). O comando go test -fuzz executará o fuzzing por um tempo ilimitado até encontrar uma falha ou ser interrompido (por padrão, o fuzzing é executado por 5 segundos se não houver falha, mas podemos controlar com a flag -fuzztime). A flag -fuzztime define a duração máxima: por exemplo, -fuzztime=30s para 30 segundos. Também é possível especificar um número de execuções com -fuzzminimizationtime e outras opções.

Durante a execução, o fuzzer gera novas entradas e as adiciona ao corpus de fuzzing (que fica em um diretório chamado testdata/fuzz). Se uma entrada causar uma falha (por exemplo, um panic, uma falha em uma asserção, ou um teste que retorna t.Fail()), o fuzzer para e salva a entrada que causou a falha em um arquivo dentro de testdata/fuzz/FuzzFunctionName. Esse arquivo pode ser usado posteriormente para reproduzir o problema e criar um teste de regressão.

Vejamos um exemplo de execução:

$ go test -fuzz=FuzzParseInt -fuzztime=10s
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing (10s)
...
FAIL: FuzzParseInt (0.05s)
    --- FAIL: FuzzParseInt (0.00s)
        fuzz_test.go:25: panic: runtime error: index out of range [10] with length 3
...
    Failing input written to testdata/fuzz/FuzzParseInt/6e4f...

Nesse caso, o fuzzer encontrou uma entrada que causou um panic, salvou o arquivo de entrada e o teste falhou. O arquivo salvo contém a entrada exata que causou o problema, permitindo que você depure e corrija o código.

Além da flag -fuzz, outras flags úteis incluem -fuzztime (duração), -fuzzminimizationtime (tempo para minimizar a entrada), -parallel (para paralelizar o fuzzing) e -cover (para cobertura de código).

Encontrando bugs

O fuzzing é uma ferramenta poderosa para encontrar bugs, especialmente aqueles que são difíceis de prever com testes manuais. Ele pode revelar:

  • Panics: entradas que causam pânico no programa, como acessos a índices inválidos, divisão por zero, etc.
  • Falhas de lógica: entradas que fazem a função retornar resultados incorretos, que podem ser detectados por asserções ou propriedades.
  • Vazamentos de memória: embora não seja o foco principal, o fuzzing pode ajudar a identificar comportamentos anômalos.
  • Vulnerabilidades de segurança: como buffer overflows, injeção de código, etc., especialmente em parsers e funções de rede.

Para encontrar bugs de lógica, é comum usar propriedades que devem ser verdadeiras para qualquer entrada válida. Por exemplo, se você está testando uma função de ordenação, pode verificar se a saída está ordenada e tem o mesmo comprimento da entrada. Em fuzzing, você pode escrever essas propriedades dentro da função alvo e usar t.Fatal ou t.Errorf para falhar o teste quando elas não forem satisfeitas.

Exemplo: vamos testar uma função que inverte uma string. Uma propriedade é que inverter duas vezes deve retornar a string original.

func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

func FuzzReverse(f *testing.F) {
    seeds := []string{"hello", "world", "a", "", "12345", "日本語"}
    for _, s := range seeds {
        f.Add(s)
    }

    f.Fuzz(func(t *testing.T, s string) {
        rev := Reverse(s)
        doubleRev := Reverse(rev)
        if s != doubleRev {
            t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, doubleRev, s)
        }
        // Outra propriedade: a reversão deve ter o mesmo comprimento (em runas)
        if len([]rune(s)) != len([]rune(rev)) {
            t.Errorf("Length mismatch: Reverse(%q) = %q, length %d vs %d", s, rev, len(s), len(rev))
        }
    })
}

Se executarmos esse fuzzing, ele pode encontrar entradas que quebram essas propriedades. Por exemplo, strings com caracteres Unicode podem causar problemas se a função não for implementada corretamente (usando bytes em vez de runas).

Quando o fuzzer encontra uma falha, ele gera uma entrada mínima (após um processo de minimização) e a salva. Você pode então examinar o arquivo salvo, reproduzir o bug com um teste comum e corrigi-lo.

Corpus

O corpus de fuzzing é o conjunto de entradas que o fuzzer utiliza como base para gerar novas variações. Ele é composto por:

  • Seeds: entradas fornecidas via f.Add no código do fuzz test.
  • Entradas geradas: entradas que o fuzzer cria durante a execução e que são adicionadas ao corpus se aumentarem a cobertura de código ou se forem interessantes.
  • Entradas salvas: quando uma falha é encontrada, a entrada que a causou é salva permanentemente no diretório testdata/fuzz.

O diretório testdata/fuzz é criado automaticamente na raiz do pacote. Para cada função de fuzzing, há um subdiretório com o nome da função (por exemplo, testdata/fuzz/FuzzParseInt). Dentro dele, cada entrada é salva em um arquivo com um nome hash (por exemplo, 6e4f... ). Esses arquivos contêm a entrada em um formato específico (geralmente uma representação textual dos argumentos).

O corpus é importante porque:

  • Fornece um conjunto de entradas de partida que garantem que os casos comuns sejam testados.
  • Permite que o fuzzer explore variações dessas entradas, aumentando a cobertura.
  • Facilita a reprodução de bugs: se um bug foi encontrado, a entrada salva pode ser usada em um teste tradicional para verificar a correção.

Você pode adicionar entradas manualmente ao corpus adicionando arquivos no diretório testdata/fuzz/FuzzFunctionName. O formato do arquivo é simples: cada linha contém um argumento (para múltiplos argumentos, cada um em uma linha). Por exemplo, para uma função que recebe uma string, o arquivo pode conter apenas a string. Para tipos como int, o valor é escrito como decimal. Para slices, é usado um formato semelhante a um array. Consulte a documentação oficial para detalhes.

Quando você executa go test sem a flag -fuzz, os testes de fuzzing são executados apenas com as seeds e as entradas do corpus, como testes normais. Isso é útil para verificar se os casos já conhecidos passam.

Uma boa prática é incluir no corpus entradas que representem casos limites, como strings vazias, strings com caracteres especiais, valores extremos, etc. Isso ajuda o fuzzer a começar de uma base sólida.

Boas práticas e observações finais

Ao usar fuzzing em Go, algumas boas práticas podem maximizar sua eficácia:

  • Forneça seeds representativas: inclua entradas típicas, limites e casos extremos.
  • Escreva propriedades significativas: use asserções que verifiquem invariantes do seu código, não apenas a ausência de panics.
  • Use tipos de dados simples: o fuzzer suporta tipos básicos como string, int, float64, bool, []byte, etc. Para estruturas complexas, você pode serializá-las em bytes e desserializar dentro do target.
  • Execute regularmente: integre o fuzzing ao seu pipeline de CI para descobrir bugs cedo.
  • Minimize as falhas: o fuzzer já minimiza automaticamente, mas você pode usar a flag -fuzzminimizationtime para controlar o esforço.
  • Use cobertura de código: execute com -cover para ver quais partes do código estão sendo exercitadas.

Lembre-se de que o fuzzing não substitui testes unitários, mas os complementa. Ele é particularmente eficaz para encontrar bugs em funções que processam entrada não confiável. Com o tempo, você verá uma melhoria significativa na robustez do seu código.

Referências

Exercícios

  1. Crie uma função FuzzAbs para testar a função math.Abs (que retorna o valor absoluto de um float64). Use seeds com valores positivos, negativos, zero e NaN. Na função alvo, verifique que o resultado é sempre não negativo e que, para valores não NaN, o valor absoluto de um número é igual ao valor absoluto do negativo.

    ✓ Resposta:
    package main
    
    import (
        "math"
        "testing"
    )
    
    func FuzzAbs(f *testing.F) {
        // Seeds
        f.Add(1.0)
        f.Add(-1.0)
        f.Add(0.0)
        f.Add(math.NaN())
        f.Add(math.Inf(1))
        f.Add(math.Inf(-1))
    
        f.Fuzz(func(t *testing.T, x float64) {
            result := math.Abs(x)
            if result < 0 {
                t.Errorf("Abs(%v) = %v, want non-negative", x, result)
            }
            if !math.IsNaN(x) {
                if math.Abs(-x) != result {
                    t.Errorf("Abs(%v) != Abs(-%v): %v vs %v", x, x, result, math.Abs(-x))
                }
            }
        })
    }
  2. Escreva um fuzz test para uma função Divide que recebe dois inteiros e retorna a divisão inteira, mas que deve evitar divisão por zero. Use seeds com divisões comuns. Na função alvo, capture o erro de divisão por zero e verifique que o resultado é correto quando não há erro.

    ✓ Resposta:
    package main
    
    import (
        "testing"
    )
    
    func Divide(a, b int) (int, error) {
        if b == 0 {
            return 0, errors.New("division by zero")
        }
        return a / b, nil
    }
    
    func FuzzDivide(f *testing.F) {
        f.Add(10, 2)
        f.Add(-10, 2)
        f.Add(10, -2)
        f.Add(0, 1)
        f.Add(1, 0)
    
        f.Fuzz(func(t *testing.T, a, b int) {
            res, err := Divide(a, b)
            if b == 0 {
                if err == nil {
                    t.Errorf("expected error for division by zero, got nil")
                }
            } else {
                if err != nil {
                    t.Errorf("unexpected error: %v", err)
                } else {
                    // Verifica a propriedade: a = res * b + resto
                    if a != res*b+(a%b) {
                        t.Errorf("result %d not correct for %d/%d", res, a, b)
                    }
                }
            }
        })
    }
  3. Execute o fuzzing para o exemplo FuzzReverse fornecido na seção "Encontrando bugs" e observe as entradas que causam falhas. Depois, corrija a função Reverse para lidar com caracteres Unicode corretamente (usando runas). Escreva o código corrigido.

    ✓ Resposta:
    func Reverse(s string) string {
        runes := []rune(s)
        for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
            runes[i], runes[j] = runes[j], runes[i]
        }
        return string(runes)
    }
  4. Crie um fuzz test para a função ParseDate que recebe uma string e retorna um time.Time e um erro. Use seeds com datas válidas e inválidas. Na função alvo, verifique que se a data é válida, o erro é nil, e que o ano retornado é sempre maior que 0.

    ✓ Resposta:
    package main
    
    import (
        "testing"
        "time"
    )
    
    func ParseDate(s string) (time.Time, error) {
        return time.Parse("2006-01-02", s)
    }
    
    func FuzzParseDate(f *testing.F) {
        f.Add("2024-01-01")
        f.Add("2023-12-31")
        f.Add("2024-13-01") // inválido
        f.Add("abc")
        f.Add("")
    
        f.Fuzz(func(t *testing.T, s string) {
            t_, err := ParseDate(s)
            if err == nil {
                if t_.Year() <= 0 {
                    t.Errorf("year %d is not positive for input %q", t_.Year(), s)
                }
            }
        })
    }
  5. Explique a importância do corpus no fuzzing e como você pode adicionar entradas manualmente. Dê um exemplo de arquivo de corpus para uma função que recebe dois inteiros.

    ✓ Resposta:O corpus é crucial porque fornece um conjunto de entradas iniciais que o fuzzer usa para explorar variações, aumentando a cobertura e a probabilidade de encontrar bugs. Entradas manuais podem ser adicionadas criando arquivos no diretório testdata/fuzz/FuzzFunctionName. Para uma função que recebe dois inteiros, cada arquivo deve conter duas linhas: a primeira com o primeiro inteiro e a segunda com o segundo. Por exemplo, um arquivo chamado exemplo pode conter:
    42
    -7