A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-maildir / tools / fuzz.zig
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}