Zig Fuzzer: Fuzz Testing Nativo, Targets e CI

Para fazer fuzz testing em Zig, escreva um test que chama std.testing.fuzz com uma função testOne recebendo []const u8, transforme esses bytes em uma chamada real do seu código e deixe o fuzzer procurar crashes, panics, leaks e violações de invariante. O valor do fuzzer nativo aparece quando você testa parsers, deserializers, decoders e qualquer fronteira que aceite dados externos sem confiar no formato.

Fuzzing não substitui testes unitários nem property-based testing. Ele cobre outro ângulo: em vez de você inventar o caso ruim, o computador gera milhares ou milhões de entradas e pressiona o caminho que costuma quebrar em produção. Este guia mostra o modelo mental, um target mínimo, padrões para parsers, redução de crashes, integração com CI e quando combinar o fuzzer da std com ferramentas externas.

Como a std e as flags de zig test ainda evoluem antes do 1.0, fixe a versão do Zig no repositório e confirme a assinatura de std.testing.fuzz na release do projeto.

Resposta rápida

NecessidadeAbordagem recomendada
Achar crash em parser/decoderstd.testing.fuzz + testOne([]const u8)
Validar invariante de domínioproperty-based testing + geradores
Regressão determinísticasalve o input que quebrou e vire teste unitário
PR / merge gatecorpus curto + seeds conhecidos
Busca longa por bugs rarosjob noturno com orçamento de tempo/CPU
Código com alocaçãotesting.allocator ou GPA com detecção de leak
Entrada estruturadainterprete prefixos/bytes como enums e comprimentos
API instável pré-1.0pin da toolchain + smoke test do próprio fuzz harness

Por que fuzzing importa em Zig

Zig atrai parsers, protocolos, tools de build, runtimes e código perto do metal. Esses programas lidam com bytes que vêm de arquivo, socket, memória compartilhada ou outro processo. A falha típica não é 2 + 2 != 4; é um comprimento mentiroso, um offset negativo interpretado como usize, um enum fora do intervalo ou um caminho que libera duas vezes.

Testes de exemplo pegam o que você lembrou de escrever. Fuzzing pressiona o restante:

  • headers HTTP malformados;
  • JSON truncado;
  • payloads protobuf com campos repetidos;
  • ELF/PE/WASM corrompidos;
  • configurações TOML/YAML com escapes estranhos;
  • mensagens de protocolo com tamanho maior que o buffer declarado.

A linguagem ajuda porque erros são valores, alocadores são explícitos e builds de teste podem instrumentar leaks. Isso torna o feedback do fuzzer mais acionável do que em stacks onde panics silenciosos ou alocação global escondem o problema.

O alvo mínimo com std.testing.fuzz

O esqueleto mais útil é curto: receber bytes, chamar a API real e aceitar erros esperados sem engolir invariantes.

const std = @import("std");
const testing = std.testing;
const parseConfig = @import("config.zig").parseConfig;

test "fuzz parseConfig" {
    try testing.fuzz(.{}, struct {
        fn testOne(input: []const u8) !void {
            var gpa = std.heap.GeneralPurposeAllocator(.{}){};
            defer {
                const leaked = gpa.deinit();
                if (leaked == .leak) @panic("leak no fuzz target");
            }
            const allocator = gpa.allocator();

            const parsed = parseConfig(allocator, input) catch return;
            defer parsed.deinit(allocator);

            // Invariante barata: serializar e parsear de novo não pode crashar.
            const roundtrip = try parsed.toBytes(allocator);
            defer allocator.free(roundtrip);
            const again = parseConfig(allocator, roundtrip) catch return;
            defer again.deinit(allocator);
        }
    }.testOne);
}

Pontos importantes do exemplo:

  1. O fuzzer manda bytes crus. Cabe a você interpretá-los.
  2. Erro de parse esperado é sucesso do target. catch return evita transformar rejeição válida em “bug”.
  3. Leak e panic são falhas. Use alocador de teste ou GPA e falhe alto.
  4. Round-trip barato aumenta o sinal. Se o valor parseado puder ser serializado, force esse caminho sem depender de I/O.

Para um overview mais amplo do sistema de testes, combine este guia com o guia completo de testes em Zig.

Como desenhar um fuzz target que encontra bugs de verdade

Um target fraco só gera ruído. Um target forte tem cinco propriedades.

1. Exercita código de produção, não um stub

Se o fuzzer só chama uma função de validação superficial, ele não toca no decoder real. Prefira a mesma entrada que a aplicação usaria após ler um arquivo ou frame de rede.

2. Falha de forma observável

O fuzzer precisa de um oráculo. Em Zig, oráculos comuns são:

  • @panic / unreachable atingidos;
  • overflow em modos de segurança ativos;
  • leak de memória;
  • assert de invariante (std.debug.assert);
  • diferença em round-trip;
  • comparação com implementação de referência para o mesmo input.

3. Roda rápido e sem estado global

Evite abrir porta, falar com banco ou dormir. Estado global entre iterações cria falsos positivos e esconde bugs de uso-após-free. Recrie allocator e estruturas a cada testOne.

4. Aceita entrada maliciosa sem confiança prévia

Não “corrija” o input antes de chamar o parser. Se você normaliza demais, remove justamente os casos que quebram offsets e comprimentos.

5. Tem orçamento claro

Defina quantos segundos ou iterações cabem no CI. Fuzzing sem orçamento vira job infinito; fuzzing com 2 segundos no PR ainda ajuda quando há corpus de regressão.

Transformando bytes em entradas estruturadas

Muitos bugs só aparecem com combinações específicas de campos. Em vez de esperar que o fuzzer “adivinhe” um header válido por acaso, interprete os primeiros bytes como roteador:

fn decodeCase(input: []const u8) void {
    if (input.len == 0) return;
    const kind = input[0] % 4;
    const payload = input[1..];

    switch (kind) {
        0 => parseJson(payload),
        1 => parseToml(payload),
        2 => parseBinaryFrame(payload),
        else => parseLegacy(payload),
    }
}

Outras estratégias úteis:

  • usar 2–4 bytes para comprimentos e fatiar o restante;
  • mapear bytes para enums de opcode;
  • misturar um dicionário de tokens legítimos com mutações;
  • alimentar o mesmo payload a duas implementações e comparar resultado.

Essa abordagem se aproxima de structure-aware fuzzing sem sair do harness nativo.

Parsers: o caso de uso número um

Se você só puder fuzzar uma coisa no projeto, fuzz o parser. Exemplos de invariantes baratas:

DomínioInvariante útil
JSON/configinput rejeitado não aloca de forma ilimitada
Frame bináriopayload.len <= declared_len
Codecdecode(encode(x)) preserva campos canônicos
Protocoloestado inválido nunca avança sem erro explícito
Path/URLnormalização não escapa o diretório base

Exemplo para um decoder de frame:

const Frame = struct {
    opcode: u8,
    payload: []const u8,
};

fn decodeFrame(buffer: []const u8) !Frame {
    if (buffer.len < 2) return error.UnexpectedEof;
    const opcode = buffer[0];
    const declared = buffer[1];
    if (buffer.len < 2 + @as(usize, declared)) return error.UnexpectedEof;
    return .{
        .opcode = opcode,
        .payload = buffer[2 .. 2 + declared],
    };
}

test "fuzz decodeFrame" {
    try testing.fuzz(.{}, struct {
        fn testOne(input: []const u8) !void {
            const frame = decodeFrame(input) catch return;
            try testing.expect(frame.payload.len <= 255);
            try testing.expect(frame.payload.ptr >= input.ptr);
            try testing.expect(frame.payload.ptr + frame.payload.len <= input.ptr + input.len);
        }
    }.testOne);
}

Aqui o fuzzer procura, entre outras coisas, fatias que escapam do buffer original — classe clássica de bug em parsers manuais.

Redução de crash: do input gigante ao caso mínimo

Quando o fuzzer encontra um crash, o input inicial pode ter kilobytes. Antes de abrir issue, reduza:

  1. Salve o input bruto (crash-001.bin).
  2. Tente remover sufixo/prefixo enquanto o bug permanece.
  3. Substitua blocos por zeros ou por tokens curtos.
  4. Congele o caso mínimo como teste unitário nomeado.
test "regressão: frame com declared_len mentiroso" {
    const input = [_]u8{ 0x01, 0x05, 'a', 'b' }; // declara 5, só tem 2
    try testing.expectError(error.UnexpectedEof, decodeFrame(&input));
}

Esse ciclo — fuzz encontra, humano reduz, unitário trava — é o que transforma fuzzing em qualidade permanente. Sem a etapa de regressão, o mesmo bug volta no mês seguinte.

Fuzzing e propriedade: quando usar cada um

Use fuzz testing quando a pergunta for: “este código sobrevive a entradas hostis?”.

Use property-based testing quando a pergunta for: “esta regra de domínio permanece verdadeira para qualquer valor gerado?”.

Exemplos:

  • fuzz: parser de HTTP, decoder de imagem, deserialização de cache em disco;
  • propriedade: sort é idempotente, set.insert torna contains verdadeiro, compressão+descompressão preserva bytes.

Os dois compartilham geradores e oráculos. Em projetos Zig é comum começar com fuzz de bytes e, depois, extrair propriedades estáveis para a suíte rápida. O tutorial de property-based testing em Zig cobre o segundo caminho em detalhe.

Integração com CI sem quebrar o pipeline

Fuzzing contínuo precisa de duas velocidades.

Gate de pull request

  • rode zig test normal;
  • execute fuzz targets por poucos segundos ou com corpus pequeno;
  • falhe se qualquer seed de crash conhecido voltar a quebrar.

Busca profunda

  • job agendado (noite/fim de semana);
  • orçamento de minutos ou horas por target;
  • artefatos com inputs interessantes;
  • notificação só quando houver crash novo.

Em GitHub Actions ou GitLab CI, isole o job de fuzz do job de unitários para não punir feedback de PR. Os guias de GitHub Actions multiplataforma e GitLab CI para build/test/release mostram a espinha dorsal de pipeline; acrescente um job específico de fuzz em cima deles.

Modelo mental de stages:

lint/build -> unit/property -> fuzz-smoke (PR) -> fuzz-deep (schedule)

Corpus, dicionários e seeds

Mesmo com fuzzer nativo simples, a qualidade sobe quando você alimenta material inicial:

  • Corpus: arquivos reais enxutos (configs válidas, frames típicos, respostas de API).
  • Dicionário: tokens do protocolo (Content-Length, magic numbers, chaves JSON).
  • Seeds de crash: inputs que já quebraram o sistema uma vez.

Organize no repositório:

fuzz/
  corpus/config/
  corpus/frames/
  crashes/
  dict/protocol.dict

No PR, rode primeiro os crashes. Na busca profunda, parta do corpus e deixe a mutação trabalhar. Evite corpus enorme com binários de produção inteiros; prefira recortes que exercitem ramos distintos.

Instrumentação e modos de segurança

Zig já oferece alavancas úteis sem tool externo:

  • builds de teste com checagens de segurança ativas;
  • testing.allocator para detectar leaks;
  • asserts em pré-condições de parsers;
  • logs só em falha, para não afogar o harness.

Quando o bug for de memória mais sutil, combine com profiling e flamegraph depois de isolar o input. Fuzzer acha o caso; profiler explica o custo; debugger explica o caminho. Para inspeção interativa, o guia de debugging em Zig continua sendo a referência operacional.

Quando ir além do fuzzer da std

O fuzzer nativo é excelente para o dia a dia do repositório. Ainda assim, há cenários em que ferramentas externas compensam:

CenárioOpção
Campanha longa em protocolo complexolibFuzzer / AFL++ via harness C ABI
Comparação com implementação em Cdriver FFI compartilhado
Cobertura guiada muito maduraengate com sanitizers do ecossistema C
Target multi-linguagemcorpus comum + wrappers por linguagem

A ponte natural em Zig é exportar uma função C que recebe ponteiro+tamanho e chama o decoder Zig. Assim você reutiliza a mesma lógica sob AFL++/libFuzzer sem duplicar o parser. Comece pelo nativo; só adicione complexidade quando o corpus e os targets internos já estiverem sólidos.

Checklist prático antes de declarar “temos fuzzing”

  • Existe pelo menos um fuzz target no caminho crítico de parsing.
  • O target usa alocador com detecção de leak.
  • Erros esperados não são tratados como crash.
  • Crashes viram testes unitários determinísticos.
  • PR roda smoke de fuzz; schedule roda busca longa.
  • Corpus e crashes versionados cabem no git.
  • Toolchain Zig está pinada e documentada.
  • Há link da suíte de fuzz na documentação de contribuição.

Erros comuns

Engolir todo erro. Se catch {} esconde invariante violada, o fuzzer “passa” sem achar nada.

Fuzzar só o happy path. Normalizar input até ficar sempre válido derrota o propósito.

Estado global entre iterações. Singletons, caches sem reset e PRNG global contaminam o próximo caso.

Job único sem orçamento. Ou estoura o CI ou ninguém roda. Separe smoke e deep.

Não salvar o input. Sem artefato, o bug é anedota. Sempre persista o binário mínimo.

Confundir cobertura com segurança. 80% de cobertura com parser frágil ainda é parser frágil. Olhe crashes e invariantes, não só porcentagem.

Exemplo de roteiro em um serviço HTTP

Suponha um serviço que já cobre servidor HTTP, compressão e CORS. Uma ordem sensata de fuzz targets é:

  1. parser de request line e headers;
  2. decoder de chunked transfer;
  3. caminho de descompressão gzip/brotli com limite de expansão;
  4. parser de cookies/sessão;
  5. payload JSON das rotas internas.

Cada target deve impor limites: tamanho máximo, número de headers, profundidade de JSON e tempo por iteração. Fuzzer sem limite vira DoS contra a própria suíte.

Como falar de fuzzing em currículo e code review

Em code review, cobre evidência:

  • qual target cobre a mudança;
  • se o corpus ganhou um caso novo;
  • se o crash virou regressão unitária;
  • se o orçamento de CI ainda fecha.

Em portfólio, um repositório Zig com fuzz/corpus, job noturno e crashes reduzidos comunica maturidade de sistemas melhor do que só listar “usei Zig”. Para o lado de carreira e mercado, conecte essa disciplina aos materiais de carreira em Zig e ao guia de testes completo.

Perguntas frequentes

Zig tem fuzzer nativo?

Sim. Desde o Zig 0.12 a std expõe std.testing.fuzz para repetir um alvo com entradas geradas. O padrão usual é um test com testOne(input: []const u8). Confirme flags e assinatura na versão pinada do projeto.

Qual a diferença entre fuzz testing e property-based testing?

Fuzzing caça crashes e comportamentos inseguros sob bytes adversários. Property-based testing verifica propriedades explícitas com valores gerados. Use os dois: fuzz na borda do sistema, propriedades no núcleo do domínio.

O que um bom fuzz target deve fazer?

Chamar código real, terminar rápido, evitar I/O externo, falhar alto em panic/leak/invariante e aceitar erros de parse esperados sem mascarar bugs.

Como integrar fuzzing ao CI?

Smoke curto no PR com corpus/seeds, busca longa em schedule, artefatos de crash e promoção imediata do input mínimo para teste unitário.

Fuzzing substitui testes unitários?

Não. Unitários documentam contratos. Fuzzing explora o que você não escreveu. A combinação com table-driven tests e propriedades é o arranjo estável.

As APIs mudam antes do Zig 1.0?

Podem mudar. Pin da toolchain, smoke do harness e leitura das notes da release evitam surpresa. Trate exemplos da internet como modelo, não como especificação eterna.

Conclusão

Fuzz testing em Zig é uma das formas mais baratas de endurecer parsers e fronteiras de confiança. Com std.testing.fuzz, um target rápido, corpus enxuto e regressões determinísticas, você transforma entradas caóticas em bugs reproduzíveis. Comece pelo decoder mais exposto, salve cada crash como teste e só então invista em campanhas longas ou harness externo.

Se você está estruturando a suíte do zero, siga esta ordem: testes básicos, padrões e table-driven tests, este guia de fuzzer nativo, property-based testing e automação de CI/CD. O fuzzer não substitui pensamento de projeto — ele pune a parte do projeto que ainda confia demais na entrada.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.