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 / src / quota.zig
17 kB 451 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! `maildirsize`: how much mail is in a Maildir++ store, and how much is 5//! allowed. 6//! 7//! Totalling a mail store means listing every folder and adding up every 8//! message, which is fine once and ruinous on every delivery. Courier's 9//! answer is a small file in the top-level maildir that holds the running 10//! total, written like a ledger rather than a balance: 11//! 12//! ```text 13//! 10485760S,1000C 14//! 4211 1 15//! 8320 1 16//! -4211 -1 17//! ``` 18//! 19//! The first line is the quota — so many bytes (`S`), so many messages (`C`) 20//! — and every line after it is a **change**, appended by whoever made it. 21//! The usage is their sum. Appending a line is a short write at the end of a 22//! file, which is cheap and needs no coordination beyond not interleaving two 23//! of them; recomputing the balance from scratch is the slow path, taken when 24//! the ledger has grown long or is not to be trusted. 25//! 26//! That design means the file is **advisory and self-healing rather than 27//! authoritative**. It drifts — a message deleted by something that does not 28//! know about the file is never subtracted — and it is meant to, because 29//! `isStale` eventually says so and the total is recomputed. Treat a number 30//! from here as "what the store believed last time somebody checked", and do 31//! not build anything on it that a few kilobytes of drift would break. 32//! 33//! This file knows the format and nothing about the store. Walking the 34//! folders to recompute the total is `Store.recalculateQuota`. 35 36const std = @import("std"); 37const Io = std.Io; 38const Dir = Io.Dir; 39const File = Io.File; 40const Allocator = std.mem.Allocator; 41const testing = std.testing; 42 43/// The name of the file, in the top-level maildir. There is exactly one for a 44/// whole Maildir++ store: a folder does not have a quota of its own. 45pub const filename = "maildirsize"; 46 47/// What the store is allowed to hold. Absent limits are no limit. 48pub const Limits = struct { 49 /// The `S` limit: total bytes of message. 50 bytes: ?u64 = null, 51 /// The `C` limit: number of messages. 52 messages: ?u64 = null, 53 54 pub const none: Limits = .{}; 55 56 /// Reads the first line of a `maildirsize` file: comma-separated items, 57 /// each a number followed by the letter saying what it counts. 58 /// 59 /// Never fails. An item in a letter nobody has defined is ignored, which 60 /// is what Courier does and what keeps a store readable when something 61 /// has written a limit this library has not heard of. 62 pub fn parse(text: []const u8) Limits { 63 var result: Limits = .none; 64 var it = std.mem.splitScalar(u8, std.mem.trim(u8, text, " \t\r"), ','); 65 while (it.next()) |raw| { 66 const item = std.mem.trim(u8, raw, " \t\r"); 67 if (item.len < 2) continue; 68 const value = std.fmt.parseInt(u64, item[0 .. item.len - 1], 10) catch continue; 69 switch (item[item.len - 1]) { 70 'S', 's' => result.bytes = value, 71 'C', 'c' => result.messages = value, 72 else => {}, 73 } 74 } 75 return result; 76 } 77 78 pub fn format(self: Limits, w: *Io.Writer) Io.Writer.Error!void { 79 var written = false; 80 if (self.bytes) |value| { 81 try w.print("{d}S", .{value}); 82 written = true; 83 } 84 if (self.messages) |value| { 85 if (written) try w.writeByte(','); 86 try w.print("{d}C", .{value}); 87 } 88 } 89 90 pub fn isSet(self: Limits) bool { 91 return self.bytes != null or self.messages != null; 92 } 93}; 94 95/// A total, or a change to one. Signed, because the lines in the file are 96/// changes and a deletion is a negative one. 97pub const Usage = struct { 98 bytes: i64 = 0, 99 messages: i64 = 0, 100 101 pub const zero: Usage = .{}; 102 103 pub fn plus(a: Usage, b: Usage) Usage { 104 return .{ 105 .bytes = a.bytes +| b.bytes, 106 .messages = a.messages +| b.messages, 107 }; 108 } 109 110 pub fn minus(a: Usage, b: Usage) Usage { 111 return .{ 112 .bytes = a.bytes -| b.bytes, 113 .messages = a.messages -| b.messages, 114 }; 115 } 116 117 /// The same total with negative components raised to zero. A negative 118 /// total is not a mailbox owing mail; it is the ledger having drifted, 119 /// and it is what a caller about to display a number wants. 120 pub fn clamped(self: Usage) Usage { 121 return .{ 122 .bytes = @max(self.bytes, 0), 123 .messages = @max(self.messages, 0), 124 }; 125 } 126 127 /// One line of the ledger. 128 pub fn format(self: Usage, w: *Io.Writer) Io.Writer.Error!void { 129 try w.print("{d} {d}", .{ self.bytes, self.messages }); 130 } 131}; 132 133/// Everything the text of a `maildirsize` file says. Separate from `State` 134/// because it is a pure function of the bytes, which is what makes it 135/// testable without a filesystem and fuzzable at all. 136pub const Ledger = struct { 137 limits: Limits, 138 /// The sum of every change in the file. 139 usage: Usage, 140 /// How many change lines there were. 141 records: usize, 142 /// A line that could not be read. 143 damaged: bool, 144 145 pub const empty: Ledger = .{ 146 .limits = .none, 147 .usage = .zero, 148 .records = 0, 149 .damaged = false, 150 }; 151}; 152 153/// Reads the text of a `maildirsize` file: a quota on the first line and a 154/// change on every line after it. 155/// 156/// Never fails. A line that cannot be read is counted and skipped, and 157/// `damaged` says so — which `State.isStale` then turns into a request to 158/// recompute the total, since a total missing some of its changes is worse 159/// than no total at all. 160pub fn parseLedger(text: []const u8) Ledger { 161 var ledger: Ledger = .empty; 162 var lines = std.mem.splitScalar(u8, text, '\n'); 163 ledger.limits = .parse(lines.first()); 164 while (lines.next()) |raw| { 165 const line = std.mem.trim(u8, raw, " \t\r"); 166 if (line.len == 0) continue; 167 ledger.records += 1; 168 const change = parseRecord(line) orelse { 169 ledger.damaged = true; 170 continue; 171 }; 172 ledger.usage = ledger.usage.plus(change); 173 } 174 return ledger; 175} 176 177/// Everything `maildirsize` says, plus what is needed to decide whether to 178/// believe it. 179pub const State = struct { 180 limits: Limits, 181 /// The sum of every change in the file. 182 usage: Usage, 183 /// How many change lines there were. One means the file was written by a 184 /// recalculation and nothing has been appended since. 185 records: usize, 186 /// How big `maildirsize` itself is. Courier's cue to recalculate: a long 187 /// ledger is a slow read on every delivery. 188 file_size: u64, 189 mtime: Io.Timestamp, 190 /// A line that could not be read. The file has been damaged, or written 191 /// by something with its own ideas, and the total is missing whatever 192 /// those lines said. 193 damaged: bool, 194 195 /// Whether the store is over one of its limits. False when no limit is 196 /// set, whatever the usage. 197 pub fn exceeded(self: State) bool { 198 if (self.limits.bytes) |limit| { 199 if (self.usage.bytes > 0 and @as(u64, @intCast(self.usage.bytes)) > limit) return true; 200 } 201 if (self.limits.messages) |limit| { 202 if (self.usage.messages > 0 and @as(u64, @intCast(self.usage.messages)) > limit) return true; 203 } 204 return false; 205 } 206 207 /// Whether the total should be recomputed rather than trusted. 208 /// 209 /// The age test applies only when the store is over quota, and that 210 /// asymmetry is deliberate: being wrongly under quota costs a little 211 /// unfairness, while being wrongly *over* it bounces mail, so the 212 /// expensive check is spent only on the answer that would refuse a 213 /// delivery. 214 pub fn isStale(self: State, io: Io, options: StaleOptions) bool { 215 if (self.damaged) return true; 216 if (self.file_size > options.max_file_size) return true; 217 if (self.records > options.max_records) return true; 218 if (self.exceeded()) { 219 const age = self.mtime.durationTo(Io.Timestamp.now(io, .real)); 220 if (age.nanoseconds > options.max_age.nanoseconds) return true; 221 } 222 return false; 223 } 224}; 225 226pub const StaleOptions = struct { 227 /// Courier's threshold, and there is no reason to differ from it. 228 max_file_size: u64 = 5120, 229 max_records: usize = 128, 230 max_age: Io.Duration = .{ .nanoseconds = 15 * 60 * std.time.ns_per_s }, 231}; 232 233/// The most of a `maildirsize` file that will be read. Anything longer is a 234/// ledger that should have been recalculated long ago. 235pub const read_limit: Io.Limit = .limited(1024 * 1024); 236 237pub const ReadError = Dir.ReadFileAllocError || Dir.StatFileError; 238 239/// Reads `maildirsize` from the top-level maildir, or returns null if there 240/// is none — which is how a store with no quota configured looks, and is not 241/// an error. 242pub fn read(dir: Dir, io: Io, gpa: Allocator) ReadError!?State { 243 const stat = dir.statFile(io, filename, .{}) catch |err| switch (err) { 244 error.FileNotFound => return null, 245 else => |e| return e, 246 }; 247 const text = dir.readFileAlloc(io, filename, gpa, read_limit) catch |err| switch (err) { 248 error.FileNotFound => return null, 249 else => |e| return e, 250 }; 251 defer gpa.free(text); 252 253 const ledger = parseLedger(text); 254 return .{ 255 .limits = ledger.limits, 256 .usage = ledger.usage, 257 .records = ledger.records, 258 .damaged = ledger.damaged, 259 .file_size = stat.size, 260 .mtime = stat.mtime, 261 }; 262} 263 264fn writeLedger(w: *Io.Writer, limits: Limits, usage: Usage) Io.Writer.Error!void { 265 try limits.format(w); 266 try w.writeByte('\n'); 267 try usage.format(w); 268 try w.writeByte('\n'); 269 try w.flush(); 270} 271 272fn parseRecord(line: []const u8) ?Usage { 273 var fields = std.mem.tokenizeScalar(u8, line, ' '); 274 const bytes_text = fields.next() orelse return null; 275 const messages_text = fields.next() orelse return null; 276 if (fields.next() != null) return null; 277 return .{ 278 .bytes = std.fmt.parseInt(i64, bytes_text, 10) catch return null, 279 .messages = std.fmt.parseInt(i64, messages_text, 10) catch return null, 280 }; 281} 282 283pub const RecordError = File.OpenError || File.WritePositionalError || File.LengthError; 284 285/// Appends one change to the ledger: positive for a delivery, negative for a 286/// message removed. 287/// 288/// Does nothing if there is no `maildirsize`, since a store with no quota 289/// configured is not one to start keeping a ledger for. 290/// 291/// The write is made under an exclusive advisory lock, which the original 292/// design does not take — it relies on `O_APPEND` making a short write 293/// indivisible. Zig 0.16 has no way to ask for `O_APPEND`, so the position to 294/// write at has to be read first, and between reading it and writing there is 295/// a race that would have one process overwrite the other's line. The lock 296/// closes it for every process that also takes it, which is every process 297/// using this library; one that does not can still lose a line, and the 298/// ledger is self-healing for exactly that sort of reason. 299pub fn record(dir: Dir, io: Io, delta: Usage) RecordError!void { 300 var file = dir.openFile(io, filename, .{ 301 .mode = .read_write, 302 .allow_directory = false, 303 .lock = .exclusive, 304 }) catch |err| switch (err) { 305 error.FileNotFound => return, 306 else => |e| return e, 307 }; 308 defer file.close(io); 309 310 var buffer: [64]u8 = undefined; 311 var w: Io.Writer = .fixed(&buffer); 312 // Cannot overflow: two 64-bit integers and a space in a 64-byte buffer. 313 delta.format(&w) catch unreachable; 314 w.writeByte('\n') catch unreachable; 315 316 try file.writePositionalAll(io, w.buffered(), try file.length(io)); 317} 318 319pub const WriteError = Dir.CreateFileAtomicError || File.Writer.Error || 320 File.Atomic.ReplaceError || error{ 321 /// See `Maildir.DeliverError.WriteFailed`. 322 WriteFailed, 323}; 324 325/// Replaces `maildirsize` with a fresh ledger: the quota, and one line 326/// holding the whole total. This is what a recalculation writes. 327/// 328/// Written to an unnamed file and renamed into place, so a reader either sees 329/// the old total or the new one and never a half-written file. 330pub fn write(dir: Dir, io: Io, limits: Limits, usage: Usage) WriteError!void { 331 var atomic = try dir.createFileAtomic(io, filename, .{ 332 .replace = true, 333 .permissions = if (@hasDecl(File.Permissions, "fromMode")) 334 File.Permissions.fromMode(0o600) 335 else 336 .default_file, 337 }); 338 defer atomic.deinit(io); 339 340 var buffer: [128]u8 = undefined; 341 var file_writer = atomic.file.writer(io, &buffer); 342 const w = &file_writer.interface; 343 writeLedger(w, limits, usage) catch return file_writer.err orelse error.WriteFailed; 344 345 try atomic.replace(io); 346} 347 348test "a quota line" { 349 const limits: Limits = .parse("10485760S,1000C"); 350 try testing.expectEqual(@as(?u64, 10485760), limits.bytes); 351 try testing.expectEqual(@as(?u64, 1000), limits.messages); 352 try testing.expectFmt("10485760S,1000C", "{f}", .{limits}); 353} 354 355test "a quota with only one kind of limit" { 356 try testing.expectEqual(@as(?u64, null), Limits.parse("1000C").bytes); 357 try testing.expectFmt("5S", "{f}", .{Limits{ .bytes = 5 }}); 358 try testing.expectFmt("", "{f}", .{Limits.none}); 359 try testing.expect(!Limits.none.isSet()); 360} 361 362test "an unknown limit letter is ignored rather than fatal" { 363 const limits: Limits = .parse("100S,50X,20C"); 364 try testing.expectEqual(@as(?u64, 100), limits.bytes); 365 try testing.expectEqual(@as(?u64, 20), limits.messages); 366} 367 368test "an empty quota line is no quota" { 369 try testing.expect(!Limits.parse("").isSet()); 370} 371 372test "usage adds up and clamps" { 373 const total = Usage.zero 374 .plus(.{ .bytes = 4211, .messages = 1 }) 375 .plus(.{ .bytes = -4211, .messages = -1 }) 376 .plus(.{ .bytes = -100, .messages = -1 }); 377 try testing.expectEqual(@as(i64, -100), total.bytes); 378 try testing.expectEqual(@as(i64, 0), total.clamped().bytes); 379 try testing.expectFmt("-100 -1", "{f}", .{total}); 380} 381 382test "over quota only when a limit is set" { 383 const over: State = .{ 384 .limits = .{ .bytes = 100 }, 385 .usage = .{ .bytes = 200, .messages = 1 }, 386 .records = 1, 387 .file_size = 32, 388 .mtime = .zero, 389 .damaged = false, 390 }; 391 try testing.expect(over.exceeded()); 392 393 var unlimited = over; 394 unlimited.limits = .none; 395 try testing.expect(!unlimited.exceeded()); 396} 397 398test "a damaged ledger is always stale" { 399 const state: State = .{ 400 .limits = .none, 401 .usage = .zero, 402 .records = 1, 403 .file_size = 32, 404 .mtime = .zero, 405 .damaged = true, 406 }; 407 try testing.expect(state.isStale(std.Io.failing, .{})); 408} 409 410test "a long ledger is stale even when it is under quota" { 411 const state: State = .{ 412 .limits = .none, 413 .usage = .zero, 414 .records = 1, 415 .file_size = 8192, 416 .mtime = .zero, 417 .damaged = false, 418 }; 419 try testing.expect(state.isStale(std.Io.failing, .{})); 420} 421 422test "a record line" { 423 try testing.expectEqual(Usage{ .bytes = 4211, .messages = 1 }, parseRecord("4211 1").?); 424 try testing.expectEqual(Usage{ .bytes = -4211, .messages = -1 }, parseRecord("-4211 -1").?); 425 try testing.expectEqual(@as(?Usage, null), parseRecord("4211")); 426 try testing.expectEqual(@as(?Usage, null), parseRecord("4211 1 extra")); 427 try testing.expectEqual(@as(?Usage, null), parseRecord("lots 1")); 428} 429 430test "a whole ledger" { 431 const ledger = parseLedger("1000S,10C\n400 1\n300 1\n-400 -1\n"); 432 try testing.expectEqual(@as(?u64, 1000), ledger.limits.bytes); 433 try testing.expectEqual(@as(i64, 300), ledger.usage.bytes); 434 try testing.expectEqual(@as(i64, 1), ledger.usage.messages); 435 try testing.expectEqual(@as(usize, 3), ledger.records); 436 try testing.expect(!ledger.damaged); 437} 438 439test "an empty file is an empty ledger, not an error" { 440 const ledger = parseLedger(""); 441 try testing.expectEqual(Ledger.empty, ledger); 442} 443 444test "a ledger with a line nobody can read" { 445 const ledger = parseLedger("1000S\n400 1\n???\n"); 446 try testing.expect(ledger.damaged); 447 try testing.expectEqual(@as(i64, 400), ledger.usage.bytes); 448 // The unreadable line is still counted, because it is still making the 449 // file longer and still a reason to recompute. 450 try testing.expectEqual(@as(usize, 2), ledger.records); 451}