Дайджесты новостей
Графическая иллюстрация в стиле европейского комикса Ligne Claire, изображающая инспекцию фаззинг-мутаций байтов в Go.

Фаззинг нашёл баг в Go: как зафиксировать сбой и превратить его в регрессионный тест

Ты наверняка не раз писал юнит-тесты в Go: берёшь табличный тест []struct{ input, expected }, перечисляешь пяток очевидных входных данных, проверяешь граничные нули, пустую строку и со спокойной душой закрываешь задачу. В CI всё горит зелёным, ревьюер ставит аппрув. А через неделю в продакшене парсер сетевого протокола или JSON-конфигов внезапно ловит панику panic: runtime error: index out of range на каком-нибудь кривом запросе от клиента с экзотическим символом UTF-8.

Почему так происходит? Человеческий мозг предвзят: мы инстинктивно тестируем код на тех данных, которые сами считаем «разумными». Но реальный мир за пределами наших тестов полон шума, битых пакетов и вредоносных нагрузок.

Именно здесь на сцену выходит встроенный механизм Go Fuzzing, появившийся в стандартной библиотеке testing начиная с версии Go 1.18. Это не просто генератор случайных чисел, а так называемый фаззинг на основе покрытия (coverage-guided fuzzing). Движок анализирует граф ветвлений твоего скомпилированного кода и целенаправленно мутирует байты так, чтобы заглянуть в самые глухие ветки if, switch и циклов, до которых ты никогда бы не додумался вручную.

Давай разберёмся, как написать правильный фазз-тест, что происходит, когда фаззер находит панику, и как сохранить сбойный артефакт в репозитории, чтобы баг больше никогда не вернулся.

Как устроен фазз-тест: сигнатура f.Fuzz и начальный корпус

Фазз-тест в Go пишется в обычных тестовых файлах *_test.go, но вместо стандартного объекта *testing.T функция принимает параметр *testing.F:

package parser_test

import (
    "bytes"
    "testing"
    "myproject/parser"
)

func FuzzParseMessage(f *testing.F) {
    // 1. Наполняем начальный seed-корпус валидными примерами
    f.Add([]byte("HEADER:OK
BODY:hello
"))
    f.Add([]byte("HEADER:PING
BODY:
"))

    // 2. Объявляем фаззинг-таргет
    f.Fuzz(func(t *testing.T, data []byte) {
        msg, err := parser.Parse(data)
        if err != nil {
            // Ошибка валидации — это нормальное поведение парсера!
            return
        }

        // 3. Проверяем инварианты: если парсер вернул успех, контракт не должен нарушаться
        if len(msg.Header) == 0 {
            t.Fatalf("успешный парсинг вернул пустой заголовок для входа: %q", data)
        }

        // Инвариант обратимости (round-trip test)
        serialized := msg.Bytes()
        if !bytes.Equal(serialized, data) {
            t.Errorf("нарушен инвариант обратимости: %q != %q", serialized, data)
        }
    })
}

Здесь критически важны три правила:

  1. Метод f.Add формирует seed-корпус: фаззер не начинает мутации с чистого листа вслепую. Он берет твои заведомо валидные примеры и начинает постепенно изменять отдельные байты, переворачивать биты, удалять подстроки и вставлять спецсимволы.
  2. Ошибки валидации — это норма: если твоя функция возвращает ошибку err != nil, тест не должен падать! Ошибка говорит о том, что парсер корректно отклонил мусор. Фаззер ищет не штатные ошибки, а аварийные паники, бесконечные циклы или нарушение логических инвариантов бизнес-логики.
  3. Проверка инвариантов: лучше всего фаззить свойства, которые обязаны выполняться всегда (инварианты). Например: «размер распакованного буфера не превышает лимит», «после сериализации и десериализации объект идентичен исходному» или «функция не паникует ни при каких входных байтах». Если тебе часто приходится сравнивать сложные структуры данных в Go-тестах, взгляни на наш разбор библиотеки go-cmp для поиска точного diff объектов.

Два режима исполнения: обычный прогон против мутатора

Очень многие разработчики не знают, что вызов:

go test ./...

вообще не запускает активный генератор мутаций!

В обычном режиме go test рассматривает функцию FuzzParseMessage просто как параметризованный юнит-тест. Он по очереди прогоняет только те значения, которые ты явно прописал через f.Add, плюс ранее сохранённые файлы из каталога testdata/fuzz. Этот прогон выполняется за доли секунды и совершенно безопасен для регулярных проверок в CI/CD пайплайнах.

Чтобы включить настоящий движок фаззинга, нужно явно передать флаг -fuzz:

# Запуск активного фаззинга конкретного теста с лимитом по времени 30 секунд
go test -fuzz=FuzzParseMessage -fuzztime=30s -v

Когда ты выполняешь эту команду, рантайм Go делает следующее:

  • Собирает базовый профиль покрытия кода (baseline coverage) на примерах из seed-корпуса.
  • Запускает воркеры параллельно на всех доступных ядрах процессора.
  • Начинает генерировать миллионы мутаций в секунду. Каждый раз, когда мутация находит новый путь в скомпилированном коде (новое условие if), этот входной вектор добавляется во временный кэш покрытия для дальнейшего расщепления.

Анатомия падения: магия автоматической минимизации

Представь: фаззер крутился 15 минут, сгенерировал входной массив из 8192 случайных байт, и на этом массиве парсер неожиданно упал с паникой:

--- FAIL: FuzzParseMessage (12.45s)
    --- FAIL: FuzzParseMessage (0.00s)
        panic: runtime error: slice bounds out of range [:12] with capacity 8

Если бы инструмент просто выплюнул тебе файл размером 8 килобайт мусора, разбираться в дампе пришлось бы до утра. Но движок Go Fuzzing делает фантастическую вещь: он немедленно включает алгоритм минимизации входа (input minimization).

Фаззер начинает отсекать куски байтов, биты за битами, и непрерывно перезапускать код, проверяя: воспроизводится ли та же самая паника? Если при удалении первых 4000 байт сбой повторяется, фаззер выбрасывает их навсегда. Процесс повторяется, пока не останется минимально возможный набор байтов, приводящий к падению. В результате массив из 8 КБ сжимается буквально до трёх байт: �ÿ !

После минимизации Go:

  1. Завершает работу команды с кодом ошибки 1.
  2. Выводит в консоль точный стек вызова и значение сбойных аргументов.
  3. Автоматически сохраняет этот минимальный сбойный сценарий на диск в файл: testdata/fuzz/FuzzParseMessage/c7a8b9f123...

Превращение сбоя в регрессионный тест: коммитим testdata/fuzz

Заглянем внутрь сгенерированного файла в каталоге testdata/fuzz/FuzzParseMessage/:

go test fuzz v1
[]byte("�ÿ
")

Формат файла предельно прозрачен: в первой строке указана версия схемы (go test fuzz v1), а далее следуют типизированные аргументы, на которых произошёл сбой.

Вот здесь кроется главное архитектурное изящество подхода Go: этот файл предназначен для коммита в систему контроля версий (git)!

# Добавляем найденный граничный случай в git
git add testdata/fuzz/FuzzParseMessage/
git commit -m "test: reproduce boundary panic in message parser via fuzz corpus"

Теперь этот файл стал частью твоего постоянного регрессионного тестового набора. Когда любой разработчик команды или раннер GitHub Actions выполнит обычный быстрый go test ./..., фазз-тест автоматически подхватит этот файл из testdata, подаст его на вход целевой функции и гарантирует, что исправленный баг больше никогда не пролезет незамеченным!

Исправляем ошибку в парсере (добавляем проверку длины слайса перед взятием среза) и проверяем исправление:

# Проверяем, что тест теперь проходит успешно на сохранённом сбойном входе
go test -run=FuzzParseMessage -v
Совет по интеграции в CI/CD

Не запускай бесконечный фаззинг в основном блокирующем пайплайне пул-реквестов. Запускай быстрый регрессионный go test ./... на каждый коммит (он мгновенно проверяет накопленный корпус в testdata), а глубокий фаззинг go test -fuzz=. -fuzztime=10m выноси в ночной периодический джоб по расписанию cron.