对Gleam编译器的模糊测试

Hacker News Top 新闻

摘要

本文探讨了模糊测试技术,包括基于LLM的和结构感知模糊测试,以发现Gleam编译器中的错误,该编译器可以编译为JavaScript和Erlang,并具有静态类型。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/25 19:59

# 模糊测试 Gleam 编译器 | kurz.net 来源:https://www.kurz.net/posts/fuzzing-gleam-compiler 你能通过生成随机程序在编译器中发现 bug 吗? 发布于:2026年8月25日 星期二 ## 引言 我经常关注 Gleam(https://gleam.run/)的变更日志和问题追踪器。我很喜欢这个项目以及为它做贡献的人们。但每次看到与代码生成或 Erlang 和 JavaScript 输出不同的问题时,我总感到困扰,因为目前没有办法“计算出所有 Gleam 程序”,运行它们并查看是否存在任何问题。 我想象这就像一个棋盘,棋盘上有准无限数量的可能位置。但我们希望这个棋盘上包含 Gleam 程序,并希望有一个无限大的程序数据库,以查看它们是否能揭示未测试到的边缘情况。 我最初尝试做与此相关的事情,实际上是提示一个 LLM。我指示它阅读大量过去的 Gleam 问题,并通过“深入思考”来找出更多边缘情况。它提出了各种各样的位数组合、嵌套匿名函数、嵌套的 `use` 模式。可以预见,这种方法并没有产生太多结果。花了 20 美元的 token 之后,它只找到了一个问题,该问题立即被报告并修复了:https://github.com/gleam-lang/gleam/issues/5613。一个总比零个好。但“LLM 模糊测试”有很多问题:价格昂贵、不确定,有点像拉老虎机的拉杆。 但我之前回避了另一个想法,因为老实说,它听起来工作量太大了:结构感知模糊测试。 ## 结构感知模糊测试 编写软件很难,人类并不擅长。为了提供帮助,我们构建了其他可以部分自动化搜索 bug 的软件。其中一个程序就是模糊测试器。它们生成随机输入,馈送到我们的程序中。前提是,在大规模下,这些随机输入会以某种方式分布,从而暴露出我们尚未想到的边缘情况。 模糊测试器的范围可以从完全随机的乱码字节,到高度结构化的、语法感知的抽象语法树(AST)。向程序提供完全随机的字节通常用于处理图像、文件、网络请求、协议等的场景。有很多例子表明,模糊测试在开源软件中发现了真正的安全漏洞和 bug。例如,`zzuf` 在 Firefox 中发现的这个漏洞,其中翻转图像文件中的一些位会导致浏览器崩溃:https://nvd.nist.gov/vuln/detail/CVE-2007-6715。 但模糊测试器也通过缓冲区溢出发现了真正的可利用安全漏洞。谷歌有一个名为 “OSS Fuzz” 的程序,持续地对许多重要的开源项目进行模糊测试:https://google.github.io/oss-fuzz/ 在我们的案例中,我们处理的不是浏览器或网络协议。我们有一个编译器。这就打开了结构感知模糊测试的可能性。这意味着我们不是生成一串随机字节,而是生成以源代码或 AST 形式表示的代码流。 ## 进入 Gleam Gleam 有几个特点使其成为模糊测试的一个特别有趣的候选者。 1. **双目标代码生成**:它为 JavaScript 和 Erlang 两个目标生成代码。我们可以比较同一程序对两个目标的输出,并标记任何差异。 2. **极简语法**:至少与其他大多数流行编程语言相比是如此。我们可以用相对较少的代码生成涵盖该语言几乎所有概念的有效程序。 3. **静态类型**:不用说,这是一个了不起的特性,它确保了程序在运行时不会崩溃。这并不意味着类型系统中不能有 bug。过去曾出现过与类型推断相关的问题。但正如我们稍后将了解到的,语言的每个方面都需要自己的测试方法。 4. **函数式特性**:一切都表达式的事实使得组合和构建程序非常方便。 5. **Rust**:这一点可能容易被忽视,但 Gleam 编译器本身是用 Rust 编写的这一事实,使得集成现有的模糊测试工具变得非常容易。我们可以在不运行单个 `.gleam` 文件的情况下测试编译器的某些部分。 ## 我使用的资源 我们将深入探讨模糊测试器的更多技术方面,但我不会深入大量代码或细节。如果你想了解更多,请查看 Nick Fitzgerald 的这篇博文和博客。它是这个项目的主要灵感来源:https://fitzgen.com/2020/08/24/writing-a-test-case-generator.html 我们的模糊测试器将是基于生成的(generation-based),而不是基于变异的(mutation-based)。如果你想更好地理解区别,我推荐阅读这篇文章:https://fitzgen.com/2026/06/01/structure-aware-fuzzing-experiment.html 在该文章中,作者得出结论,至少对于 wasm,基于变异的方法发现了比基于生成的方法多得多的问题。所以对于这个项目,未来可能值得实现它! 要更深入地了解这个主题,请查看此资源:https://www.fuzzingbook.org/。 你可以在我的 Gleam 分支的这个分支中找到 Gleam 模糊测试器的完整代码:https://github.com/daniellionel01/gleam/tree/fuzzing ## 第一阶段:解析器 模糊测试器的一个重要设计选择:使用公共编译器 API。即使编译器 API 可能没有稳定性保证,这使得它更容易与未来的 Gleam 版本保持兼容。它也避免了处理实现细节,这是确保我们不产生任何误报或漏报的好方法。 看看我们的解析器如何捕获和分类输出的一些示例: ```console $ cargo run -p fuzzing-core --example classify "pub fn main() { 1 }" -> compiled (js: 39B, ts: 32B, erl: 238B) "pub fn main() { let f = fn(x) { x + 1 }; f(41) }" -> parse error "pub fn main() { 1 +. \"x\" }" -> analysis rejected (javascript) "pub fn main() {" -> parse error ``` 使用 fuzz crate (https://github.com/rust-fuzz/cargo-fuzz) 和一些包装代码,我们可以非常快速地用随机生成的输入(尚未结构化)来向 Gleam 编译器发起冲击,看看我们是否能导致编译器崩溃,而不是给我们一个带有更多上下文的错误消息。我们只运行它 1 秒钟,因为输出相当大: ```console $ cargo +nightly fuzz run parse_only --fuzz-dir fuzzing-harness -- -max_total_time=1 -timeout=1 INFO: Running with entropic power schedule (0xFF, 100). INFO: Seed: 302379076 INFO: Loaded 1 modules (740945 inline 8-bit counters): 740945 [0x105eeac70, 0x105f9fac1), INFO: Loaded 1 PC tables (740945 PCs): 740945 [0x105f9fac8,0x106aedfd8), INFO: 2466 files found in fuzzing-harness/corpus/parse_only INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes INFO: seed corpus: files: 2466 min: 1b max: 4046b total: 425422b rss: 62Mb #2467 INITED cov: 2434 ft: 8563 corp: 1249/171Kb exec/s: 0 rss: 108Mb #2513 REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 108Mb L: 48/3753 MS: 1 EraseBytes- #2645 REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 108Mb L: 8/3753 MS: 2 ChangeBit-EraseBytes- #2656 REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 109Mb L: 2/3753 MS: 1 EraseBytes- #2937 REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 109Mb L: 314/3753 MS: 1 EraseBytes- #3183 NEW cov: 2434 ft: 8578 corp: 1250/172Kb lim: 3764 exec/s: 0 rss: 110Mb L: 1054/3753 MS: 1 CopyPart- #3591 REDUCE cov: 2434 ft: 8578 corp: 1250/172Kb lim: 3764 exec/s: 0 rss: 111Mb L: 99/3753 MS: 3 ShuffleBytes-CrossOver-EraseBytes- #3934 NEW cov: 2434 ft: 8585 corp: 1251/173Kb lim: 3764 exec/s: 0 rss: 112Mb L: 399/3753 MS: 3 CMP-CopyPart-CopyPart- DE: "\010\000\000\000\000\000\000\000"- # ... NEW_FUNC[1/7]: 0x0001031cfb88 in _RINvNtCs3kGMwX4aip8_4core3ptr9drop_glueINtNtCshX1O598ANu2_5alloc3vec3VecINtNtNtCs846PmCUGaYz_10gleam_core3ast8constant8ConstantuEEEB1f_+0x0 (parse_only:arm64+0x1002abb88) NEW_FUNC[2/7]: 0x00010323bff4 in _RINvNtCs3kGMwX4aip8_4core3ptr9drop_glueINtNtNtCs846PmCUGaYz_10gleam_core3ast8constant8ConstantuEEBI_+0x0 (parse_only:arm64+0x100317ff4) #16785 NEW cov: 2483 ft: 8694 corp: 1262/177Kb lim: 3786 exec/s: 16785 rss: 144Mb L: 85/3753 MS: 1 CrossOver- #18061 REDUCE cov: 2483 ft: 8694 corp: 1262/177Kb lim: 3797 exec/s: 18061 rss: 149Mb L: 403/3753 MS: 1 EraseBytes- NEW_FUNC[1/4]: 0x00010312c3b0 in _RINvMs_NtCs846PmCUGaYz_10gleam_core5parseINtB5_6ParserINtNtB5_5lexer5LexerINtBT_14NewlineHandlerINtNtNtNtCs3kGMwX4aip8_4core4iter8adapters3map3MapNtNtNtB1F_3str4iter11CharIndicesNCNvBT_14make_tokenizer0EEEE23parse_bit_array_segmentNtNtNtB7_3ast7untyped11UntypedExprNCNCNvB2_21parse_expression_units6_00NvB2_17expect_expressionNvB5_24bit_array_expression_intEB7_+0x0 (parse_only:arm64+0x1002083b0) NEW_FUNC[2/4]: 0x00010318893c in _RINvNtCs3kGMwX4aip8_4core3ptr9drop_glueINtNtCs846PmCUGaYz_10gleam_core3ast15BitArraySegmentNtNtBE_7untyped11UntypedExpruEEBG_+0x0 (parse_only:arm64+0x10026493c) # ... ###### Recommended dictionary. ###### "\010\000\000\000\000\000\000\000" # Uses: 879 "\201\000" # Uses: 941 ###### End of recommended dictionary. ###### Done 24887 runs in 2 second(s) ``` 很好。查看它产生的一些伪影,你可以看到生成了什么样的输入: ```c fn ar(n,n,n,///A# o ``` ```text ఌఌ「彸䕅䕅D䕅+� ``` ```text ">\u{000000000000000000.%\f0 ``` ```text fn ar(n ar:rn a( ``` 这样做的好处是,它可以在完全不运行 `gleam` 二进制文件的情况下测试一切。它在内存中运行,使用 Rust 中的编译器管道。猜猜怎么着!当我让这个模糊测试器运行相当长一段时间后,它实际上在 nightly 版本上发现了一个回归问题,这个问题在 `v1.18.1`(撰写本文时的最新 Gleam 版本)上并没有发生: ```console $ cargo +nightly fuzz run --fuzz-dir fuzzing-harness parse_only fuzzing-harness/artifacts/parse_only/crash-8b14db5e4bf152924501e0818787026e9f5ea229 =fuzzing-harness/artifacts/parse_only/ fuzzing-harness/artifacts/parse_only/crash-8b14db5e4bf152924501e0818787026e9f5ea229` INFO: Running with entropic power schedule (0xFF, 100). INFO: Seed: 3949856390 INFO: Loaded 1 modules (750122 inline 8-bit counters): 750122 0x107773860, 0x10782aa8a), INFO: Loaded 1 PC tables (750122 PCs): 750122 [0x10782aa90,0x10839cd30), fuzzing-harness/target/aarch64-apple-darwin/release/parse_only: Running 1 inputs 1 time(s) each. Running: fuzzing-harness/artifacts/parse_only/crash-8b14db5e4bf152924501e0818787026e9f5ea229 thread '' (23346909) panicked at /gleam/compiler-core/src/parse.rs:5226:52: Token could not be converted to binop. note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace ==41428== ERROR: libFuzzer: deadly signal #0 0x000109a27654 in __sanitizer_print_stack_trace+0x28 (librustc-nightly_rt.asan.dylib:arm64+0x87654) #1 0x000106bcdf0c in fuzzer::PrintStackTrace()+0x30 (parse_only:arm64+0x1024a9f0c) #2 0x000106bc1f48 in fuzzer::Fuzzer::CrashCallback()+0x54 (parse_only:arm64+0x10249df48) #3 0x000181edb740 in _sigtramp+0x34 (libsystem_platform.dylib:arm64e+0x3740) #4 0x000181ed18d4 in pthread_kill+0x124 (libsystem_pthread.dylib:arm64e+0x68d4) # ... shortened ... NOTE: libFuzzer has rudimentary signal handlers. Combine libFuzzer with AddressSanitizer or similar for better crash reports. SUMMARY: libFuzzer: deadly signal ──────────────────────────────────────────────────────────────────────────────── Error: Fuzz target exited with exit status: 77 ``` 关键输入是在 `const` 表达式中使用管道 `|>`,就像这样:`const b = 1 |> 2`。一旦我们发现问题,我们还会在该问题上使用 `git bisect`。这样我们就可以区分 nightly 版本上的回归问题和当前最新 Gleam 版本中存在的问题。 当然,我们理想情况下不仅让它运行 1 秒,而是很多小时。公平地说,在这一步中发现的 bug 是不错的,但你可能无法用它找到代码生成 bug。在发布本文时,libFuzzer 包已从分支中移除,以保持对类型安全程序的关注。对于项目的后续阶段,我设想了更有针对性和更高效的编译器崩溃测试(即 tree-splicer)。 ## 第二阶段:类型安全的程序 为了生成类型安全的 Gleam 程序,我们将构建一个“制造器(smith)”。我们创建自己的简化版 Gleam AST,然后以概率方式为其生成程序。这样,一旦制造器决定“我们需要一个解析为 `Int` 的表达式”,它就可以提供一个字面值如 `3`,或者一个匿名函数 `fn() { 3 }()`,或者一个变量,等等。 这就是我们如何可靠地在程序间创建大量多样性,并最终发现新的边缘情况。下面是一个此类程序的样子: ```gleam pub const k_seed: Bool = False pub const k_e: Int = 5 pub const k_golden: String = "data" pub type V0 { Number(value: String, inner: List(Int)) } fn walk(xs: List(Int), acc: Int) -> Int { case xs { [] -> acc [x, ..rest] -> walk(rest, acc + x) } } fn f0(arguments: #(Float, String), l: Int, item: #(Float, Bool)) -> List(Int) { [] } fn f1(class: Int, acc: String) -> Int { 100 } fn f2(pair: Int) -> Float { case "" <> "abc" { "data" <> rest as whole -> fn(v1) { { let whole = [] 0.0 } }(0.0) inner | "ab" <> inner -> { let self_ = 3.14 fn(v2, v3) { self_ }("x", 4) } "b" <> b -> case fn(v4) { 42 }(2.0) { 9 -> { 1.0 } *. { 2.0 } item -> 3.14 } } } pub fn main() { let v = walk([5], k_e) - 100 let z = f2(v) echo [2] echo { case "res" <> "data", f0(#(1.0, "data"), v, #(0.5, False)) { _, [] -> fn(v5, v6) { 10.0 }(False, "x") "b" <> _, [] -> z +. { 2.0 } k_seed, [2, h, ..] as whole -> 10.0 _, _ -> f2(5) } } /. { 0.5 } echo { fn(v7) { 100.0 }(False) } *. { case "b", 2 { "ab", 1 -> { let rest = k_seed z } "x", 5 -> 2.0 _, v8 -> z +. { 0.25 } } } } ``` 这是一大堆乱七八糟的东西。而这正是重点!我们生成将随机选择的有效表达式组合在一起的程序,希望能发现导致一个或两个目标上出现不正确行为的组合。 但是,如果我们的一个 Gleam 程序成功编译后产生不正确的行为,我们如何知道呢?这就是两个编译目标的用武之地。例如,如果一个包含 `case` 表达式的程序中存在 bug,如果 Erlang 中的逻辑实现正确,我们可以发现它,因为如果我们在每个分支中提供不同的值,JavaScript 目标上的输出就会不同。 这并非百分百可靠,但这是一个坚实的起点。我们仍然可能在编译器中遇到在两个目标上都发生的 bug,因此这种方法可能产生误报。一如既往:没有银弹(https://en.wikipedia.org/wiki/No_Silver_Bullet)。 在我们查看 Gleam 制造器之前,我需要稍微离题一下。 ## `echo` 问题 事情是这样的。JavaScript 和 Erlang 对如何在运行时表示值有不同的想法。例如,JavaScript 中没有专门的整数和浮点类型。它有 `Number`。当将某些值转换为字符串并使用 Gleam 的内置 `echo` 关键字时,它们的表示方式不同。 看这个程序: ```gleam pub type Wibble { Wibble(wobble: Int) } pub fn main() { echo "hello" echo <<1, 2, 3>> echo <<"a":utf8, "b":utf8, "c":utf8>> echo [1, 2, 3] echo Wibble(3) echo 1.2 echo 1.0 } ``` 以下是该程序在 Erlang 和 JavaScript 上的输出对比: ```console $ gleam run --target erlang src/app.gleam:6 "hello" src/app.gleam:7 "\u{0001}\u{0002}\u{0003}" src/app.gleam:8 "abc" src/app.gleam:9 [1, 2, 3] src/app.gleam:10 Wibble(3) src/app.gleam:11 1.2 src/app.gleam:12 1.0 ``` ```console $ gleam run --target javascript src/app.gleam:6 "hello" src/app.gleam:7 <<1, 2, 3>> src/app.gleam:8 <<97, 98, 99>> src/app.gleam:9 [1, 2, 3] src/app.gleam:10 Wibble(wobble: 3) src/app.gleam:11 1.2 src/app.gleam:12 1.0 ```

相似文章

Gleam的酷炫之处

Lobsters Hottest

作者分享了学习Gleam编程语言的个人笔记,重点介绍了@deprecated属性、todo构造、泛型和case表达式等特性,并与其他语言进行了比较。

Gleam v1.17.0

Hacker News Top

Gleam v1.17.0 引入了 `gleam export escript` 命令以创建单文件 BEAM 程序,在语言服务器中高亮引用,以及常量 `todo` 表达式。此外,首届 Gleam Gathering 大会的视频也已发布。