Fuzzing в Foundry: номер и хеш публикации
В предыдущем уроке мы выбрали несколько крайних значений. Теперь сформулируем одно требование сразу для множества допустимых входов: если издатель публикует ненулевой номер и ненулевой отпечаток, чтение возвращает переданный выпуск и признак активности.
Добавим функцию с параметрами в CourseCatalogTest. Foundry может генерировать их значения при будущей fuzz-кампании. В рамках подготовки курса генерация и проверки не запускаются, поэтому найденные контрпримеры, количество прогонов и успешное покрытие не заявляются.
От примера к свойству
Обычный сценарий задаёт конкретные величины, например номер 17 и хеш строки. Fuzz-сценарий сохраняет структуру действий, но принимает входные значения как параметры функции. Среда подбирает разные комбинации в пределах соответствующих типов и выполняет одну и ту же проверку результата.
Такой подход полезен для ошибок, которые трудно угадать одним примером: усечение номера, неверная обработка определённых байтов, случайная привязка записи к постоянному ключу. При этом большое количество входов само по себе не помогает, если утверждение слишком слабое. Нам нужна точная связь между переданным отпечатком и сохранённым состоянием.
В данном уроке исследуем исходно пустой реестр. Каждая конкретная проверка должна начинаться с собственной подготовки setUp, а не продолжать случайную историю предыдущего входа. Stateful-последовательности появятся позже. Это разграничение важно: параметризованная функция с одним вызовом ещё не моделирует длительный жизненный цикл приложения.
Записываем допустимую область
У базового publish есть два ограничения: номер не равен нулю и отпечаток не равен нулю. Их можно явно выразить через допущения. Для широкого диапазона с единственной запрещённой величиной такое условие оставляет почти все возможные входы.
function testFuzzPublicationRoundTrip(
uint256 id,
bytes32 digest
) public {
vm.assume(id != 0);
vm.assume(digest != bytes32(0));
vm.prank(publisher);
catalog.publish(id, digest);
assertCourse(id, digest, true);
}
Функция assertCourse определена в девятом уроке. Она сравнивает оба поля с внешним ожиданием. Допущения не меняют контракт и не исправляют его входы: они объясняют генератору, какие комбинации относятся к этому конкретному свойству успешной публикации.
Название начинается с test, а параметры задают типизированную поверхность генерации. Часть Fuzz в названии помогает читателю понять назначение сценария. Не стоит полагаться на название как на самостоятельную гарантию качества: исследуемое свойство находится в теле функции и выборе входной области.
Нулевые значения остаются отдельными требованиями
Мы исключили ноль из положительного свойства, потому что контракт должен его отклонять. Это не означает, что нулевая ветвь больше не нужна. Сохранённые отрицательные сценарии десятого урока проверяют её напрямую и с точной ошибкой InvalidCourse().
Если в функцию добавить ранний return для всех неудобных значений, она может выглядеть успешно завершённой, не выполнив публикацию. Допущение делает область более явной, однако его тоже нужно анализировать: чрезмерно сильное условие способно исключить именно те случаи, где скрывается ошибка.
Например, условие id == 17 лишит свойство почти всего полезного диапазона. Другая крайность — сложный набор ограничений отпечатка, не вытекающий из договора. Каждое исключение должно иметь объяснение в требованиях, а не служить способом убрать неприятный вход из будущего отчёта.
Разбираем ожидаемый результат
Для входа id = 18 и произвольного ненулевого digest вызов должен записать этот набор байтов по ключу 18, после чего active станет true. Проверка чтения того же номера должна увидеть точное совпадение. Событие в этом сценарии не сравнивается: это отдельное требование седьмого урока.
Предположим, ошибочная реализация всегда пишет по ключу 17. Для привычного примера такая программа кажется правильной. Сгенерированный вход с номером 18 даст пустое чтение по ожидаемому ключу и нарушит утверждение. Это конкретная причина применять параметризацию, которую можно понять без реально выполненной кампании.
Если реализация сохраняет keccak256(abi.encode(digest)) вместо переданного digest, проверка также должна заметить несовпадение. Простое assertTrue(stored != 0) могло бы принять такой результат. Свойство round trip выражает договор хранения, а не только наличие некоторой записи.
Изменяем пример на повторную публикацию
Для замены выпуска понадобятся два отпечатка и два последовательных вызова. Начальный хеш записывается первым, затем новый хеш должен его заменить. Не нужно вводить требование first != second, если мы хотим также исследовать допустимое повторение того же значения.
function testFuzzReplacementKeepsLatestDigest(
uint256 id,
bytes32 first,
bytes32 second
) public {
vm.assume(id != 0);
vm.assume(first != bytes32(0));
vm.assume(second != bytes32(0));
vm.startPrank(publisher);
catalog.publish(id, first);
catalog.publish(id, second);
vm.stopPrank();
assertCourse(id, second, true);
}
Эта функция расширяет исследование, но история по-прежнему имеет заранее заданную длину и порядок. Генератор выбирает величины, а не произвольную последовательность методов. Позже handler позволит отдельно выбирать действия и следить за накопленным состоянием нескольких курсов.
Равные отпечатки здесь означают повторную публикацию такого же выпуска. Ожидаемый результат остаётся тем же: запись активна и содержит second, который совпадает с first. Отдельное решение о запрете повторений потребовало бы изменения контракта и новых требований, а не скрытого исключения из fuzz-функции.
Чтение чужого ключа не является round trip
Вспомогательная функция должна получать тот же id, который передан в publish. Если по привычке оставить внутри чтение постоянного 17, сценарий начинает проверять совсем другое отношение. Для одних входов он случайно совпадёт с публикацией, а для других получит пустую запись без ошибки самого реестра.
Перед будущим запуском полезно прочитать цепочку данных глазами: параметр функции попадает в вызов, затем в getter и лишь после этого сравнивается с внешним ожиданием. Отпечаток проходит такую же цепочку. Этот разбор не заменяет исполнение, но предотвращает логическую ошибку, когда большое число прогонов исследует неверно записанное свойство. Если требуется проверить отсутствие изменений другого курса, выбираем отдельный отличающийся ключ и называем это самостоятельным требованием.
Ограничиваем вывод о полноте
Генерация конечна. Даже большое число случайных входов не перебирает весь uint256 и все пары bytes32. Fuzzing помогает искать контрпримеры к сформулированному свойству, но успешная кампания не превращает это свойство в математически доказанный факт для всех входов.
Кроме того, наши функции пока не проверяют управление ключом издателя, сетевой интерфейс, журнал индексатора и наличие файлов по сохранённому отпечатку. Они сосредоточены на договоре локального хранения. Ясная граница полезнее широкого заявления «реестр протестирован», которое не сообщает, какие именно условия исследовались.
В следующем уроке разберём допущения и ограничение диапазона подробнее. Механизм параметризованных проверок описан в руководстве Foundry по проверкам. Оглавление курса.