fuzz/vt-stream

This commit is contained in:
Mitchell Hashimoto
2026-03-01 13:46:13 -08:00
parent 1e027c9f20
commit e081a4abb4
31 changed files with 178 additions and 77 deletions

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;

View File

@@ -1,6 +1,9 @@
# Hand-written seed corpus: binary files, track as-is
corpus/initial/** binary
corpus/vt-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/vt-stream-cmin/** binary linguist-generated=true
corpus/vt-stream-min/** binary linguist-generated=true

View File

@@ -1,14 +1,14 @@
# AFL++ Fuzzer for Libghostty
- `ghostty-fuzz` is a binary built with `afl-cc`
- Build `ghostty-fuzz` with `zig build`
- Fuzz targets: `fuzz-vt-parser` and `fuzz-vt-stream`
- Build all targets with `zig build`
- After running `afl-cmin`/`afl-tmin`, 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 +16,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-vt-stream
```
- **`afl-cmin`**: Do **not** use `@@`. Requires `AFL_NO_FORKSRV=1` with
@@ -24,14 +24,14 @@ 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-vt-stream/default/queue -o corpus/vt-stream-cmin \
-- zig-out/bin/fuzz-vt-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
AFL_NO_FORKSRV=1 afl-tmin -i <input> -o <output> -- zig-out/bin/fuzz-vt-stream
```
If you pass `@@` or a filename argument, `afl-showmap`/`afl-cmin`/`afl-tmin`

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 |
| ------------------ | ------------------ | ------------------------------------------------------- |
| `fuzz-vt-parser` | `fuzz-vt-parser` | VT parser only (`Parser.next` byte-at-a-time) |
| `fuzz-vt-stream` | `fuzz-vt-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,39 @@ 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-vt-parser` and `zig-out/bin/fuzz-vt-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-fuzz-vt-parser # Run the VT parser fuzzer
zig build run-fuzz-vt-stream # Run the VT stream fuzzer
zig build run # Alias for run-fuzz-vt-parser
```
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/vt-stream-initial -o afl-out/fuzz-vt-stream -- zig-out/bin/fuzz-vt-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/fuzz-vt-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 +75,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/fuzz-vt-stream/default/crashes/<filename> | zig-out/bin/fuzz-vt-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,9 +96,9 @@ 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/fuzz-vt-stream/default/queue \
-o corpus/vt-stream-cmin \
-- zig-out/bin/fuzz-vt-stream
```
`AFL_NO_FORKSRV=1` is required because the Python `afl-cmin` wrapper has
@@ -105,12 +111,12 @@ 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
mkdir -p corpus/vt-stream-min
for f in corpus/vt-stream-cmin/*; do
AFL_NO_FORKSRV=1 afl-tmin \
-i "$f" \
-o "corpus/vt-parser-min/$(basename "$f")" \
-- zig-out/bin/ghostty-fuzz
-o "corpus/vt-stream-min/$(basename "$f")" \
-- zig-out/bin/fuzz-vt-stream
done
```
@@ -130,8 +136,8 @@ rename the output files to replace colons with underscores before committing:
### Corpus directories
| 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) |
| Directory | Contents |
| -------------------------- | ----------------------------------------------- |
| `corpus/initial/` | Hand-written seed inputs for vt-parser |
| `corpus/vt-parser-cmin/` | Output of `afl-cmin` (edge-deduplicated corpus) |
| `corpus/vt-stream-initial/`| Hand-written seed inputs for vt-stream |

View File

@@ -1,59 +1,69 @@
const std = @import("std");
const afl = @import("afl");
const FuzzTarget = struct {
name: []const u8,
source: []const u8,
corpus: []const u8,
};
const fuzz_targets = [_]FuzzTarget{
.{
.name = "fuzz-vt-parser",
.source = "src/fuzz_vt_parser.zig",
.corpus = "corpus/vt-parser-cmin",
},
.{
.name = "fuzz-vt-stream",
.source = "src/fuzz_vt_stream.zig",
.corpus = "corpus/vt-stream-initial",
},
};
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");
const run_step = b.step("run", "Run the default fuzzer (vt-parser) with afl-fuzz");
const ghostty_dep = b.lazyDependency("ghostty", .{
.simd = false,
});
for (fuzz_targets, 0..) |fuzz, i| {
const target_run_step = b.step(
b.fmt("run-{s}", .{fuzz.name}),
b.fmt("Run {s} with afl-fuzz", .{fuzz.name}),
);
// 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 lib_mod = b.createModule(.{
.root_source_file = b.path("src/lib.zig"),
.root_source_file = b.path(fuzz.source),
.target = target,
.optimize = optimize,
});
if (b.lazyDependency("ghostty", .{
.simd = false,
})) |dep| {
lib_mod.addImport(
"ghostty-vt",
dep.module("ghostty-vt"),
);
if (ghostty_dep) |dep| {
lib_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
}
// C lib
const lib = b.addLibrary(.{
.name = "ghostty-fuzz",
.name = fuzz.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);
// 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);
const run = afl.addFuzzerRun(b, exe, b.path(fuzz.corpus), b.path(b.fmt("afl-out/{s}", .{fuzz.name})));
// 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"));
b.installArtifact(lib);
const exe_install = b.addInstallBinFile(exe, fuzz.name);
b.getInstallStep().dependOn(&exe_install.step);
// Install
b.installArtifact(lib);
const exe_install = b.addInstallBinFile(exe, "ghostty-fuzz");
b.getInstallStep().dependOn(&exe_install.step);
target_run_step.dependOn(&run.step);
// Run
run_step.dependOn(&run.step);
// Default `zig build run` runs the first target (vt-parser)
if (i == 0) {
run_step.dependOn(&run.step);
}
}
}

View File

@@ -0,0 +1 @@
Hello, World!

View File

@@ -0,0 +1 @@
P$q"p\

Binary file not shown.

View File

@@ -0,0 +1,2 @@
ABCDhello
world

View File

@@ -0,0 +1 @@
<01><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>

View File

@@ -0,0 +1 @@
DDDM

View File

@@ -0,0 +1 @@
[3@

View File

@@ -0,0 +1 @@
]52;c;SGVsbG8=

Binary file not shown.

View File

@@ -0,0 +1 @@
78c

View File

@@ -0,0 +1,71 @@
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const Terminal = ghostty_vt.Terminal;
const ReadonlyStream = ghostty_vt.ReadonlyStream;
/// Fixed-capacity allocator that avoids heap allocation and gives the
/// fuzzer deterministic, bounded memory behaviour. Backed by a single
/// fixed buffer; every `reset()` returns the bump pointer to the start
/// so the same memory is reused across iterations.
const FuzzAllocator = struct {
buf: [mem_size]u8 = undefined,
state: std.heap.FixedBufferAllocator = undefined,
/// 4 MiB is plenty for a small terminal with a few pages of
/// scrollback, while staying within the resident-set limits
/// that AFL++ expects.
const mem_size = 4 * 1024 * 1024;
fn init(self: *FuzzAllocator) void {
self.state = std.heap.FixedBufferAllocator.init(&self.buf);
}
fn allocator(self: *FuzzAllocator) std.mem.Allocator {
return self.state.allocator();
}
fn reset(self: *FuzzAllocator) void {
self.state.reset();
}
};
var fuzz_alloc: FuzzAllocator = .{};
pub export fn zig_fuzz_init() callconv(.c) void {
fuzz_alloc.init();
}
pub export fn zig_fuzz_test(
buf: [*]const u8,
len: usize,
) callconv(.c) void {
fuzz_alloc.reset();
const alloc = fuzz_alloc.allocator();
const input = buf[0..@intCast(len)];
// Allocate a terminal; if we run out of fixed-buffer space just
// skip this input (not a bug, just a very large allocation).
var term = Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
.max_scrollback = 100,
}) catch return;
defer term.deinit(alloc);
var stream: ReadonlyStream = term.vtStream();
defer stream.deinit();
// Use the first byte to decide between the scalar and slice paths
// so both code paths get exercised by the fuzzer.
if (input.len == 0) return;
const mode = input[0];
const data = input[1..];
if (mode & 1 == 0) {
// Slice path — exercises SIMD fast-path
stream.nextSlice(data) catch {};
} else {
// Scalar path — exercises byte-at-a-time UTF-8 decoding
for (data) |byte| _ = stream.next(byte) catch {};
}
}