fuzz: terminal.vtStream fuzzer (#11109)

This augments our libghostty fuzzing to add fuzzing for
`terminal.vtStream` which exercises a LOT more codepaths than the pure
parser (thousands of tuples compared to hundreds with `afl-showmap` on
the two binaries). I also fixed up a few more minor things: prettier
ignores AFL related files, lib-vt exports the readonly streams, etc.
This commit is contained in:
Mitchell Hashimoto
2026-03-01 15:08:47 -08:00
committed by GitHub
1355 changed files with 421 additions and 102 deletions

View File

@@ -19,3 +19,7 @@ website/.next
# shaders
*.frag
# fuzz corpus files
test/fuzz-libghostty/corpus/
test/fuzz-libghostty/afl-out/

View File

@@ -59,6 +59,8 @@ pub const Style = terminal.Style;
pub const Terminal = terminal.Terminal;
pub const Stream = terminal.Stream;
pub const StreamAction = terminal.StreamAction;
pub const ReadonlyStream = terminal.ReadonlyStream;
pub const ReadonlyHandler = terminal.ReadonlyHandler;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = terminal.CursorStyle;

Binary file not shown.

View File

@@ -1,6 +1,9 @@
# Hand-written seed corpus: binary files, track as-is
corpus/initial/** binary
corpus/parser-initial/** binary
corpus/stream-initial/** binary
# Generated/minimized corpora: binary, mark as generated
corpus/vt-parser-cmin/** binary linguist-generated=true
corpus/vt-parser-min/** binary linguist-generated=true
corpus/parser-cmin/** binary linguist-generated=true
corpus/parser-min/** binary linguist-generated=true
corpus/stream-cmin/** binary linguist-generated=true
corpus/stream-min/** binary linguist-generated=true

View File

@@ -1,14 +1,18 @@
# AFL++ Fuzzer for Libghostty
- `ghostty-fuzz` is a binary built with `afl-cc`
- Build `ghostty-fuzz` with `zig build`
- After running `afl-cmin`/`afl-tmin`, run `corpus/sanitize-filenames.sh`
- Build all fuzzer with `zig build`
- The list of available fuzzers is in `build.zig` (search for `fuzzers`).
- Run a specific fuzzer with `zig build run-<name>` (e.g. `zig build run-parser`)
- Corpus directories follow the naming convention `corpus/<fuzzer>-<variant>`
(e.g. `corpus/parser-initial`, `corpus/stream-cmin`).
- Do NOT run `afl-tmin` unless explicitly requested — it is very slow.
- After running `afl-cmin`, run `corpus/sanitize-filenames.sh`
before committing to replace colons with underscores (colons are invalid
on Windows NTFS).
## Important: stdin-based input
The instrumented binary (`afl.c` harness) reads fuzz input from **stdin**,
The instrumented binaries (`afl.c` harness) read fuzz input from **stdin**,
not from a file argument. This affects how you invoke AFL++ tools:
- **`afl-fuzz`**: Uses shared-memory fuzzing automatically; `@@` works
@@ -16,7 +20,7 @@ not from a file argument. This affects how you invoke AFL++ tools:
- **`afl-showmap`**: Must pipe input via stdin, **not** `@@`:
```sh
cat testcase | afl-showmap -o map.txt -- zig-out/bin/ghostty-fuzz
cat testcase | afl-showmap -o map.txt -- zig-out/bin/fuzz-stream
```
- **`afl-cmin`**: Do **not** use `@@`. Requires `AFL_NO_FORKSRV=1` with
@@ -24,15 +28,9 @@ not from a file argument. This affects how you invoke AFL++ tools:
```sh
AFL_NO_FORKSRV=1 /opt/homebrew/Cellar/afl++/4.35c/libexec/afl-cmin.bash \
-i afl-out/default/queue -o corpus/vt-parser-cmin \
-- zig-out/bin/ghostty-fuzz
-i afl-out/fuzz-stream/default/queue -o corpus/stream-cmin \
-- zig-out/bin/fuzz-stream
```
- **`afl-tmin`**: Also requires `AFL_NO_FORKSRV=1`, no `@@`:
```sh
AFL_NO_FORKSRV=1 afl-tmin -i <input> -o <output> -- zig-out/bin/ghostty-fuzz
```
If you pass `@@` or a filename argument, `afl-showmap`/`afl-cmin`/`afl-tmin`
If you pass `@@` or a filename argument, `afl-showmap`/`afl-cmin`
will see only ~4 tuples (the C main paths) and produce useless results.

View File

@@ -1,9 +1,19 @@
# AFL++ Fuzzer for Libghostty
This directory contains an [AFL++](https://aflplus.plus/) fuzzing harness for
libghostty-vt (Zig module). At the time of writing this README, it only
fuzzes the VT parser, but it can be extended to cover other components of
libghostty as well.
This directory contains [AFL++](https://aflplus.plus/) fuzzing harnesses for
libghostty-vt (Zig module).
## Fuzz Targets
| Target | Binary | Description |
| -------- | ------------- | ------------------------------------------------------- |
| `parser` | `fuzz-parser` | VT parser only (`Parser.next` byte-at-a-time) |
| `stream` | `fuzz-stream` | Full terminal stream (`nextSlice` + `next` via handler) |
The stream target creates a small `Terminal` and exercises the readonly
`Stream` handler, covering printing, CSI dispatch, OSC, DCS, SGR, cursor
movement, scrolling regions, and more. The first byte of each input selects
between the slice path (SIMD fast-path) and the scalar path.
## Prerequisites
@@ -21,43 +31,38 @@ From this directory (`test/fuzz-libghostty`):
zig build
```
This compiles a Zig static library (with the fuzz harness in `src/lib.zig`),
emits LLVM bitcode, then links it with `src/main.c` using `afl-cc` to produce
the instrumented binary at `zig-out/bin/ghostty-fuzz`.
This compiles Zig static libraries for each fuzz target, emits LLVM bitcode,
then links each with `afl.c` using `afl-cc` to produce instrumented binaries
at `zig-out/bin/fuzz-parser` and `zig-out/bin/fuzz-stream`.
## Running the Fuzzer
The build system has a convenience step that invokes `afl-fuzz` with the
correct arguments:
Each target has its own run step:
```sh
zig build run
zig build run-parser # Run the VT parser fuzzer
zig build run-stream # Run the VT stream fuzzer
```
This is equivalent to:
Or invoke `afl-fuzz` directly:
```sh
afl-fuzz -i corpus/initial -o afl-out -- zig-out/bin/ghostty-fuzz @@
afl-fuzz -i corpus/stream-initial -o afl-out/stream -- zig-out/bin/fuzz-stream @@
```
You may want to run `afl-fuzz` directly with different options
for your own experimentation.
The fuzzer runs indefinitely. Let it run for as long as you like; meaningful
coverage is usually reached within a few hours, but longer runs can find
deeper bugs. Press `ctrl+c` to stop the fuzzer when you're done.
## Finding Crashes and Hangs
After (or during) a run, results are written to `afl-out/default/`:
After (or during) a run, results are written to `afl-out/<target>/default/`:
```
afl-out/default/
afl-out/stream/default/
├── crashes/ # Inputs that triggered crashes
├── hangs/ # Inputs that triggered hangs/timeouts
└── queue/ # All interesting inputs (the evolved corpus)
├── hangs/ # Inputs that triggered hangs/timeouts
└── queue/ # All interesting inputs (the evolved corpus)
```
Each file in `crashes/` or `hangs/` is a raw byte file that triggered the
@@ -69,12 +74,12 @@ issue. The filename encodes metadata about how it was found (e.g.
Replay any crashing input by piping it into the harness:
```sh
cat afl-out/default/crashes/<filename> | zig-out/bin/ghostty-fuzz
cat afl-out/stream/default/crashes/<filename> | zig-out/bin/fuzz-stream
```
## Corpus Management
After a fuzzing run, the queue in `afl-out/default/queue/` typically
After a fuzzing run, the queue in `afl-out/<target>/default/queue/` typically
contains many redundant inputs. Use `afl-cmin` to find the smallest
subset that preserves full edge coverage, and `afl-tmin` to shrink
individual test cases.
@@ -90,38 +95,19 @@ Reduce the evolved queue to a minimal set covering all discovered edges:
```sh
AFL_NO_FORKSRV=1 afl-cmin.bash \
-i afl-out/default/queue \
-o corpus/vt-parser-cmin \
-- zig-out/bin/ghostty-fuzz
-i afl-out/stream/default/queue \
-o corpus/stream-cmin \
-- zig-out/bin/fuzz-stream
```
`AFL_NO_FORKSRV=1` is required because the Python `afl-cmin` wrapper has
a bug in AFL++ 4.35c. Use the `afl-cmin.bash` script instead (typically
found in AFL++'s `libexec` directory).
### Test case minimization (`afl-tmin`)
Shrink each file in the minimized corpus to the smallest input that
preserves its unique coverage:
```sh
mkdir -p corpus/vt-parser-min
for f in corpus/vt-parser-cmin/*; do
AFL_NO_FORKSRV=1 afl-tmin \
-i "$f" \
-o "corpus/vt-parser-min/$(basename "$f")" \
-- zig-out/bin/ghostty-fuzz
done
```
This is slow (hundreds of executions per file) but produces the most
compact corpus. It can be skipped if you only need edge-level
deduplication from `afl-cmin`.
### Windows compatibility
AFL++ output filenames contain colons (e.g., `id:000024,time:0,...`), which
are invalid on Windows (NTFS). After running `afl-cmin` or `afl-tmin`,
are invalid on Windows (NTFS). After running `afl-cmin`,
rename the output files to replace colons with underscores before committing:
```sh
@@ -132,6 +118,7 @@ rename the output files to replace colons with underscores before committing:
| Directory | Contents |
| ------------------------ | ----------------------------------------------- |
| `corpus/initial/` | Hand-written seed inputs for `afl-fuzz -i` |
| `corpus/vt-parser-cmin/` | Output of `afl-cmin` (edge-deduplicated corpus) |
| `corpus/vt-parser-min/` | Output of `afl-tmin` (individually minimized) |
| `corpus/parser-initial/` | Hand-written seed inputs for vt-parser |
| `corpus/parser-cmin/` | Output of `afl-cmin` (edge-deduplicated corpus) |
| `corpus/stream-initial/` | Hand-written seed inputs for vt-stream |
| `corpus/stream-cmin/` | Output of `afl-cmin` (edge-deduplicated corpus) |

View File

@@ -1,59 +1,72 @@
const std = @import("std");
const afl = @import("afl");
/// Possible fuzz targets. Each fuzz target is implemented in
/// src/fuzz_<name>.zig and has an initial corpus in corpus/<name>-initial.
const Fuzzer = struct {
name: []const u8,
pub fn source(comptime self: Fuzzer) []const u8 {
return "src/fuzz_" ++ self.name ++ ".zig";
}
pub fn corpus(comptime self: Fuzzer) []const u8 {
// Change this suffix to use cmin vs initial corpus
return "corpus/" ++ self.name ++ "-cmin";
}
};
const fuzzers: []const Fuzzer = &.{
.{ .name = "parser" },
.{ .name = "stream" },
};
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the fuzzer with afl-fuzz");
// Create the C ABI library from Zig source that exports the
// API that the `afl-cc` main.c entrypoint can call into. This
// lets us just use standard `afl-cc` to fuzz test our library without
// needing to write any Zig-specific fuzzing harnesses.
const lib = lib: {
// Zig module
const ghostty_dep = b.lazyDependency("ghostty", .{
.simd = false,
});
inline for (fuzzers) |fuzzer| {
const run_step = b.step(
b.fmt("run-{s}", .{fuzzer.name}),
b.fmt("Run {s} with afl-fuzz", .{fuzzer.name}),
);
const lib_mod = b.createModule(.{
.root_source_file = b.path("src/lib.zig"),
.root_source_file = b.path(fuzzer.source()),
.target = target,
.optimize = optimize,
});
if (b.lazyDependency("ghostty", .{
.simd = false,
})) |dep| {
if (ghostty_dep) |dep| {
lib_mod.addImport(
"ghostty-vt",
dep.module("ghostty-vt"),
);
}
// C lib
const lib = b.addLibrary(.{
.name = "ghostty-fuzz",
.name = fuzzer.name,
.root_module = lib_mod,
});
// Required to build properly with afl-cc
lib.root_module.stack_check = false;
lib.root_module.fuzz = true;
break :lib lib;
};
const exe = afl.addInstrumentedExe(b, lib);
const run = afl.addFuzzerRun(
b,
exe,
b.path(fuzzer.corpus()),
b.path(b.fmt("afl-out/{s}", .{fuzzer.name})),
);
run_step.dependOn(&run.step);
// Build a C entrypoint with afl-cc that links against the generated
// static Zig library. afl-cc is expected to be on the PATH.
const exe = afl.addInstrumentedExe(b, lib);
// Runner to simplify running afl-fuzz.
// Use the cmin corpus (edge-deduplicated from prior runs) so that each
// fuzzing session starts from full coverage. Switch to "corpus/initial"
// if you don't have a cmin corpus yet.
const run = afl.addFuzzerRun(b, exe, b.path("corpus/vt-parser-cmin"), b.path("afl-out"));
// Install
b.installArtifact(lib);
const exe_install = b.addInstallBinFile(exe, "ghostty-fuzz");
b.getInstallStep().dependOn(&exe_install.step);
// Run
run_step.dependOn(&run.step);
const exe_install = b.addInstallBinFile(
exe,
"fuzz-" ++ fuzzer.name,
);
b.getInstallStep().dependOn(&exe_install.step);
}
}

Some files were not shown because too many files have changed in this diff Show More