登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

把模糊测试发现的输入固化为长期回归用例

来源:17golang原创

时间:2026-10-07 11:33:05 174浏览 收藏

我在 Go fuzz 测试里遇到过一种很容易被忽略的“修好”:模糊测试找到了一个输入,代码修复后本地通过了,但下一次重构时这个输入又把同一个边界打出来。真正可靠的做法,是把失败输入保存在仓库的 testdata/fuzz/FuzzXxx 目录中,让它成为 seed corpus。这样,普通的 go test 也会再次运行它,而不是只依赖一次 fuzz 进程里的缓存。

要点速览
  • 失败输入被最小化后,优先保存到对应 fuzz 测试的 testdata/fuzz/FuzzXxx。
  • f.Add 适合少量稳定种子;文件形式适合提交较长或二进制的 corpus。
  • $GOCACHE/fuzz 是运行期间的 generated corpus,不等同于应该提交到 Git 的回归用例。

从失败输入到可提交的 seed corpus

Go 原生 fuzzing 会尝试把失败输入缩小到仍能复现问题的形式,并在输出中给出保存位置和定向重放命令。看到 Failing input written to testdata/fuzz/FuzzParseQuery/... 后,不要只复制最后一行字符串到工单里:这个文件本身就是 fuzz 测试的 corpus 条目,应该和修复一起提交。

目录名必须对应 fuzz 函数名。下面的示例假设测试函数叫 FuzzParseQuery,包目录下的结构应保持稳定:

package parser_test

// 该命令只定向重放已经保存的失败样本,便于确认修复是否生效。
// 参数末尾的哈希目录名来自 Go fuzz 输出,不能随意改写。
go test -run=FuzzParseQuery/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49

// 修复后把 corpus 文件与代码一起提交;普通 go test 会默认运行它。
// 这里的路径是相对当前 Go 包目录,而不是 $GOCACHE/fuzz。
testdata/fuzz/FuzzParseQuery/
└── a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
Go fuzz 失败输入落到 testdata/fuzz 中成为 seed corpus 的结构说明图
图1:结构说明图,展示 Go fuzz 失败输入如何落到可提交的 seed corpus。

文件内容采用 Go fuzz corpus 格式,首行声明格式版本,后面按 fuzz 参数顺序保存值。不要手工把它改成普通 JSON;如果参数是 []byte、整数或字符串,文件里的类型和值必须与 fuzz target 的参数完全对应。

让 fuzz 目标适合长期回归

持久化样本只是第一步,fuzz target 也要足够稳定。官方规则要求函数形如 FuzzXxx(*testing.F),并且每个 fuzz 测试只有一个 f.Fuzz 目标。参数类型要使用 Go 支持的基本类型,seed corpus 的类型和顺序必须与它们一致。

例如,下面的目标用 URL 查询字符串展示“解析后再编码仍保持语义”的不变量。不能解析的随机字符串被跳过,真正能进入业务语义的输入才参与比较:

func FuzzParseQuery(f *testing.F) {
    // 先放一个稳定种子,帮助普通测试和 fuzz 从有效输入开始。
    f.Add("user=go&lang=zh")

    // fuzz target 要快速、可重复,不把结果写入全局状态。
    f.Fuzz(func(t *testing.T, raw string) {
        values, err := url.ParseQuery(raw)
        if err != nil {
            // 无法进入本例业务语义的输入不作为失败样本。
            t.Skip()
        }

        encoded := values.Encode()
        roundTrip, err := url.ParseQuery(encoded)
        if err != nil {
            // 编码后的结果本应可再次解析,错误说明不变量被破坏。
            t.Fatalf("重新解析编码结果失败: %v", err)
        }
        if !reflect.DeepEqual(values, roundTrip) {
            // 失败信息要能帮助后续定位保存下来的最小输入。
            t.Fatalf("往返解析结果不同: before=%v after=%v", values, roundTrip)
        }
    })
}

如果失败样本是短字符串,可以用 f.Add 表达;如果样本较长、含二进制字节,或需要保留 fuzz 引擎生成的精确编码,文件形式更容易审查和回放。两种形式都会被当作 seed corpus,区别只是一个写在测试代码里,一个写在 testdata/fuzz/FuzzXxx 里。

用普通测试和定向重放验证持久化结果

修复代码后,先使用输出里的定向命令重放原始失败样本。定向命令成功,只能说明这个输入当前不再触发失败;接着还要运行普通 go test,确认仓库中的 seed corpus 能在日常测试入口被执行。

# 先重放单个失败样本,快速确认修复覆盖了原始边界。
# 中文注释说明该命令只筛选一个 FuzzXxx/哈希测试用例。
go test -run='FuzzParseQuery/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49'

# 再运行包内普通测试;f.Add 和 testdata/fuzz 下的 seed corpus 都会参与。
# 不加 -fuzz 时不会持续生成随机输入,但会检查已有的回归样本。
go test

# 需要继续探索新路径时再开启 fuzz,并限制时长避免占用机器不退出。
# -fuzztime 只控制本次探索,不决定样本是否进入仓库。
go test -fuzz=FuzzParseQuery -fuzztime=30s
Go fuzz seed corpus 与 GOCACHE generated corpus 边界说明图
图2:边界说明图,区分长期提交的 seed corpus 与运行期间生成的 fuzz 缓存。

这里最容易混淆的是 $GOCACHE/fuzz。fuzz 运行时会把能扩展覆盖率的输入放进 generated corpus,以便当前探索继续推进;它是缓存,不是团队审查过的回归契约。真正需要长期保留的,是能说明 bug 边界、已经最小化并能被普通测试重放的 seed corpus。

常见边界与排查方式

  • 定向重放找不到样本:先确认命令使用的是同一个包目录、同一个 FuzzXxx 名称,以及输出中的完整哈希。
  • 普通 go test 仍失败:不要删除 corpus 来“恢复绿色”,先判断失败是修复遗漏、测试不变量不成立,还是样本类型与参数不一致。
  • 只在本机能复现:检查样本是否误留在 $GOCACHE/fuzz;未提交的生成缓存不能替代仓库中的 testdata/fuzz。
  • 测试偶尔抖动:移除对时间、随机数、全局可变状态和外部服务的依赖,让同一个输入每次得到相同的判定。

相关问题

f.Add 和 testdata/fuzz/FuzzXxx 可以同时使用吗? 可以。两者都是 seed corpus,前者适合少量可读初始样本,后者适合需要独立保存的失败输入或二进制样本。

生成 corpus 是否应该提交到 Git? 通常不应该。生成 corpus 位于 $GOCACHE/fuzz,服务于 fuzz 运行期的覆盖率探索;只有经过确认的失败输入或业务边界,才应整理为仓库中的 seed corpus。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>