A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
17 kB
393 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! Run the fuzz targets in `tests/fuzz.zig` against input this makes up.
5//!
6//! Zig has a fuzzer of its own and those targets are written for it, so the
7//! obvious thing to run is `zig build fuzz --fuzz`. With the devshell's
8//! patched Zig that now *compiles* — `flake.nix` says what the patch is —
9//! and then ends with
10//!
11//! ```
12//! error: step 'run test': corrupted coverage file: pcs_len was zero
13//! ```
14//!
15//! because nothing in 0.16.0 populates the table of program counters, however
16//! the modules are built. A fuzzer with no coverage is a random number
17//! generator, so this is one written down honestly: it makes an input, hands
18//! it to a target, and says so when one comes back with an error.
19//!
20//! ```console
21//! $ zig build fuzz-run # a minute of each
22//! $ zig build fuzz-run -- --seconds 300 --target name
23//! $ zig build fuzz-run -- --seed 12345 # exactly again
24//! $ zig build fuzz-run -- --input fuzz-findings/x.bin --target name
25//! ```
26//!
27//! # What an input is
28//!
29//! Not a file: a `std.testing.Smith` reads it as a stream of answers, and the
30//! encoding is worth knowing before writing a generator for it.
31//!
32//! * `smith.slice(buf)` reads **four** bytes as a little-endian `u32` length,
33//! then that many bytes of content. A length larger than `buf.len` is not
34//! reduced into range — it yields an *empty* slice. So a string of random
35//! bytes gives almost every target nothing at all to parse, and a generator
36//! that does not write a plausible length is fuzzing nothing.
37//! * `smith.value(T)` reads **eight** bytes as a little-endian `u64` and, if
38//! that value is outside the asked-for range, returns the range's minimum
39//! rather than reducing it. For an `i64` every value is in range; for a
40//! `bool` only 0 and 1 are, so random bytes make it false every time.
41//!
42//! Every target here begins with a `slice`, so `makeInput` writes two
43//! length-prefixed chunks — two because a target may ask for two slices, and
44//! the second would otherwise only ever see the random tail — and each chunk
45//! is a mutation of one of the target's own seeds. That corpus is the whole of
46//! what stands in for coverage feedback, and for these parsers it matters: a
47//! maildir name is a colon, the two characters `2,`, and a set of letters in a
48//! particular order, and random bytes are none of that. Starting from
49//! something that already parses is what gets past the first branch.
50//!
51//! The length is capped at the target's own buffer size, which `Target` has to
52//! carry for the reason above: a length larger than the buffer yields nothing
53//! rather than a truncation, and getting it wrong is silent — the target runs,
54//! reports no failure, and was handed the empty string every time.
55//!
56//! # The watchdog
57//!
58//! Nothing here should be able to loop — every parser walks a bounded input
59//! once — but "should" is what a fuzzer is for. A thread watches the clock,
60//! and an iteration that outlasts `--timeout` seconds is reported as a hang
61//! with the input that caused it. There is no way to unwind out of it, so that
62//! ends the run.
63
64const std = @import("std");
65const targets = @import("fuzz_targets");
66
67const Smith = std.testing.Smith;
68
69/// Milliseconds on a clock that only goes forwards while the machine is up.
70fn nowMs(io: std.Io) i64 {
71 return @intCast(@divFloor(std.Io.Timestamp.now(io, .awake).nanoseconds, std.time.ns_per_ms));
72}
73
74/// What the watchdog needs to see, written before each iteration begins.
75const Watch = struct {
76 /// When the running iteration started, or zero between iterations.
77 started_ms: std.atomic.Value(i64) = .init(0),
78 /// The input it is running, which is what a hang has to report.
79 input: []const u8 = &.{},
80 target: []const u8 = "",
81 timeout_s: u32 = 10,
82 dir: []const u8 = "",
83};
84
85var watch: Watch = .{};
86
87pub fn main(init: std.process.Init) !void {
88 const io = init.io;
89
90 // Two allocators, and they have to be two. The targets are written to run
91 // against the testing allocator, which cannot be named outside a test
92 // build; this is the same thing by another route, a debug allocator whose
93 // outstanding allocations are counted after every input, since a leak is
94 // one of the things being fuzzed for. Nothing else may allocate from it --
95 // the loop's own buffer would be indistinguishable from a target's leak --
96 // so everything here uses the process allocator instead.
97 var checked: std.heap.DebugAllocator(.{}) = .init;
98 defer _ = checked.deinit();
99 targets.backing = checked.allocator();
100 const gpa = init.gpa;
101
102 var seconds: u32 = 60;
103 var iterations: ?u64 = null;
104 var seed: u64 = @bitCast(@as(i64, @truncate(std.Io.Timestamp.now(io, .real).nanoseconds)));
105 var only: ?[]const u8 = null;
106 var input_path: ?[]const u8 = null;
107 var dir: []const u8 = "fuzz-findings";
108 var timeout_s: u32 = 10;
109
110 var args: std.process.Args.Iterator = .init(init.minimal.args);
111 _ = args.skip();
112 while (args.next()) |arg| {
113 if (std.mem.eql(u8, arg, "--seconds")) {
114 seconds = std.fmt.parseInt(u32, args.next() orelse "60", 10) catch 60;
115 } else if (std.mem.eql(u8, arg, "--iterations")) {
116 iterations = std.fmt.parseInt(u64, args.next() orelse "0", 10) catch null;
117 } else if (std.mem.eql(u8, arg, "--seed")) {
118 seed = std.fmt.parseInt(u64, args.next() orelse "0", 10) catch seed;
119 } else if (std.mem.eql(u8, arg, "--target")) {
120 only = args.next();
121 } else if (std.mem.eql(u8, arg, "--input")) {
122 input_path = args.next();
123 } else if (std.mem.eql(u8, arg, "--findings")) {
124 dir = args.next() orelse dir;
125 } else if (std.mem.eql(u8, arg, "--timeout")) {
126 timeout_s = std.fmt.parseInt(u32, args.next() orelse "10", 10) catch 10;
127 } else {
128 std.debug.print(
129 \\usage: fuzz [--target NAME] [--seconds N | --iterations N] [--seed S]
130 \\ [--timeout S] [--findings DIR] [--input FILE]
131 \\
132 \\Targets: {s}
133 \\
134 , .{targetNames()});
135 std.process.exit(2);
136 }
137 }
138
139 watch.timeout_s = timeout_s;
140 watch.dir = dir;
141
142 const thread = try std.Thread.spawn(.{}, watchdog, .{io});
143 thread.detach();
144
145 // One input, from a file, and nothing else: this is how a finding is
146 // looked at again after it has been fixed.
147 if (input_path) |path| {
148 const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(1 << 20));
149 defer gpa.free(bytes);
150 const name = only orelse targets.all[0].name;
151 const target = find(name) orelse {
152 std.debug.print("no target called {s}; there are: {s}\n", .{ name, targetNames() });
153 std.process.exit(2);
154 };
155 watch.input = bytes;
156 watch.target = target.name;
157 watch.started_ms.store(nowMs(io), .release);
158 target.run(bytes) catch |err| {
159 std.debug.print("{s}: {t}\n", .{ target.name, err });
160 show(bytes);
161 std.process.exit(1);
162 };
163 std.debug.print("{s}: that input is fine now\n", .{target.name});
164 return;
165 }
166
167 var prng: std.Random.DefaultPrng = .init(seed);
168 const random = prng.random();
169 var buffer: std.ArrayList(u8) = .empty;
170 defer buffer.deinit(gpa);
171
172 std.debug.print("seed {d}\n", .{seed});
173 var failures: usize = 0;
174 for (targets.all) |target| {
175 if (only) |name| if (!std.mem.eql(u8, name, target.name)) continue;
176
177 var runs: u64 = 0;
178 const deadline = nowMs(io) + @as(i64, seconds) * 1000;
179 while (if (iterations) |n| runs < n else nowMs(io) < deadline) : (runs += 1) {
180 try makeInput(gpa, &buffer, random, target);
181 watch.input = buffer.items;
182 watch.target = target.name;
183 watch.started_ms.store(nowMs(io), .release);
184 const result = target.run(buffer.items);
185 watch.started_ms.store(0, .release);
186 if (checked.detectLeaks() != 0) {
187 std.debug.print("\n{s}: leaked\n", .{target.name});
188 try report(io, dir, target.name, buffer.items);
189 std.process.exit(1);
190 }
191 result catch |err| {
192 failures += 1;
193 std.debug.print("\n{s}: {t}\n", .{ target.name, err });
194 try report(io, dir, target.name, buffer.items);
195 // Keep going: one shape of failure is usually many inputs, and
196 // stopping at the first says less than a handful does.
197 if (failures >= 10) {
198 std.debug.print("ten failures; stopping\n", .{});
199 std.process.exit(1);
200 }
201 };
202 }
203 std.debug.print("{s}: {d} runs\n", .{ target.name, runs });
204 }
205 if (failures != 0) std.process.exit(1);
206}
207
208fn find(name: []const u8) ?targets.Target {
209 for (targets.all) |t| if (std.mem.eql(u8, t.name, name)) return t;
210 return null;
211}
212
213fn targetNames() []const u8 {
214 comptime var names: []const u8 = "";
215 inline for (targets.all, 0..) |t, i| {
216 names = names ++ (if (i == 0) "" else ", ") ++ t.name;
217 }
218 return names;
219}
220
221/// Make the next input: two length-prefixed chunks and a random tail.
222///
223/// Two, because a target may ask for two slices -- `paths` wants a working
224/// directory and then an argument -- and the second would otherwise only ever
225/// see whatever random bytes happened to follow. A target that asks for one
226/// slice reads the first chunk and leaves the rest for its `value` calls.
227///
228/// The length is capped at `target.content_max` rather than at some number
229/// chosen here, because `Smith.slice` answers a length larger than its buffer
230/// with an *empty* slice rather than a truncated one. Getting that wrong is
231/// silent: the target runs, reports no failure, and has been handed nothing.
232fn makeInput(
233 gpa: std.mem.Allocator,
234 out: *std.ArrayList(u8),
235 random: std.Random,
236 target: targets.Target,
237) !void {
238 out.clearRetainingCapacity();
239
240 var content: std.ArrayList(u8) = .empty;
241 defer content.deinit(gpa);
242
243 for (0..2) |_| {
244 content.clearRetainingCapacity();
245 if (target.corpus.len == 0 or random.uintLessThan(u8, 8) == 0) {
246 // Sometimes nothing but noise, so that the shapes nobody thought
247 // of are reachable at all.
248 const len = random.uintLessThan(usize, target.content_max);
249 try content.ensureUnusedCapacity(gpa, len);
250 for (0..len) |_| content.appendAssumeCapacity(random.int(u8));
251 } else {
252 const seed = target.corpus[random.uintLessThan(usize, target.corpus.len)];
253 try content.appendSlice(gpa, seed);
254 const rounds = 1 + random.uintLessThan(usize, 8);
255 for (0..rounds) |_| try mutate(gpa, &content, random);
256 }
257 if (content.items.len > target.content_max) {
258 content.shrinkRetainingCapacity(target.content_max);
259 }
260
261 var length: [4]u8 = undefined;
262 std.mem.writeInt(u32, &length, @intCast(content.items.len), .little);
263 try out.appendSlice(gpa, &length);
264 try out.appendSlice(gpa, content.items);
265 }
266
267 // And a tail, for whatever a target asks after its slices: an `i64` reads
268 // eight bytes from here.
269 const tail = 16 + random.uintLessThan(usize, 48);
270 try out.ensureUnusedCapacity(gpa, tail);
271 for (0..tail) |_| out.appendAssumeCapacity(random.int(u8));
272}
273
274fn mutate(gpa: std.mem.Allocator, content: *std.ArrayList(u8), random: std.Random) !void {
275 if (content.items.len == 0) {
276 try content.append(gpa, random.int(u8));
277 return;
278 }
279 switch (random.uintLessThan(u8, 8)) {
280 // A byte, replaced. The commonest useful mutation, and the one that
281 // turns a reply code into a nearly-a-reply-code.
282 0, 1 => content.items[random.uintLessThan(usize, content.items.len)] = random.int(u8),
283 // A byte, replaced by one of the ones this protocol is made of.
284 2, 3 => content.items[random.uintLessThan(usize, content.items.len)] =
285 interesting[random.uintLessThan(usize, interesting.len)],
286 4 => try content.insert(gpa, random.uintLessThan(usize, content.items.len), random.int(u8)),
287 5 => try content.insert(
288 gpa,
289 random.uintLessThan(usize, content.items.len),
290 interesting[random.uintLessThan(usize, interesting.len)],
291 ),
292 // A run on the end, which is how a line grows a second field.
293 6 => for (0..1 + random.uintLessThan(usize, 16)) |_| {
294 try content.append(gpa, interesting[random.uintLessThan(usize, interesting.len)]);
295 },
296 else => _ = content.orderedRemove(random.uintLessThan(usize, content.items.len)),
297 }
298}
299
300/// The bytes this format is mostly made of, plus the ones that end things.
301///
302/// CR and LF are in it several times over because every structure here is
303/// delimited by one: a header line, the blank line before the body, a fold, a
304/// soft line break, a boundary. `=`, `?` and `_` are the punctuation of an
305/// encoded word and of quoted-printable; `;`, `*` and `'` are the punctuation
306/// of an RFC 2231 parameter; `<`, `>`, `@`, `,`, `:` and `"` are what an
307/// address is held together with. The high bytes are there because a header
308/// that is supposed to be ASCII and is not is the commonest defect of all.
309const interesting = blk: {
310 var set: []const u8 = "\r\n\r\n\r\n";
311 set = set ++ " \t;=?_*'.-\"\\<>@,:()/[]";
312 set = set ++ "0123456789";
313 set = set ++ "ABCDEFQTUVabcdefqtuv";
314 set = set ++ &[_]u8{ 0x00, 0x7f, 0x80, 0xc3, 0xa9, 0xff };
315 break :blk set;
316};
317
318/// Print a failing input and write it where it can be fed back.
319fn report(io: std.Io, dir: []const u8, target: []const u8, input: []const u8) !void {
320 show(input);
321
322 var name: [128]u8 = undefined;
323 const path = std.fmt.bufPrint(&name, "{s}/{s}-{x:0>16}.bin", .{
324 dir,
325 target,
326 std.hash.Wyhash.hash(0, input),
327 }) catch return;
328
329 std.Io.Dir.cwd().createDirPath(io, dir) catch {};
330 var file = std.Io.Dir.cwd().createFile(io, path, .{}) catch |err| {
331 std.debug.print("(could not write {s}: {t})\n", .{ path, err });
332 return;
333 };
334 defer file.close(io);
335 file.writeStreamingAll(io, input) catch {};
336 std.debug.print(
337 "written to {s}, and `--input {s} --target {s}` runs it again\n",
338 .{ path, path, target },
339 );
340}
341
342/// The input, in hex, and then what a target actually reads out of it.
343///
344/// The second half earns its lines: an input is a stream of answers rather
345/// than a file, so the bytes alone do not say what the parser was given, and
346/// that is the first thing anybody wants to see.
347fn show(input: []const u8) void {
348 std.debug.print("input, {d} bytes:\n ", .{input.len});
349 for (input, 0..) |b, i| {
350 if (i != 0 and i % 32 == 0) std.debug.print("\n ", .{});
351 std.debug.print(" {x:0>2}", .{b});
352 }
353 std.debug.print("\n", .{});
354
355 var smith: Smith = .{ .in = input };
356 var buffer: [4096]u8 = undefined;
357 const text = buffer[0..smith.slice(&buffer)];
358 std.debug.print("which reads as {d} bytes:\n", .{text.len});
359 var lines = std.mem.splitScalar(u8, text, '\n');
360 while (lines.next()) |line| {
361 std.debug.print(" |{f}\n", .{std.ascii.hexEscape(line, .lower)});
362 }
363}
364
365/// Watch for an iteration that never ends.
366fn watchdog(io: std.Io) void {
367 while (true) {
368 std.Io.sleep(io, .fromMilliseconds(500), .awake) catch return;
369 const started = watch.started_ms.load(.acquire);
370 if (started == 0) continue;
371 const elapsed = nowMs(io) - started;
372 if (elapsed < @as(i64, watch.timeout_s) * 1000) continue;
373
374 std.debug.print(
375 "\n{s}: no answer after {d} seconds, which is a hang\n",
376 .{ watch.target, @divTrunc(elapsed, 1000) },
377 );
378 show(watch.input);
379 var name: [128]u8 = undefined;
380 const path = std.fmt.bufPrint(&name, "{s}/{s}-hang-{x:0>16}.bin", .{
381 watch.dir,
382 watch.target,
383 std.hash.Wyhash.hash(0, watch.input),
384 }) catch std.process.exit(3);
385 std.Io.Dir.cwd().createDirPath(io, watch.dir) catch {};
386 if (std.Io.Dir.cwd().createFile(io, path, .{})) |file| {
387 defer file.close(io);
388 file.writeStreamingAll(io, watch.input) catch {};
389 std.debug.print("written to {s}\n", .{path});
390 } else |_| {}
391 std.process.exit(3);
392 }
393}