50 万次差异模糊测试用例。零次偏差。以及一个原有测试套件永远抓不到的 Bug。

我把一个 Python 库移植到了 Rust:原封不动的 1059 个测试全过了,但代码依然是错的
这次黑客马拉松的规则很直接:拿原项目的测试套件,不许改动,让你的重写版通过测试。只要改了测试文件,直接零分。
我的确全过了。1059 个测试,全绿,那个测试文件我甚至都没打开过。但移植版依然是错的。真正揪出问题的不是测试,而是 50 万个随机生成的价格字符串——它们在两个实现版本中并肩运行,最终暴露了差异。
- 1059 个测试通过,134 个来自上游的 xfailed 测试——文件未经任何修改,每次 CI 运行均通过 SHA-256 校验,且该目录在整个仓库历史中仅有一次提交。
- 100.4 秒内完成 50 万次差异模糊测试,零偏差,种子为
20260803,可复现(日志见 fuzz/log.txt)。 - 零 unsafe 代码。
forbid(unsafe_code) 使其成为一个编译错误,而非仅仅是口头承诺。 - 吞吐量提升约 4 倍,原生 Rust 调用的启动速度提升约 13 倍(若从 Python 调用则约为 3 倍)——这里故意说“约”,因为实际测量的噪音比一个干净的倍数要大得多。
price-parser 是 Scrapinghub 开发的一个 Python 库,用于从抓取的文本中提取价格和货币符号。比如输入“-US$ 1,234.56”,输出 1234.56 和“US$”。
这是一个不起眼的小库,干着枯燥的活儿,但在许多爬虫中被广泛使用。我特意选了它,因为在它的行为逻辑中,Python 和 Rust 恰好在两个地方存在暗流涌动的分歧:正则表达式和 Decimal。
这使得它不适合作为周末的练手项目,但却是一个极佳的试金石。真正的问题不是“我会不会写 Rust”,而是“我能否完美复现别人的行为逻辑,哪怕是他们自己都未曾意料到的部分?”
这是我参加 Port Mortem / Code Resurrection 2026 大赛 Track D(Python → Rust)的参赛作品。
“不许改动”是被强制执行的,而非基于信任。原始测试文件在比赛开始时计算了哈希值,并且每次推送都会进行校验:

那 134 个 xfailed 是上游自带的,标记在同一个被哈希的文件里。这个仓库里没有 conftest.py 来偷偷添加它们,而且它们设置了 strict=True,所以如果其中任何一个意外通过了,pytest 也会将其报告为失败。这里没有任何藏污纳垢的地方。
那片绿色的通过标记,很容易让人宣告移植大功告成。我的代码也在那个状态停留了大约一天。随后,差异模糊测试器运行了——生成价格字符串,将相同的字节同时喂给上游 Python 和 Rust 版本,然后逐个字段比较——结果在第一次正式运行时就抓到了 Bug。
Decimal("٥") 在 Python 中等于 5。Python 的 Decimal 接受任何 Unicode 十进制数字。而 rust_decimal 只接受 ASCII,其他一概拒绝。
恶心的地方在于分歧隐藏的位置。两个正则引擎都将 \d 匹配为 \p{Nd},所以提取过程完美一致——移植版每次都正确地找到了数字。失败完全发生在后续的转换阶段。没有抛出异常,没有报错,也没有警告。用阿拉伯-印度语、梵文或孟加拉语数字书写的价格,返回的结果里直接就没有金额了。
上游的测试语料来源于抓取的西方电商网站,本质上全是 ASCII。在这 1059 个测试中,没有一个能捕获这个问题,以后也不可能有——不是因为测试套件写得差,而是因为测试套件只能覆盖有人想到的输入。
修复方法利用了 Unicode 的一个特性:通用类别为 Nd 的每个字符,都位于以其脚本零字符开头的连续十个字符区间内。因此,你不需要一张手写的数字映射表,只需要 68 个区间的起始值——这些值从 Unicode 数据中生成,而非手动输入——然后通过减法就能得出数值。
另外,amount_text 依然原封不动地返回原始数字,因为上游就是这么做的。只有数值转换会进行折叠处理。
(?(1)yes|no) 是一个条件组:如果分组 1 参与了匹配,则匹配前者,否则匹配后者。Rust 的 regex 库没有条件组——也没有环视和反向引用。这是一个刻意的架构选择,而非缺陷;正是这种妥协换来了线性时间保证。
不过在这里我们也不需要条件组。条件组恰好有两个结果,所以该模式被拆分为两个模式,按照引擎本身会尝试的顺序依次运行:
排序规则很容易搞错,我一开始就搞错了:最左边的起始位置永远优先,分支顺序只决定平局时的结果。按照这个规则匹配,行为就能完全对齐——例如“12€345”会匹配带跳过分支的三个数字,而“12€ 345”则两个都不匹配,直接落入普通规则。
值得直说,因为这是任何看仓库的人都会注意到的第一件事。核心 crate 里根本没有 PyO3。parse_price 是纯纯的 Rust 代码,Rust 调用者永远不会接触到 Python。CPython 扩展模块是一个可选特性,它存在的唯一目的就是让上游的测试套件能不加修改地运行在这段代码上,这也是整个比赛的评分标准。
这也是为什么关于 unsafe 的说法带有一个星号,原因如下:
forbid(unsafe_code) 在默认构建中是激活的——它不是一条 lint 警告,而是一个编译错误,且下游无法覆盖。它仅在 python 特性下才被放宽,因为 PyO3 的宏在 FFI 边界处会展开为 unsafe。整个 crate 中没有任何手写的 unsafe 代码。而且,差异模糊测试器也没有链接 Python——它生成一个真实的 CPython 作为独立进程,然后跨进程边界比较输出。
这两个谎言都没有被测试抓到,都是因为我多问了一句“这个数字真的合理吗?”才被揭穿的。
第一个谎言:构建配置。maturin develop 默认是 debug 构建,速度大概慢 20 倍。第一次基准测试拿一个 debug 扩展去和 release 二进制文件对比,得出的结论是我的移植版比原版 Python 慢了 5 倍。现在,模块会报告自己的构建配置,基准测试也拒绝在 debug 模式下运行。
第二个谎言:峰值内存,在两个平台上各骗了我一次。在 Windows 上,一个 ctypes 调用无论工作负载如何,都返回恒定的约 3.4 MiB。我没有盲信它——我让一个子进程分配了 200 MB 内存,然后眼睁睁看着报告的数字纹丝不动。
在 Linux 上,getrusage(RUSAGE_CHILDREN).ru_maxrss 对所有三个实现返回了相同的 393 MiB,因为它是该进程曾回收的所有子进程的最高水位线——它永远在报告之前的一次 cargo build 的内存数据。这读起来像是一个真实的测量值,但根本不是那么回事。现在 Linux 改为在子进程存活时采样 /proc/<pid>/status 中的 VmHWM。Windows 则直接返回空值,因为什么也不发布,好过发布一个你知道是错的数字。
这两个损坏的版本都返回了一个常量——这就是破绽,也是本文中唯一值得你拿走借鉴的经验。
最终存活下来的真实数据:
|
|---|
| 指标 | Python | 从 Python 调用 | 原生 Rust |
| 每次解析 | 21.98 µs | 4.56 µs | 4.65 µs |
| 每秒解析数 | 45,498 | 219,261 | 215,258 |
| 启动时间 | 324.3 ms | 108.1 ms | 25.3 ms |
| p50 | 18.30 µs | 4.50 µs | 3.50 µs |
| p99 | 70.00 µs | 10.60 µs | 9.50 µs |
| p99.9 | 586.10 µs | 59.80 µs | 65.90 µs |
| 最大值 | 16,499 µs | 881 µs | 1,515 µs |
看最后两行:原生 Rust 竟然输给了包裹它的 FFI 路径。作为真实结果这显然是不可能的——一个包装器不可能打败它包装的内核——但这正是保留这两行的原因。这意味着在这台硬件上,两条 Rust 路径根本无法区分,诚实的结论是“大约 4 倍”,而不是精确的 4.82 倍。
冻结的测试套件运行、哈希校验、模糊测试日志,以及零 unsafe 的声明——这些都通过演示来证明,而非仅仅靠嘴说:
- 1059 个通过,134 个 xfailed —— 上游的测试套件,未经修改且经过哈希验证。
- 50 万次模糊测试用例,耗时 100.4 秒,每秒 4982 次,零偏差 —— 种子 20260803。
- 50 万个生成用例中,有 390,723 个包含可解析的金额。
- 零 unsafe,由编译器强制执行。
- 34 个被记录的决策,其中包括我犯错并由 Python 的行为来定夺的两次。
- 没有声称在原项目中发现了 Bug。差异测试只能找出我与上游行为不一致的地方,而不是上游做错的地方。发现的每一次分歧都是我的错。我检查过了,我不打算凭空捏造一个。
git clone https://github.com/ShivharakhYadav/price-parser-rs
cd price-parser-rs
docker build -t price-parser-rs .
docker run --rm price-parser-rs
不需要你本地配置任何工具链。最后一条命令会验证测试文件的哈希值,运行 Rust 测试,运行原始的 Python 测试套件,然后再次验证哈希值——这样就算测试套件在中间被悄悄篡改了,它也无法在前后两端都产生通过的结果。
仓库地址:github.com/ShivharakhYadav/price-parser-rs
上文提到的每一个数字,都是仓库里已提交的文件——fuzz/log.txt、bench/results.json,以及包含 34 条记录(绝非精选集)的 DECISIONS.md。
感谢阅读。🦀