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 / tests / fuzz.zig
13 kB 334 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! What the parsers must do with input nobody wrote. 5//! 6//! Everything here is a property rather than an example: not "this name 7//! parses to those flags" — the unit tests have those — but "whatever 8//! arrives, the parser terminates, stays inside its buffers, and if it claims 9//! to have understood the input then writing it back out and reading it again 10//! gives the same answer". 11//! 12//! Stability is the property that earns its keep here, and it is a stronger 13//! claim than it sounds. A maildir library rewrites names constantly: every 14//! flag change is a `parse`, a change, and a `write`. If that round trip is 15//! not a fixed point — if writing a parsed name can produce something that 16//! parses differently — then a message's name drifts a little every time 17//! anybody touches it, and since the name is the message's identity, the 18//! message eventually becomes a different message. The name target therefore 19//! asks for the *second* write to equal the first rather than for the output 20//! to equal the input: a name may legitimately be repaired on the way in 21//! (unsorted flags get sorted, a duplicate letter is dropped), but repairing 22//! it twice must change nothing, and the repair must not touch `base`. 23//! 24//! Each target is an ordinary test as well as a fuzz target. Without `--fuzz` 25//! it runs the seeds beside it, so `zig build test` exercises the same 26//! properties on input that has already been interesting once. 27//! 28//! Note that Zig 0.16.0 cannot build a test executable in fuzz mode without a 29//! patched standard library, and leaves the fuzzer's coverage table empty even 30//! then; `flake.nix` says what the patch is and `tools/fuzz.zig` is the loop 31//! that stands in for the fuzzer. The properties are worth having either way. 32 33const builtin = @import("builtin"); 34const std = @import("std"); 35const Io = std.Io; 36const Dir = Io.Dir; 37const Allocator = std.mem.Allocator; 38const testing = std.testing; 39 40const maildir = @import("maildir"); 41const Flags = maildir.Flags; 42const Name = maildir.Name; 43const folder = maildir.folder; 44const quota = maildir.quota; 45 46/// The allocator the targets run against. 47/// 48/// Under `zig build test` that is the testing allocator, which reports a leak 49/// as a failure. `tools/fuzz.zig` cannot name it — it is not a test build — so 50/// it sets this to a checked allocator of its own instead. Nothing here 51/// allocates yet; it is part of the contract the driver expects. 52pub var backing: Allocator = if (builtin.is_test) testing.allocator else undefined; 53 54/// One fuzz target: what to call it, what it already knows to be interesting, 55/// how much of an input it can read, and the property itself. 56pub const Target = struct { 57 name: []const u8, 58 corpus: []const []const u8, 59 /// The size of the buffer the target reads its slice into. 60 /// 61 /// `Smith.slice` answers a length larger than its buffer with an *empty* 62 /// slice rather than a truncated one, so a generator that does not know 63 /// this number will silently hand the target nothing at all. 64 content_max: usize, 65 run: *const fn (input: []const u8) anyerror!void, 66}; 67 68pub const all = [_]Target{ 69 .{ .name = "name", .corpus = &name_seeds, .content_max = name_max, .run = runName }, 70 .{ .name = "flags", .corpus = &flag_seeds, .content_max = flag_max, .run = runFlags }, 71 .{ .name = "folder", .corpus = &folder_seeds, .content_max = folder_max, .run = runFolder }, 72 .{ .name = "quota", .corpus = &quota_seeds, .content_max = quota_max, .run = runQuota }, 73}; 74 75// -- message names ------------------------------------------------------------ 76 77const name_max = Dir.max_name_bytes; 78 79const name_seeds = [_][]const u8{ 80 "1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com", 81 "1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com:2,RS", 82 "1757700000.M1P2Q3.host,S=4211,W=4300:2,DFPRST", 83 "1757700000.M1P2Q3.host:2,", 84 "1757700000.M1P2Q3.host:2,TSRPFD", // flags out of order 85 "1757700000.M1P2Q3.host:2,SS", // the same flag twice 86 "1757700000.M1P2Q3.host:2,Sab", // Dovecot keywords 87 "1757700000.M1P2Q3.host:1,experimental", 88 "1757700000.M1P2Q3.host:2,S=1", // an info field that is not flags 89 "a:b:2,S", // a colon in what would have to be the unique part 90 "x,S=:2,S", // an empty size field 91 "x,S=99999999999999999999999:2,S", // a size that does not fit 92 ":2,S", 93 ":", 94 "", 95}; 96 97/// A name that has been parsed and written once must not change if it is 98/// parsed and written again, and the repair must not alter what identifies the 99/// message. 100fn nameProperty(input: []const u8) !void { 101 const first: Name = .parse(input, ':'); 102 103 var once_buffer: [name_max * 2]u8 = undefined; 104 const once = first.bufWrite(&once_buffer, ':') catch return; 105 106 const second: Name = .parse(once, ':'); 107 108 var twice_buffer: [name_max * 2]u8 = undefined; 109 const twice = try second.bufWrite(&twice_buffer, ':'); 110 111 // The fixed point: repairing a name twice changes nothing. 112 try testing.expectEqualStrings(once, twice); 113 114 // The identity is untouched by the repair. This is the one that would 115 // lose mail: a message whose base changed is, to every other program 116 // sharing the maildir, a different message. 117 try testing.expectEqualStrings(first.base(), second.base()); 118 try testing.expectEqualStrings(first.unique, second.unique); 119 120 // And so are the flags, and the size the name claims. 121 try testing.expect(first.flags().eql(second.flags())); 122 try testing.expectEqual(first.size(), second.size()); 123 try testing.expectEqual(first.virtualSize(), second.virtualSize()); 124 125 // A name always begins with its own unique part, and the unique part 126 // always begins with the base. Nothing may be inserted before them. 127 try testing.expect(std.mem.startsWith(u8, once, first.unique)); 128 try testing.expect(std.mem.startsWith(u8, first.unique, first.base())); 129} 130 131test "fuzz names" { 132 for (name_seeds) |seed| try nameProperty(seed); 133 try testing.fuzz({}, fuzzName, .{}); 134} 135 136fn fuzzName(_: void, smith: *testing.Smith) !void { 137 var buffer: [name_max]u8 = undefined; 138 try nameProperty(buffer[0..smith.slice(&buffer)]); 139} 140 141fn runName(input: []const u8) anyerror!void { 142 var smith: testing.Smith = .{ .in = input }; 143 var buffer: [name_max]u8 = undefined; 144 return nameProperty(buffer[0..smith.slice(&buffer)]); 145} 146 147// -- flags -------------------------------------------------------------------- 148 149const flag_max = 256; 150 151const flag_seeds = [_][]const u8{ 152 "", 153 "S", 154 "DFPRST", 155 "TSRPFD", 156 "SSSS", 157 "Sab", 158 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 159 "S,", 160 "2,S", 161 "\x00", 162}; 163 164/// Flags that parse must be written sorted, without duplicates, with exactly 165/// the letters that went in, and must read back as the same set. 166fn flagsProperty(input: []const u8) !void { 167 const parsed = Flags.parse(input) catch |err| { 168 // The only refusal is a character that is not a letter, and there has 169 // to be one in the input for it to happen. 170 try testing.expectEqual(error.InvalidFlag, err); 171 for (input) |c| if (Flags.Letters.indexOf(c) == null) return; 172 return error.RefusedValidFlags; 173 }; 174 175 var buffer: [64]u8 = undefined; 176 var w: Io.Writer = .fixed(&buffer); 177 try parsed.format(&w); 178 const text = w.buffered(); 179 180 // Strictly ascending, which is both sorted and free of duplicates, and is 181 // what the specification means by ASCII order. 182 for (text, 0..) |c, index| { 183 if (index > 0) try testing.expect(text[index - 1] < c); 184 } 185 186 // The same letters, no more and no fewer. 187 for (input) |c| try testing.expect(std.mem.findScalar(u8, text, c) != null); 188 for (text) |c| try testing.expect(std.mem.findScalar(u8, input, c) != null); 189 190 // And it reads back as the same set. 191 try testing.expect(parsed.eql(try Flags.parse(text))); 192 try testing.expectEqual(parsed.count(), text.len); 193} 194 195test "fuzz flags" { 196 for (flag_seeds) |seed| try flagsProperty(seed); 197 try testing.fuzz({}, fuzzFlags, .{}); 198} 199 200fn fuzzFlags(_: void, smith: *testing.Smith) !void { 201 var buffer: [flag_max]u8 = undefined; 202 try flagsProperty(buffer[0..smith.slice(&buffer)]); 203} 204 205fn runFlags(input: []const u8) anyerror!void { 206 var smith: testing.Smith = .{ .in = input }; 207 var buffer: [flag_max]u8 = undefined; 208 return flagsProperty(buffer[0..smith.slice(&buffer)]); 209} 210 211// -- folder names ------------------------------------------------------------- 212 213const folder_max = Dir.max_name_bytes; 214 215const folder_seeds = [_][]const u8{ 216 "Work", 217 "Work/Reports", 218 "Work/Reports/Q1", 219 "example.com", 220 "Work//Reports", 221 "/Work", 222 "Work/", 223 "", 224 ".", 225 "..", 226 "Work/../Escape", 227 "Wörk/Berichte", 228}; 229 230/// A path that can be encoded comes back out as the components that went in, 231/// and the result is a name this library recognises as a folder. 232fn folderProperty(input: []const u8) !void { 233 var buffer: [folder_max]u8 = undefined; 234 const dirname = folder.bufPath(&buffer, input, '/') catch return; 235 236 // Anything this produces must be recognised by the thing that reads a 237 // directory listing, or a folder could be created and then not listed. 238 try testing.expect(folder.isFolder(dirname)); 239 240 // The components survive the round trip. 241 var produced = folder.components(dirname); 242 var expected = std.mem.splitScalar(u8, input, '/'); 243 while (expected.next()) |component| { 244 try testing.expectEqualStrings(component, produced.next() orelse 245 return error.MissingComponent); 246 } 247 try testing.expectEqual(@as(?[]const u8, null), produced.next()); 248 249 // A folder name and its parent agree about their relationship, and no 250 // path can escape the store it is in. 251 try testing.expect(std.mem.findScalar(u8, dirname, '/') == null); 252 if (folder.parent(dirname)) |above| { 253 try testing.expect(folder.isFolder(above)); 254 try testing.expect(folder.isBelow(dirname, above)); 255 try testing.expect(!folder.isBelow(above, dirname)); 256 } 257} 258 259test "fuzz folder names" { 260 for (folder_seeds) |seed| try folderProperty(seed); 261 try testing.fuzz({}, fuzzFolder, .{}); 262} 263 264fn fuzzFolder(_: void, smith: *testing.Smith) !void { 265 var buffer: [folder_max]u8 = undefined; 266 try folderProperty(buffer[0..smith.slice(&buffer)]); 267} 268 269fn runFolder(input: []const u8) anyerror!void { 270 var smith: testing.Smith = .{ .in = input }; 271 var buffer: [folder_max]u8 = undefined; 272 return folderProperty(buffer[0..smith.slice(&buffer)]); 273} 274 275// -- the quota ledger --------------------------------------------------------- 276 277const quota_max = 4096; 278 279const quota_seeds = [_][]const u8{ 280 "10485760S,1000C\n4211 1\n", 281 "10485760S,1000C\n4211 1\n8320 1\n-4211 -1\n", 282 "1000C\n", 283 "\n", 284 "", 285 "nonsense\nmore nonsense\n", 286 "1S\n9223372036854775807 1\n9223372036854775807 1\n", 287 "1S\n-9223372036854775808 -1\n-9223372036854775808 -1\n", 288 "100S,50X,20C\n", 289 "10S\n 4211 1 \n", 290}; 291 292/// Reading a ledger never fails, and a ledger this library writes reads back 293/// as what was written. 294fn quotaProperty(input: []const u8) !void { 295 const ledger = quota.parseLedger(input); 296 297 // A sum of saturating additions cannot have overflowed, so the totals are 298 // always usable, and clamping only ever raises them to zero. 299 try testing.expect(ledger.usage.clamped().bytes >= 0); 300 try testing.expect(ledger.usage.clamped().messages >= 0); 301 302 // What was written is what is read: the limits and the total survive a 303 // trip through the file format, which is what `Store.recalculateQuota` 304 // depends on. 305 var buffer: [128]u8 = undefined; 306 var w: Io.Writer = .fixed(&buffer); 307 ledger.limits.format(&w) catch return; 308 w.writeByte('\n') catch return; 309 ledger.usage.format(&w) catch return; 310 w.writeByte('\n') catch return; 311 312 const again = quota.parseLedger(w.buffered()); 313 try testing.expectEqual(ledger.limits.bytes, again.limits.bytes); 314 try testing.expectEqual(ledger.limits.messages, again.limits.messages); 315 try testing.expectEqual(ledger.usage, again.usage); 316 try testing.expectEqual(@as(usize, 1), again.records); 317 try testing.expect(!again.damaged); 318} 319 320test "fuzz the quota ledger" { 321 for (quota_seeds) |seed| try quotaProperty(seed); 322 try testing.fuzz({}, fuzzQuota, .{}); 323} 324 325fn fuzzQuota(_: void, smith: *testing.Smith) !void { 326 var buffer: [quota_max]u8 = undefined; 327 try quotaProperty(buffer[0..smith.slice(&buffer)]); 328} 329 330fn runQuota(input: []const u8) anyerror!void { 331 var smith: testing.Smith = .{ .in = input }; 332 var buffer: [quota_max]u8 = undefined; 333 return quotaProperty(buffer[0..smith.slice(&buffer)]); 334}