A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
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 = "a_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}