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.

Initial commit: maildir and Maildir++ for Zig 0.16

A maildir is a directory of three directories and one idea: a message is a
file whose name carries everything mutable about it. Delivery is an exclusive
create in `tmp` and a rename into `new`, so it needs no lock and is safe over
NFS; changing a flag is a rename, so a message file is written once and never
modified, which is what lets the `,S=` size in its name be trusted.

The library is that convention and nothing else. `Name` and `Flags` are pure
functions over bytes, `Maildir` is one mailbox, and `Store` is a Maildir++
tree with the `maildirsize` quota ledger covering it. Message content is
zig-mime's: `Message.parse` hands the bytes over and this library has no
opinion about what is in them, which is what lets it file a message it cannot
parse -- a message that has already arrived has to be filed whatever is in its
headers.

Two decisions are worth singling out. Flags keep every letter they were given,
not just the six that are defined, because Dovecot stores IMAP keywords as the
letters `a` to `z` and a library that dropped them would silently delete
labels a user applied. And `Name.parse` cannot fail: a name it does not
understand keeps its info verbatim and is written back byte for byte, because
refusing to list a message written in a dialect this library has not heard of
loses mail that is sitting right there.

A NixOS test runs a real Dovecot against a maildir this library wrote and
found two things worth knowing. Dovecot leaves a flagged message in `new`,
renaming it in place to `new/...,S=121:2,S`, which the specification says
should only ever happen in `cur` -- so a reader that took the rule literally
would show that message as unread forever. And a keyword survives a move
between folders only half way: the letter is part of the flags and travels,
but what the letter means lives in a `dovecot-keywords` file inside each
mailbox and does not, which is a limit of the format rather than of this code.

62 million fuzz inputs across the name, flag, folder and quota parsers, with
no findings. The name target asks that writing a parsed name twice changes
nothing and never touches `base`, because the name is the message's identity
and a round trip that is not a fixed point makes a message drift into a
different message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016LJuBzNgYqy1qhVusk5VZY

author
Jeffrey C. Ollie
co-author
Claude Opus 5 (1M context)
date (Sep 12, 2026, 5:25 PM -0500) commit e1b77a0b
+6349
+9
.gitignore
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + /.zig-cache/ 5 + /zig-out/ 6 + /result 7 + /result-* 8 + /zig-pkg/ 9 + /fuzz-findings/
+418
README.md
··· 1 + <!-- 2 + SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 3 + SPDX-License-Identifier: MIT 4 + --> 5 + 6 + # zig-maildir 7 + 8 + Maildirs for Zig 0.16: delivering messages into one, reading them back out, 9 + changing their flags, and the Maildir++ tree of folders and quota that the rest 10 + of the world built on top. 11 + 12 + ```console 13 + $ zig-maildir create Maildir 14 + $ zig-maildir deliver Maildir message.eml 15 + 1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com 16 + $ zig-maildir list Maildir 17 + new INBOX 4211 1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com 18 + $ zig-maildir flag Maildir 1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com +S +R 19 + RS 20 + ``` 21 + 22 + Three things, and you can stop at any of them: 23 + 24 + | | | 25 + | --- | --- | 26 + | `Name` and `Flags` | [the naming convention](#names-and-flags) — pure functions over bytes, no I/O and no allocation | 27 + | `Maildir` | [one mailbox](#one-maildir): delivery, iteration, flags, and the `tmp`/`new`/`cur` dance that makes it safe | 28 + | `Store` | [a Maildir++ tree](#maildir-folders): folders, and [the quota ledger](#quota) covering all of them | 29 + 30 + The message content is [zig-mime][zig-mime]'s: this library files messages and 31 + does not read them, and `Message.parse` hands the bytes straight over. The 32 + [API documentation](https://jeff.jcollie.page/zig-maildir/) is generated from 33 + the doc comments, which carry most of the explanation of why a maildir is the 34 + shape it is. 35 + 36 + [zig-mime]: https://git.jcollie.dev/jeff/zig-mime 37 + 38 + ## Where this lives 39 + 40 + The repository lives on the Forgejo instance at 41 + <https://git.jcollie.dev/jeff/zig-maildir>, which is where the continuous 42 + integration and the published documentation are: 43 + 44 + ```console 45 + $ git clone https://git.jcollie.dev/jeff/zig-maildir.git 46 + ``` 47 + 48 + ## Adding it to a project 49 + 50 + ```console 51 + $ zig fetch --save git+https://git.jcollie.dev/jeff/zig-maildir.git 52 + ``` 53 + 54 + ```zig 55 + const maildir = b.dependency("zig_maildir", .{ .target = target }).module("maildir"); 56 + exe.root_module.addImport("maildir", maildir); 57 + ``` 58 + 59 + ## Why a maildir is shaped like this 60 + 61 + A maildir is a directory holding three others — `tmp`, `new` and `cur` — and 62 + one idea: **a message is a file whose name carries everything mutable about 63 + it.** Everything else follows from that. 64 + 65 + * **Delivery takes no lock.** A message is written into `tmp` under a name 66 + nobody else will invent, and then renamed into `new`. A reader never sees a 67 + partial message, because a message only appears in `new` once it is whole and 68 + `rename` within a filesystem is atomic. Two mail servers, an IMAP daemon and 69 + a `procmail` can deliver at the same moment, over NFS, with nothing 70 + arbitrating between them. 71 + * **Reading is a directory listing.** There is no index to corrupt, no lock to 72 + hold while a slow client reads its mail, and no way for one crashed process 73 + to leave the mailbox unusable. 74 + * **Changing a flag is a `rename`.** Nothing is rewritten, so a message file is 75 + written exactly once and never modified — which is what lets the size 76 + recorded in its name be trusted, and what makes a maildir safe to back up 77 + while it is in use. 78 + 79 + The cost is that `tmp` accumulates the wreckage of interrupted deliveries, 80 + which is why `cleanTemp` exists and why the specification says to delete 81 + anything in there older than thirty-six hours. 82 + 83 + ## One maildir 84 + 85 + ```zig 86 + const maildir = @import("maildir"); 87 + 88 + var mailbox: maildir.Maildir = try .create(.cwd(), io, "Maildir", .{}); 89 + defer mailbox.close(io); 90 + 91 + // Delivery. The message lands in `new` with no flags, which is what "new" 92 + // means, and its name records how big it is. 93 + const delivered = try mailbox.deliver(io, bytes, .{}); 94 + 95 + // Reading. An iterator yields a `Message`, which is a name and nothing else 96 + // until you ask it for something. 97 + var it = mailbox.iterate(.new); 98 + while (try it.next(io)) |message| { 99 + var m = message; 100 + std.debug.print("{s} {d} bytes\n", .{ m.id(), try m.size(&mailbox, io) }); 101 + try m.setFlags(&mailbox, io, .{ .seen = true }); 102 + } 103 + ``` 104 + 105 + `setFlags` moves the message from `new` into `cur`, and that is not a 106 + convenience: a name in `new` has no info field, so there is nowhere in `new` 107 + for a flag to be written. "Read but still new" is not a thing a maildir can 108 + express. 109 + 110 + Delivery comes in two shapes. `deliver` takes a slice; `beginDelivery` gives 111 + back a writer, which is what to use when the message is arriving from a socket 112 + or being written by something else: 113 + 114 + ```zig 115 + var buffer: [4096]u8 = undefined; 116 + var delivery = try mailbox.beginDelivery(io, 10); 117 + errdefer delivery.abort(io); 118 + try built_message.write(delivery.writer(io, &buffer)); // zig-mime writes 119 + const delivered = try delivery.commit(io, .{}); 120 + ``` 121 + 122 + Either way the delivery is all or nothing: if anything fails before the rename, 123 + the partial file in `tmp` is removed and nothing ever appears in `new`. 124 + 125 + `deliver` also takes a destination, because not every message is new. An IMAP 126 + `APPEND` of an already-read message, or a client saving a draft, goes straight 127 + into `cur` with its flags on: 128 + 129 + ```zig 130 + _ = try mailbox.deliver(io, bytes, .{ .to = .{ .cur = .{ .seen = true, .draft = true } } }); 131 + ``` 132 + 133 + ## Names and flags 134 + 135 + ```zig 136 + const name: maildir.Name = .parse("1757700000.M1R2Q3.host,S=4211:2,RS", ':'); 137 + name.base(); // "1757700000.M1R2Q3.host" -- the identity 138 + name.size(); // 4211, without touching the filesystem 139 + name.flags().seen; // true 140 + ``` 141 + 142 + Three things about the name are worth knowing before writing anything that 143 + touches one. 144 + 145 + **The identity is `base`, and it must never change.** Two programs sharing a 146 + maildir agree about which message is which by that string and nothing else, so 147 + changing it loses every IMAP UID, every read/unread record and every 148 + synchronisation state keyed to it. `setFlags` and `addFlags` rewrite the info 149 + field and leave it alone; `moveTo` deliberately does not, and the section below 150 + says why. 151 + 152 + **Flags are a set of letters written in ASCII order**, and six of them are 153 + defined: `D` draft, `F` flagged, `P` passed, `R` replied, `S` seen, `T` 154 + trashed. Those get named fields. Every other letter is kept in `other` — 155 + 156 + ```zig 157 + var flags = try maildir.Flags.parse("Sb"); // Dovecot wrote this 158 + flags.replied = true; 159 + // "RSb": the keyword `b` is still there. 160 + ``` 161 + 162 + — and keeping it is the whole point. Dovecot stores IMAP keywords, which are 163 + arbitrary user-applied labels, as the letters `a` to `z` with a 164 + `dovecot-keywords` file mapping them to names. A library that read `:2,Sb`, 165 + marked the message replied and wrote back `:2,RS` would have silently deleted a 166 + label the user applied. Reading flags, changing one and writing them back is 167 + the most common thing anybody does to a maildir, and it has to be lossless. 168 + 169 + **The separator is a parameter.** A colon is what the specification says and 170 + what every Unix mail program expects, and it is also illegal in a FAT, exFAT or 171 + NTFS filename — so a maildir on a memory stick or a Windows share is written 172 + with `!` or `;` instead, and a reader that insists on a colon sees every 173 + message in it as new and flagless. 174 + 175 + ```zig 176 + var mailbox: maildir.Maildir = try .open(.cwd(), io, "Maildir", .{ .separator = '!' }); 177 + ``` 178 + 179 + ## Maildir++ folders 180 + 181 + A maildir has no room for a second mailbox in it, so Maildir++ puts the folders 182 + *beside* the messages, as hidden directories in the top-level maildir, each a 183 + complete maildir of its own: 184 + 185 + ```text 186 + Maildir/ the top-level maildir, which IMAP calls INBOX 187 + tmp/ new/ cur/ its own messages 188 + maildirsize the quota ledger, covering everything below 189 + .Work/ the folder "Work" 190 + tmp/ new/ cur/ 191 + maildirfolder the marker that says this is a folder 192 + .Work.Reports/ the folder "Work/Reports" 193 + ``` 194 + 195 + The hierarchy is **flat on disk and nested in the name**: `.Work.Reports` is a 196 + sibling of `.Work`, not a child, which is what lets a whole folder tree be 197 + listed with one `readdir`. 198 + 199 + ```zig 200 + var store: maildir.Store = try .create(.cwd(), io, "Maildir", .{}); 201 + defer store.close(io); 202 + 203 + var inbox = try store.inbox(io); 204 + defer inbox.close(io); 205 + 206 + var reports = try store.createFolder(io, &.{ "Work", "Reports" }); // and ".Work" 207 + defer reports.close(io); 208 + 209 + var names = try store.folders(gpa, io); // ".Work", ".Work.Reports", sorted 210 + defer names.deinit(gpa); 211 + ``` 212 + 213 + A folder name **cannot contain a dot**. The dot is the hierarchy delimiter and 214 + Maildir++ never defined an escape for one, so a mailbox called `example.com` is 215 + either the folder `com` inside the folder `example` or it is not representable. 216 + This library returns `error.InvalidComponent` rather than silently creating the 217 + wrong thing. 218 + 219 + Moving a message between folders changes its name, and has to: 220 + 221 + ```zig 222 + try message.moveTo(&inbox, &archive, io); 223 + ``` 224 + 225 + Two maildirs are two directories and nothing coordinates the names in them, so 226 + a message carrying its name into a folder that already had one like it would 227 + overwrite a message — and `rename` would do it silently. The flags travel; the 228 + identity does not. This is what IMAP's `MOVE` does, and it is why an IMAP 229 + server cannot promise a moved message keeps its UID. 230 + 231 + A keyword, though, travels only half way, and the NixOS test pins it down. The 232 + letter survives the move because it is part of the flags; what the letter 233 + *means* does not, because that is recorded in a `dovecot-keywords` file inside 234 + each mailbox, so a message labelled "Important" in the inbox arrives in the 235 + archive carrying a letter that mailbox has never assigned. Dovecot's own `MOVE` 236 + updates the destination's mapping. Nothing outside Dovecot can, because the 237 + mapping is Dovecot's rather than the maildir's — the six standard flags are the 238 + only ones that mean the same thing in every directory. 239 + 240 + ## Quota 241 + 242 + Totalling a mail store means listing every folder and adding up every message, 243 + which is fine once and ruinous on every delivery. Courier's answer is 244 + `maildirsize`, a small file in the top-level maildir written like a ledger 245 + rather than a balance: 246 + 247 + ```text 248 + 10485760S,1000C 249 + 4211 1 250 + 8320 1 251 + -4211 -1 252 + ``` 253 + 254 + The first line is the quota and every line after it is a *change*, appended by 255 + whoever made it. The usage is their sum. 256 + 257 + ```zig 258 + try store.setQuota(io, .{ .bytes = 10 * 1024 * 1024, .messages = 1000 }, .zero); 259 + try store.recordUsage(io, .{ .bytes = @intCast(bytes.len), .messages = 1 }); 260 + 261 + const state = (try store.quotaState(gpa, io)).?; 262 + if (state.exceeded()) return error.OverQuota; 263 + if (state.isStale(io, .{})) _ = try store.recalculateQuota(gpa, io); 264 + ``` 265 + 266 + Treat a number from there as "what the store believed last time somebody 267 + checked". The ledger is **advisory and self-healing rather than 268 + authoritative**: it drifts, because a message deleted by something that does 269 + not know about the file is never subtracted, and it is meant to — `isStale` 270 + eventually says so and `recalculateQuota` walks the store and writes a fresh 271 + total. Note that `isStale` only applies its age test when the store is *over* 272 + quota, which is deliberate: being wrongly under quota costs a little 273 + unfairness, while being wrongly over it bounces mail, so the expensive check is 274 + spent only on the answer that would refuse a delivery. 275 + 276 + `recalculateQuota` counts a message from the `,S=` in its name where there is 277 + one, so keeping `record_size` on at delivery is what makes a recalculation a 278 + directory walk rather than a `stat` of every message in the store. 279 + 280 + ## Reading the message 281 + 282 + ```zig 283 + var parsed = try message.parse(&mailbox, gpa, io, .unlimited, .{}); 284 + defer parsed.deinit(); 285 + 286 + std.debug.print("{s}\n", .{(try parsed.root.subject()) orelse "(no subject)"}); 287 + ``` 288 + 289 + That is [zig-mime][zig-mime] from there on: RFC 5322 and the MIME documents, 290 + addresses, dates, encoded words, the whole tree of parts. It parses from a 291 + slice, so the message is read into memory first and the `mime.Message` owns 292 + that copy. 293 + 294 + The division of labour is the point. A maildir is a naming convention over a 295 + directory and has no opinion about what is in the files; a message parser has 296 + no opinion about where the message came from. Keeping them apart is what lets 297 + this library deliver a message it cannot parse, which is exactly what a mail 298 + store must do — a message that has already arrived has to be filed whatever is 299 + in its headers. 300 + 301 + ## The command-line tool 302 + 303 + ```console 304 + $ zig-maildir create Maildir # a maildir, or a whole store 305 + $ zig-maildir deliver Maildir message.eml # `-` reads standard input 306 + $ zig-maildir list Maildir # every message, with its flags 307 + $ zig-maildir headers Maildir <id> # decoded, through zig-mime 308 + $ zig-maildir flag Maildir <id> +S -F 309 + $ zig-maildir move Maildir <id> Archive 310 + $ zig-maildir folders Maildir 311 + $ zig-maildir mkfolder Maildir Work/Reports 312 + $ zig-maildir quota Maildir 10485760 1000 313 + $ zig-maildir recalc Maildir 314 + $ zig-maildir clean Maildir # old wreckage out of tmp 315 + ``` 316 + 317 + It exists to show what the library looks like from outside, and to give the 318 + NixOS test a second program to point at a maildir Dovecot is also looking at. 319 + 320 + ## What is deliberately not here 321 + 322 + - **No message parsing.** That is [zig-mime][zig-mime], and `Message.parse` is 323 + the whole of the connection between them. 324 + - **No IMAP, POP or SMTP.** This library is about the store, not about serving 325 + it or filling it. It does provide the pieces an IMAP server needs — stable 326 + identities, keyword-preserving flags, Maildir++ folders, quota — but the 327 + protocol is somebody else's. 328 + - **No subscriptions.** Which folders a client has subscribed to is IMAP's 329 + business and every server keeps it differently: Courier in 330 + `courierimapsubscribed`, Dovecot in `subscriptions`. A file with either name 331 + is left alone rather than guessed at. 332 + - **No index, and no caching of one.** Every listing is a `readdir`. That is 333 + what a maildir is, and a program that needs an index over one should keep it 334 + itself, keyed by `Message.id`. 335 + - **No locking, anywhere except the quota ledger.** The design does not need 336 + any, which is its whole appeal. `quota.record` takes an advisory lock because 337 + Zig 0.16 cannot open a file `O_APPEND` and the position therefore has to be 338 + read before it is written; that is the one race the original design closed 339 + with a flag this library cannot ask for. 340 + 341 + ## Interoperability 342 + 343 + `nix flake check` boots a NixOS guest with a real Dovecot serving a maildir 344 + this library wrote, and drives both. It asserts what would otherwise be a 345 + comfortable assumption: 346 + 347 + - Dovecot finds and indexes a message `zig-maildir` delivered, its 348 + `RFC822.SIZE` is the number in the name, and it keeps that `,S=` field when 349 + it renames the file itself. 350 + - **Dovecot leaves a flagged message in `new`**, renaming it in place to 351 + something like `new/…,S=121:2,S`, which the specification says should only 352 + ever happen in `cur`. A reader that took the rule literally would show that 353 + message as unread forever, which is why `Name.parse` cannot fail. 354 + - A flag set by either is seen by the other, in both directions. 355 + - **An IMAP keyword Dovecot applied survives this library changing a flag**, 356 + which is the one that would silently lose a user's labels. 357 + - A folder made by either is listed by the other, and is flat on disk with a 358 + `maildirfolder` in it. 359 + - A message moved between folders keeps its standard flags and gets a new name 360 + — and its keyword arrives as a letter the destination mailbox cannot name, 361 + which is a limit of the format rather than of this library. 362 + 363 + It needs KVM, so it runs on the ephemeral runner tiers rather than the 364 + always-on one. 365 + 366 + ## Fuzzing 367 + 368 + `tests/fuzz.zig` holds four targets, each a property rather than an example: 369 + whatever arrives, the parser terminates, stays inside its buffers, and — if it 370 + claims to have understood the input — writing it back out and reading it again 371 + gives the same answer. 372 + 373 + ```console 374 + $ zig build test # the properties, over the checked-in seeds 375 + $ zig build fuzz-run # a minute of each, with generated input 376 + $ zig build fuzz-run -- --seconds 300 --target name 377 + $ zig build fuzz-run -- --input fuzz-findings/x.bin --target name 378 + ``` 379 + 380 + Stability is the property that earns its keep, and it is a stronger claim than 381 + it sounds. A maildir library rewrites names constantly, since every flag change 382 + is a parse, a change and a write. If that round trip is not a fixed point — if 383 + writing a parsed name can produce something that parses differently — then a 384 + message's name drifts a little every time anybody touches it, and since the 385 + name *is* the message's identity, the message eventually becomes a different 386 + message. So the name target asks for the second write to equal the first rather 387 + than for the output to equal the input: a name may legitimately be repaired on 388 + the way in (unsorted flags get sorted, a duplicate letter is dropped), but 389 + repairing it twice must change nothing, and the repair must never touch `base`. 390 + 391 + `tools/fuzz.zig` is that loop and says at the top why it exists: Zig 0.16.0 392 + cannot build a test executable in fuzz mode without a patched standard library 393 + — `flake.nix` carries the patch — and leaves the fuzzer's coverage table empty 394 + even then. A fuzzer with no coverage is a random number generator, so this is 395 + one written down honestly, with a corpus of real names to mutate instead. 396 + 397 + ## The API documentation 398 + 399 + <https://jeff.jcollie.page/zig-maildir/>, rebuilt from `main` on every push. 400 + Zig writes it out of the doc comments, which in this project carry most of the 401 + explanation — why delivery needs no lock, why a flag change is a rename, what 402 + `,S=` is for and why it may be trusted. 403 + 404 + ```console 405 + $ zig build docs-serve # then open http://127.0.0.1:8000/ 406 + $ zig build docs # or just build it, into zig-out/docs 407 + ``` 408 + 409 + It has to be served rather than opened. What Zig emits is not a page but a 410 + program: a WebAssembly viewer that fetches the source of everything it shows 411 + out of a tar file beside it, and a browser refuses to fetch anything from a 412 + `file://` page. That is the same reason `zig std` runs a server rather than 413 + opening a file. `tools/docs_server.zig` is that server — one directory, one 414 + person, the loopback interface, and nothing else. 415 + 416 + ## Licence 417 + 418 + MIT. See `LICENSES/MIT.txt`.
+12
REUSE.toml
··· 1 + version = 1 2 + SPDX-PackageName = "zig-maildir" 3 + SPDX-PackageSupplier = "Jeffrey C. Ollie <jeff@ocjtech.us>" 4 + SPDX-PackageDownloadLocation = "https://git.jcollie.dev/jeff/zig-maildir" 5 + 6 + # Generated or machine-managed, so a header added by hand would be lost the 7 + # next time a dependency moved. 8 + [[annotations]] 9 + path = ["flake.lock", "build.zig.zon", "build.zig.zon.nix"] 10 + precedence = "aggregate" 11 + SPDX-FileCopyrightText = "© 2026 Jeffrey C. Ollie <jeff@ocjtech.us>" 12 + SPDX-License-Identifier = "MIT"
+173
build.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + const std = @import("std"); 5 + 6 + pub fn build(b: *std.Build) void { 7 + const target = b.standardTargetOptions(.{}); 8 + const optimize = b.standardOptimizeOption(.{}); 9 + 10 + // Email messages, which this library reads and writes but deliberately 11 + // does not parse: <https://git.jcollie.dev/jeff/zig-mime>. A maildir is a 12 + // naming convention over a directory, and what it stores is somebody 13 + // else's subject. 14 + const mime = b.dependency("mime", .{ 15 + .target = target, 16 + .optimize = optimize, 17 + }); 18 + 19 + const mod = b.addModule("maildir", .{ 20 + .root_source_file = b.path("src/root.zig"), 21 + .target = target, 22 + .imports = &.{.{ .name = "mime", .module = mime.module("mime") }}, 23 + }); 24 + 25 + // The command-line tool, which exists to show what the library looks like 26 + // from outside and to give the NixOS tests a second program to point at a 27 + // maildir Dovecot is also looking at. 28 + const exe = b.addExecutable(.{ 29 + .name = "zig-maildir", 30 + .root_module = b.createModule(.{ 31 + .root_source_file = b.path("src/main.zig"), 32 + .target = target, 33 + .optimize = optimize, 34 + .imports = &.{ 35 + .{ .name = "maildir", .module = mod }, 36 + .{ .name = "mime", .module = mime.module("mime") }, 37 + }, 38 + }), 39 + }); 40 + b.installArtifact(exe); 41 + 42 + const run_cmd = b.addRunArtifact(exe); 43 + run_cmd.step.dependOn(b.getInstallStep()); 44 + run_cmd.stdio = .inherit; 45 + if (b.args) |args| run_cmd.addArgs(args); 46 + const run_step = b.step("run", "Run the command-line tool"); 47 + run_step.dependOn(&run_cmd.step); 48 + 49 + // A test executable covers one module, so each needs its own. Missing one 50 + // out would not fail: its tests would simply never run. 51 + const test_step = b.step("test", "Run tests"); 52 + for ([_]*std.Build.Module{ mod, exe.root_module }) |m| { 53 + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = m })).step); 54 + } 55 + 56 + // What the library does to a real directory: delivery, flags, folders and 57 + // the quota ledger, all against a temporary maildir. A module of its own 58 + // so that `zig build test` runs them and a consumer never compiles them. 59 + const e2e_mod = b.createModule(.{ 60 + .root_source_file = b.path("tests/e2e.zig"), 61 + .target = target, 62 + .optimize = optimize, 63 + .imports = &.{ 64 + .{ .name = "maildir", .module = mod }, 65 + .{ .name = "mime", .module = mime.module("mime") }, 66 + }, 67 + }); 68 + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = e2e_mod })).step); 69 + 70 + // The fuzz targets: what the name, flag, folder and quota parsers must do 71 + // with input nobody wrote. They are ordinary tests as well as fuzz 72 + // targets, so `zig build test` exercises the same properties on the seeds 73 + // checked in beside them. 74 + const fuzz_mod = b.createModule(.{ 75 + .root_source_file = b.path("tests/fuzz.zig"), 76 + .target = target, 77 + .optimize = optimize, 78 + .imports = &.{.{ .name = "maildir", .module = mod }}, 79 + }); 80 + // Zig's fuzzer takes one test at a time and keeps a coverage file per 81 + // test, so naming a target is what you want when a finding is being 82 + // chased: `zig build fuzz --fuzz -Dfuzz-filter=name`. 83 + const fuzz_filter = b.option( 84 + []const u8, 85 + "fuzz-filter", 86 + "Fuzz or test only the targets whose name contains this", 87 + ); 88 + const fuzz_tests = b.addTest(.{ 89 + .root_module = fuzz_mod, 90 + .filters = if (fuzz_filter) |f| &.{f} else &.{}, 91 + }); 92 + test_step.dependOn(&b.addRunArtifact(fuzz_tests).step); 93 + 94 + // A step of its own for `zig build fuzz --fuzz`, which needs a run step 95 + // holding nothing else: the fuzzer takes over the terminal and runs until 96 + // it is stopped, so it must not be reached by `zig build test`. 97 + const fuzz_step = b.step("fuzz", "The fuzz targets: add --fuzz to fuzz them"); 98 + fuzz_step.dependOn(&b.addRunArtifact(fuzz_tests).step); 99 + 100 + // The loop that drives those same targets without Zig's fuzzer, which 101 + // this toolchain cannot usefully run: `tools/fuzz.zig` says why, and the 102 + // short version is that the coverage table comes back empty. Optimised, 103 + // because a fuzzer's whole job is how many inputs it gets through, and 104 + // ReleaseSafe keeps every check that makes a failure a failure. 105 + const fuzz_run = b.addExecutable(.{ 106 + .name = "zig-maildir-fuzz", 107 + .root_module = b.createModule(.{ 108 + .root_source_file = b.path("tools/fuzz.zig"), 109 + .target = b.graph.host, 110 + .optimize = .ReleaseSafe, 111 + .imports = &.{.{ .name = "fuzz_targets", .module = fuzz_mod }}, 112 + }), 113 + }); 114 + const run_fuzz = b.addRunArtifact(fuzz_run); 115 + run_fuzz.stdio = .inherit; 116 + if (b.args) |a| run_fuzz.addArgs(a); 117 + const fuzz_run_step = b.step("fuzz-run", "Fuzz the targets with a loop of our own"); 118 + fuzz_run_step.dependOn(&run_fuzz.step); 119 + 120 + // Nothing else builds the fuzz driver, so without this it could stop 121 + // compiling and `zig build test` would not notice. 122 + const check_step = b.step("check", "Compile everything without running it"); 123 + check_step.dependOn(&fuzz_run.step); 124 + 125 + // -- documentation ------------------------------------------------------- 126 + // 127 + // Zig emits the API documentation as a side effect of compiling, so the 128 + // module is built as a library purely to get at it. What comes out is not 129 + // a page but a program: a WebAssembly viewer, its javascript, and a tar of 130 + // the sources it reads from. 131 + const library = b.addLibrary(.{ .name = "maildir", .root_module = mod }); 132 + const install_docs = b.addInstallDirectory(.{ 133 + .source_dir = library.getEmittedDocs(), 134 + .install_dir = .prefix, 135 + .install_subdir = "docs", 136 + }); 137 + const docs_step = b.step("docs", "Build the API documentation into zig-out/docs"); 138 + docs_step.dependOn(&install_docs.step); 139 + 140 + // That viewer fetches `sources.tar` and `main.wasm` at runtime, which a 141 + // browser refuses to do from a `file://` page, so reading the docs 142 + // locally means serving them. It is the same reason `zig std` runs a 143 + // server rather than opening a file. 144 + const docs_port = b.option(u16, "docs-port", "Port for `zig build docs-serve` (default 8000)") orelse 8000; 145 + 146 + const docs_server = b.addExecutable(.{ 147 + .name = "docs-server", 148 + .root_module = b.createModule(.{ 149 + .root_source_file = b.path("tools/docs_server.zig"), 150 + // Always built for the machine running the build, never for 151 + // whatever -Dtarget the library is being built for. 152 + .target = b.graph.host, 153 + .optimize = .Debug, 154 + }), 155 + }); 156 + 157 + const run_docs_server = b.addRunArtifact(docs_server); 158 + run_docs_server.step.dependOn(&install_docs.step); 159 + run_docs_server.addArg(b.getInstallPath(.prefix, "docs")); 160 + run_docs_server.addArg(b.fmt("{d}", .{docs_port})); 161 + // The server runs until interrupted, so its output has to reach the 162 + // terminal rather than being captured by the build runner. 163 + run_docs_server.stdio = .inherit; 164 + 165 + const docs_serve_step = b.step("docs-serve", "Serve the API documentation over HTTP"); 166 + docs_serve_step.dependOn(&run_docs_server.step); 167 + 168 + // The server has tests of its own; without this they would never run. 169 + test_step.dependOn(&b.addRunArtifact( 170 + b.addTest(.{ .root_module = docs_server.root_module }), 171 + ).step); 172 + check_step.dependOn(&docs_server.step); 173 + }
+23
build.zig.zon
··· 1 + .{ 2 + .name = .zig_maildir, 3 + .version = "0.0.0", 4 + .fingerprint = 0x670d9679f9517e0b, // Changing this has security and trust implications. 5 + .minimum_zig_version = "0.16.0", 6 + .dependencies = .{ 7 + .mime = .{ 8 + .url = "git+https://git.jcollie.dev/jeff/zig-mime.git#efdb9e8a7fd4a2d9c8318c663771832289259e89", 9 + .hash = "zig_mime-0.0.0-4saPF8Z6BQB9wid4v2A2LrT_OyhQYZUwQDx7aCdpm0Hc", 10 + }, 11 + }, 12 + .paths = .{ 13 + "build.zig", 14 + "build.zig.zon", 15 + "build.zig.zon.nix", 16 + "src", 17 + "tests", 18 + "tools", 19 + "LICENSES", 20 + "REUSE.toml", 21 + "README.md", 22 + }, 23 + }
+242
build.zig.zon.nix
··· 1 + # generated by zon2nix (https://github.com/jcollie/zon2nix) 2 + { 3 + lib, 4 + linkFarm, 5 + fetchzip, 6 + fetchurl, 7 + fetchgit, 8 + runCommandLocal, 9 + zig_0_16, 10 + zstd, 11 + name ? "zig-packages", 12 + }: 13 + let 14 + unpackZigArtifact = 15 + { 16 + name, 17 + artifact, 18 + }: 19 + runCommandLocal name 20 + { 21 + nativeBuildInputs = [ zig_0_16 ]; 22 + } 23 + '' 24 + # workaround https://codeberg.org/ziglang/zig/issues/31866 25 + # https://github.com/Cloudef/zig2nix/issues/54 26 + mkdir "$TMPDIR/src" "$TMPDIR/cache" "$TMPDIR/cache/tmp" 27 + touch "$TMPDIR/src/build.zig" 28 + hash="$(cd "$TMPDIR/src" && zig fetch --global-cache-dir "$TMPDIR/cache" ${artifact})" 29 + mkdir "$out" 30 + tar zxvf "$TMPDIR/cache/p/$hash.tar.gz" --directory "$out/" --strip-components=1 31 + ''; 32 + 33 + fetchZig = 34 + { 35 + name, 36 + url, 37 + hash, 38 + unpack, 39 + }: 40 + let 41 + artifact = 42 + if unpack then 43 + fetchzip { 44 + inherit url hash; 45 + nativeBuildInputs = [ zstd ]; 46 + } 47 + else 48 + fetchurl { inherit url hash; }; 49 + in 50 + unpackZigArtifact { inherit name artifact; }; 51 + 52 + fetchGitZig = 53 + { 54 + name, 55 + url, 56 + hash, 57 + }: 58 + let 59 + parts = lib.splitString "#" url; 60 + url_base = builtins.elemAt parts 0; 61 + url_without_query = builtins.elemAt (lib.splitString "?" url_base) 0; 62 + rev_base = builtins.elemAt parts 1; 63 + rev = 64 + if builtins.match "^[a-fA-F0-9]{40}$" rev_base != null then rev_base else "refs/heads/${rev_base}"; 65 + in 66 + fetchgit { 67 + inherit name rev hash; 68 + url = url_without_query; 69 + deepClone = false; 70 + fetchSubmodules = false; 71 + }; 72 + 73 + fetchZigArtifact = 74 + { 75 + name, 76 + url, 77 + hash, 78 + unpack, 79 + }: 80 + let 81 + parts = lib.splitString "://" url; 82 + proto = builtins.elemAt parts 0; 83 + path = builtins.elemAt parts 1; 84 + fetcher = { 85 + "git+http" = fetchGitZig { 86 + inherit name hash; 87 + url = "http://${path}"; 88 + }; 89 + "git+https" = fetchGitZig { 90 + inherit name hash; 91 + url = "https://${path}"; 92 + }; 93 + http = fetchZig { 94 + inherit name hash unpack; 95 + url = "http://${path}"; 96 + }; 97 + https = fetchZig { 98 + inherit name hash unpack; 99 + url = "https://${path}"; 100 + }; 101 + }; 102 + in 103 + fetcher.${proto}; 104 + # The packages, as real directories holding symlinked files, rather than as 105 + # a farm of symlinked directories. 106 + # 107 + # Zig runs a dependency's own build steps with the working directory set to 108 + # that dependency, and points at the program to run with a path counted in 109 + # directories up from there. Through a symlink the two disagree: Zig counts 110 + # from `<farm>/<package>/`, four directories below the root, while the kernel 111 + # resolves the working directory to `/nix/store/<hash>`, which is three, so 112 + # the path lands one short of where the program is. 113 + # 114 + # It works anyway when the build directory is `/build`, because the sum then 115 + # overshoots into the root and going above the root stays there. It fails 116 + # when the build directory is under `/nix/var/nix/builds`, which is where Nix 117 + # puts it when the sandbox is off. Real directories make the two depths 118 + # agree, so it works either way. 119 + # 120 + # The files have to be real as well, which is the expensive part and cannot 121 + # be avoided. `--symbolic-link` would leave them pointing into each 122 + # dependency's own store path, so that the farm is a few megabytes rather 123 + # than a second copy of every dependency -- but Zig's 124 + # `installHeadersDirectory` walks the directory and copies only the entries 125 + # whose kind is `.file`. Symlinked headers are skipped without a word, and 126 + # the first thing to include one fails with `'dcimgui.h' not found`. 127 + # 128 + # `--link` is not the way out. A hard link into a Nix output is a file the 129 + # builder did not create: inside the Linux sandbox the store is a separate 130 + # mount and `link` fails with `Invalid cross-device link`, while on Darwin it 131 + # succeeds and leaves root-owned files in the output, which Nix refuses while 132 + # canonicalising with `invalid ownership on file`. 133 + # 134 + # So it is a real copy, with `--reflink=auto` to share the blocks on a 135 + # filesystem that can. `nix store optimise` recovers the duplication after 136 + # the fact, hard-linking identical files across the store, which is the 137 + # store's own business to do and not a build's. 138 + copyFarm = 139 + farm: entries: pathDependencyPackages: 140 + runCommandLocal farm 141 + { 142 + # The packages whose own manifest declares a dependency by `.path`. 143 + # Zig 0.16.0 cannot build these through `zig build --system`: it spins 144 + # in userspace forever, because a `.path` dependency's hash is computed 145 + # against the system package directory during the fetch and against the 146 + # real global cache afterwards, and in that mode the two disagree. A 147 + # package that wants `--system` copies each of these into its build 148 + # root and passes `--fork=`; see the README. 149 + passthru = { inherit pathDependencyPackages; }; 150 + } 151 + '' 152 + mkdir -p "$out" 153 + cp --recursive --reflink=auto --dereference --no-preserve=mode \ 154 + ${linkFarm farm entries}/. "$out/" 155 + ''; 156 + in 157 + copyFarm name 158 + [ 159 + { 160 + name = "N-V-__8AAJ77GgCr4jV_q5d8vuaUZIWMrHbXUMYV7il4sgLB"; 161 + path = fetchZigArtifact { 162 + name = "cldr_core"; 163 + url = "https://registry.npmjs.org/cldr-core/-/cldr-core-48.2.0.tgz"; 164 + hash = "sha256-UxDgx6BsH+uD3I5UyFhLvgucLqgyAXKhjVQZhrEkWF0="; 165 + unpack = false; 166 + }; 167 + } 168 + { 169 + name = "N-V-__8AAGszqAU24FLBIkgdecxizqeniOtXvaJyJNhnerSV"; 170 + path = fetchZigArtifact { 171 + name = "cldr_dates"; 172 + url = "https://registry.npmjs.org/cldr-dates-full/-/cldr-dates-full-48.2.0.tgz"; 173 + hash = "sha256-Albxzv7KFPfVFb5Ict2kj83X51OBQw8lqv0BQ+rltDA="; 174 + unpack = false; 175 + }; 176 + } 177 + { 178 + name = "N-V-__8AAP5iTQJ7vhRS_dLVKhpakujqxJsIbu89VYJgXryk"; 179 + path = fetchZigArtifact { 180 + name = "cldr_numbers"; 181 + url = "https://registry.npmjs.org/cldr-numbers-full/-/cldr-numbers-full-48.2.0.tgz"; 182 + hash = "sha256-LRehRTxVmmIRLK7tUuC8/jy4U5yZ0jmue37U0IJ2edk="; 183 + unpack = false; 184 + }; 185 + } 186 + { 187 + name = "datetime-0.0.1-6-va79gDDwCD7vUCwnd8YeSBQlXh5xq-yjnhzeT7VCYw"; 188 + path = fetchZigArtifact { 189 + name = "datetime"; 190 + url = "git+https://git.jcollie.dev/jeff/zig-datetime.git#bd76e05460dba019f7749e44a37ea5afe6c49609"; 191 + hash = "sha256-hVLUAc9Mm5HjLSkUURlb4nW2sn78ZvrWKsaC1cdzCuE="; 192 + unpack = true; 193 + }; 194 + } 195 + { 196 + name = "zig_mime-0.0.0-4saPF8Z6BQB9wid4v2A2LrT_OyhQYZUwQDx7aCdpm0Hc"; 197 + path = fetchZigArtifact { 198 + name = "mime"; 199 + url = "git+https://git.jcollie.dev/jeff/zig-mime.git#efdb9e8a7fd4a2d9c8318c663771832289259e89"; 200 + hash = "sha256-Q8B/Rdb4GuxEdwhbdrRPeYq5B81ulgijTbujBkclG80="; 201 + unpack = true; 202 + }; 203 + } 204 + { 205 + name = "N-V-__8AAHNhQgCiWfjOCo_LQgx55jnHBr2Z61ZAqPU9n5Uo"; 206 + path = fetchZigArtifact { 207 + name = "moment"; 208 + url = "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz"; 209 + hash = "sha256-UiGan+5eH6reTHJTbBc8VM7dXiYZJy3QwlGjCur83ow="; 210 + unpack = false; 211 + }; 212 + } 213 + { 214 + name = "N-V-__8AABybDwDd46ZHFqBjb0twea7p9vwNzdSzUHFwA55f"; 215 + path = fetchZigArtifact { 216 + name = "tzcode"; 217 + url = "https://data.iana.org/time-zones/releases/tzcode2026d.tar.gz"; 218 + hash = "sha256-L1yff+Kea4y4Y1g2Z4hLjOF7CkhTVaBUtZHGvfzYF5E="; 219 + unpack = false; 220 + }; 221 + } 222 + { 223 + name = "N-V-__8AAFiAFQDNovBNmFwF3hznlSfpY7KwAN3Jy7rhie29"; 224 + path = fetchZigArtifact { 225 + name = "tzdata"; 226 + url = "https://data.iana.org/time-zones/releases/tzdata2026d.tar.gz"; 227 + hash = "sha256-DLKqjjM8PcBJutxCoMYfIZh7jNROEH+pALrXZKrMd2c="; 228 + unpack = false; 229 + }; 230 + } 231 + { 232 + name = "win32-42.0.39-preview-mX5pFS564gPTezZn4v3TMxRnfJUrZNx1B_F2p2HKXOeG"; 233 + path = fetchZigArtifact { 234 + name = "zigwin32"; 235 + url = "git+https://github.com/marlersoft/zigwin32#9f15c276b4e9d05afd34a10d8662a7dfc34647ea"; 236 + hash = "sha256-JCmUrieEnOKQViUGjPNrlGwJMMM4X/BjC3iur5fRbqA="; 237 + unpack = true; 238 + }; 239 + } 240 + ] 241 + [ 242 + ]
+45
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1789149629, 6 + "narHash": "sha256-eQUZVehWLDBbpBR2rq6DnuRee1ULT6Y675RhsYDSjYI=", 7 + "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", 8 + "type": "tarball", 9 + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1072397.eaad089433ca/nixexprs.tar.xz" 10 + }, 11 + "original": { 12 + "type": "tarball", 13 + "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" 14 + } 15 + }, 16 + "root": { 17 + "inputs": { 18 + "nixpkgs": "nixpkgs", 19 + "zon2nix": "zon2nix" 20 + } 21 + }, 22 + "zon2nix": { 23 + "inputs": { 24 + "nixpkgs": [ 25 + "nixpkgs" 26 + ] 27 + }, 28 + "locked": { 29 + "lastModified": 1788977680, 30 + "narHash": "sha256-3vgICcen5N2oAlQrijqJhxn8HEsnXASGPxQLJVYzX0Q=", 31 + "owner": "jcollie", 32 + "repo": "zon2nix", 33 + "rev": "7b9c43312de08e176097b8c8920f0dd1a46982be", 34 + "type": "github" 35 + }, 36 + "original": { 37 + "owner": "jcollie", 38 + "repo": "zon2nix", 39 + "type": "github" 40 + } 41 + } 42 + }, 43 + "root": "root", 44 + "version": 7 45 + }
+171
flake.nix
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + { 5 + description = "zig-maildir"; 6 + 7 + inputs = { 8 + nixpkgs = { 9 + url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; 10 + }; 11 + # Mine, not the one in nixpkgs, which is Jari Vetoniemi's original and 12 + # takes different options. 13 + zon2nix = { 14 + url = "github:jcollie/zon2nix"; 15 + inputs = { 16 + nixpkgs.follows = "nixpkgs"; 17 + }; 18 + }; 19 + }; 20 + 21 + outputs = 22 + { 23 + nixpkgs, 24 + zon2nix, 25 + ... 26 + }: 27 + let 28 + inherit (nixpkgs) lib; 29 + makePackages = 30 + system: 31 + import nixpkgs { 32 + inherit system; 33 + }; 34 + forAllSystems = lib.genAttrs lib.systems.flakeExposed; 35 + # The virtual machine tests are NixOS ones, so they exist only where 36 + # NixOS does. Without this `nix flake check` tries to evaluate them for 37 + # Darwin and fails before it has run anything. 38 + forLinuxSystems = lib.genAttrs ( 39 + lib.filter (system: lib.hasSuffix "-linux" system) lib.systems.flakeExposed 40 + ); 41 + 42 + # The devshell's Zig, with one line of its own standard library put 43 + # right, because without it `zig build fuzz --fuzz` cannot compile. 44 + # 45 + # Zig 0.16.0's `compiler/test_runner.zig` reports a failing fuzz input by 46 + # asking `std.debug.writeStackTrace` to print what `@errorReturnTrace()` 47 + # gave it. Those are two different types: an error return trace is a 48 + # `builtin.StackTrace`, a ring buffer with a write index, and that 49 + # function takes a `debug.StackTrace`, which is a plain slice and a count 50 + # of what was skipped. It is a type error, it is on the path taken only 51 + # under `-ffuzz`, and it stops *any* project with a fuzz test in it from 52 + # building one. The fix is the function next door: `writeErrorReturnTrace` 53 + # takes exactly the type in hand and is what the other three places in 54 + # the same file use. 55 + # 56 + # `--replace-fail` is the whole safety of this: the day Zig ships the fix 57 + # the pattern will not be found, the build will fail here rather than 58 + # patch something else, and this can go. 59 + # 60 + # It buys the fuzzer and not its coverage. Nothing in this release 61 + # populates the table of program counters, so a bounded run ends with 62 + # "corrupted coverage file: pcs_len was zero" and an unbounded one 63 + # panics in the build runner's coverage thread; neither is a finding, 64 + # and a finding says "input saved to" above the report. The properties 65 + # in `tests/fuzz.zig` run as ordinary tests either way, and 66 + # `zig build fuzz-run` drives them with a loop of our own. 67 + fuzzableZig = 68 + pkgs: 69 + let 70 + # A farm of symlinks rather than a copy: the library is 217 MB, and 71 + # exactly one file of it is being changed. 72 + library = pkgs.runCommand "zig-0.16.0-lib-fuzz-fix" { } '' 73 + cp -rs --no-preserve=mode ${pkgs.zig_0_16}/lib/zig $out 74 + chmod -R u+w $out 75 + rm $out/compiler/test_runner.zig 76 + cp --no-preserve=mode \ 77 + ${pkgs.zig_0_16}/lib/zig/compiler/test_runner.zig \ 78 + $out/compiler/test_runner.zig 79 + substituteInPlace $out/compiler/test_runner.zig \ 80 + --replace-fail \ 81 + 'std.debug.writeStackTrace(trace, stderr)' \ 82 + 'std.debug.writeErrorReturnTrace(trace, stderr)' 83 + ''; 84 + in 85 + pkgs.symlinkJoin { 86 + name = "zig-0.16.0-fuzzable"; 87 + paths = [ pkgs.zig_0_16 ]; 88 + nativeBuildInputs = [ pkgs.makeWrapper ]; 89 + postBuild = '' 90 + wrapProgram $out/bin/zig --set ZIG_LIB_DIR ${library} 91 + ''; 92 + }; 93 + in 94 + { 95 + packages = forAllSystems ( 96 + system: 97 + let 98 + pkgs = makePackages system; 99 + in 100 + rec { 101 + zig-maildir = pkgs.callPackage ./package.nix { }; 102 + default = zig-maildir; 103 + # The dependency farm on its own, so that a workflow job which runs 104 + # `zig build` for something other than the package -- the 105 + # documentation -- can be handed it without building the package to 106 + # get at it. 107 + zig-deps = pkgs.callPackage ./build.zig.zon.nix { }; 108 + } 109 + ); 110 + 111 + # The virtual machine test, which is here rather than in `zig build 112 + # test` because what it tests cannot be reached from a test binary: a 113 + # real Dovecot opening the same maildir this library wrote, with its own 114 + # idea of what a valid name and a valid flag are. 115 + checks = forLinuxSystems ( 116 + system: 117 + let 118 + pkgs = makePackages system; 119 + zig-maildir = pkgs.callPackage ./package.nix { }; 120 + test = file: pkgs.testers.runNixOSTest (import file { inherit zig-maildir; }); 121 + in 122 + { 123 + inherit zig-maildir; 124 + dovecot = test ./tests/nixos/dovecot.nix; 125 + } 126 + ); 127 + 128 + devShells = forAllSystems ( 129 + system: 130 + let 131 + pkgs = makePackages system; 132 + in 133 + { 134 + default = pkgs.mkShell { 135 + name = "zig-maildir"; 136 + nativeBuildInputs = [ 137 + (fuzzableZig pkgs) 138 + pkgs.git-pages-cli 139 + pkgs.pinact 140 + pkgs.reuse 141 + 142 + # Wrapped so that the Zig it shells out to for `zig env` is the 143 + # one this project builds with, rather than whatever happens to 144 + # be on the caller's PATH. Without a Zig at all it prints 145 + # "unable to execute zig" and writes nothing, leaving the 146 + # previous build.zig.zon.nix in place looking untouched. 147 + (pkgs.symlinkJoin { 148 + name = "zon2nix"; 149 + paths = [ zon2nix.packages.${system}.zon2nix ]; 150 + nativeBuildInputs = [ pkgs.makeWrapper ]; 151 + postBuild = '' 152 + wrapProgram $out/bin/zon2nix \ 153 + --prefix PATH : ${lib.makeBinPath [ pkgs.zig_0_16 ]} 154 + ''; 155 + }) 156 + ] 157 + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ 158 + # The other implementations to read what this one writes. 159 + # Dovecot is the one whose interpretation of a maildir is the de 160 + # facto standard; isync/mbsync and offlineimap each keep state of 161 + # their own alongside it and are unforgiving about names. 162 + pkgs.dovecot 163 + pkgs.isync 164 + 165 + pkgs.kcov 166 + ]; 167 + }; 168 + } 169 + ); 170 + }; 171 + }
+61
package.nix
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + { 5 + lib, 6 + stdenv, 7 + callPackage, 8 + zig_0_16, 9 + }: 10 + let 11 + # Generated from build.zig.zon by zon2nix; regenerate with 12 + # nix develop -c zon2nix --16 --nix=build.zig.zon.nix build.zig.zon 13 + zigDeps = callPackage ./build.zig.zon.nix { }; 14 + in 15 + stdenv.mkDerivation (finalAttrs: { 16 + pname = "zig-maildir"; 17 + version = "0.0.0"; 18 + 19 + # Named rather than filtered, so that editing something outside this list -- 20 + # the flake, a scratch file, the NixOS tests that consume the result -- does 21 + # not rebuild the package and, in the tests, the whole virtual machine. 22 + src = lib.fileset.toSource { 23 + root = ./.; 24 + fileset = lib.fileset.unions [ 25 + ./build.zig 26 + ./build.zig.zon 27 + ./build.zig.zon.nix 28 + ./src 29 + ./tests 30 + ./tools 31 + ./LICENSES 32 + ./README.md 33 + ./REUSE.toml 34 + ]; 35 + }; 36 + 37 + nativeBuildInputs = [ zig_0_16.hook ]; 38 + 39 + # `--system` does not merely offer the directory, it forbids fetching: a 40 + # dependency missing from it is a build error naming the package rather than 41 + # a silent attempt to reach a network the sandbox does not have. 42 + zigBuildFlags = [ 43 + "--system" 44 + "${zigDeps}" 45 + ]; 46 + # The check phase assembles its own flags rather than reusing the build's, 47 + # so without this `zig build test` runs without --system and tries to fetch. 48 + zigCheckFlags = finalAttrs.zigBuildFlags; 49 + 50 + # The end-to-end tests want a directory to make a maildir in, which the 51 + # build sandbox has; nothing here needs a network or a second process. 52 + doCheck = true; 53 + 54 + meta = { 55 + description = "A maildir and Maildir++ library for Zig"; 56 + homepage = "https://git.jcollie.dev/jeff/zig-maildir"; 57 + license = lib.licenses.mit; 58 + mainProgram = "zig-maildir"; 59 + platforms = lib.platforms.unix; 60 + }; 61 + })
+21
LICENSES/MIT.txt
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Jeffrey C. Ollie 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+363
src/Flags.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! The flags on a message, which in a maildir are part of its file name. 5 + //! 6 + //! A message in `cur` is named `<unique>:2,<flags>`, where `<flags>` is a 7 + //! string of letters. Six of them were defined by the original maildir and 8 + //! mean the same thing everywhere: 9 + //! 10 + //! | | | | 11 + //! | --- | --- | --- | 12 + //! | `D` | `draft` | a message still being composed | 13 + //! | `F` | `flagged` | marked by the user; IMAP's `\Flagged` | 14 + //! | `P` | `passed` | resent, forwarded or bounced onwards | 15 + //! | `R` | `replied` | IMAP's `\Answered` | 16 + //! | `S` | `seen` | IMAP's `\Seen` | 17 + //! | `T` | `trashed` | IMAP's `\Deleted`: to be removed at the next expunge | 18 + //! 19 + //! The letters **must be written in ASCII order**, which is what `format` 20 + //! does, and which is why `other` is a set rather than a string. 21 + //! 22 + //! Everything else that turns up in that position lands in `other`, and the 23 + //! reason it is kept rather than discarded is that dropping it corrupts 24 + //! somebody else's state. Dovecot stores IMAP keywords — arbitrary 25 + //! user-defined labels — as the letters `a` through `z`, mapped to their names 26 + //! by a `dovecot-keywords` file beside the maildir, so a program that reads 27 + //! `:2,Sb`, marks the message replied and writes back `:2,RS` has silently 28 + //! deleted a label the user applied. Reading flags, changing one and writing 29 + //! them back is the single most common thing done to a maildir, and it has to 30 + //! be lossless. 31 + 32 + const std = @import("std"); 33 + const Io = std.Io; 34 + const testing = std.testing; 35 + 36 + const Flags = @This(); 37 + 38 + /// `D`. A message still being composed; IMAP's `\Draft`. 39 + draft: bool = false, 40 + /// `F`. Marked by the user for their own reasons; IMAP's `\Flagged`. 41 + flagged: bool = false, 42 + /// `P`. Resent, forwarded or bounced onwards. IMAP has no equivalent. 43 + passed: bool = false, 44 + /// `R`. Replied to; IMAP's `\Answered`. 45 + replied: bool = false, 46 + /// `S`. Read; IMAP's `\Seen`. 47 + seen: bool = false, 48 + /// `T`. Marked for deletion; IMAP's `\Deleted`. A trashed message is still 49 + /// there — removing it is a separate act, which IMAP calls an expunge. 50 + trashed: bool = false, 51 + /// Every letter that is not one of the six above, kept so that changing a 52 + /// flag does not discard one this library does not know about. `a` through 53 + /// `z` are Dovecot's IMAP keywords; the twenty remaining uppercase letters 54 + /// have no agreed meaning, which is not a reason to throw them away. 55 + /// 56 + /// Setting one of the six here as well as in its own field is harmless — 57 + /// `format` writes each letter once — but `setLetter` routes them to the 58 + /// fields, and that is the way to set a flag whose letter is only known at 59 + /// run time. 60 + other: Letters = .empty, 61 + 62 + /// No flags at all: what a message delivered to `new` has, and what `:2,` 63 + /// with nothing after it means. 64 + pub const none: Flags = .{}; 65 + 66 + /// One of the six flags the maildir defines, named by the letter that stands 67 + /// for it. 68 + pub const Flag = enum(u8) { 69 + draft = 'D', 70 + flagged = 'F', 71 + passed = 'P', 72 + replied = 'R', 73 + seen = 'S', 74 + trashed = 'T', 75 + 76 + /// The letter this flag is written as. 77 + pub fn letter(flag: Flag) u8 { 78 + return @intFromEnum(flag); 79 + } 80 + 81 + /// The flag a letter stands for, or null if it is not one of the six. 82 + pub fn fromLetter(c: u8) ?Flag { 83 + return switch (c) { 84 + 'D' => .draft, 85 + 'F' => .flagged, 86 + 'P' => .passed, 87 + 'R' => .replied, 88 + 'S' => .seen, 89 + 'T' => .trashed, 90 + else => null, 91 + }; 92 + } 93 + }; 94 + 95 + /// A set of ASCII letters, held in the order they must be written in: bit 0 96 + /// is `A`, bit 25 is `Z`, bit 26 is `a`, bit 51 is `z`. Uppercase before 97 + /// lowercase is ASCII order, so iterating the bits upwards produces a valid 98 + /// flag string without a sort. 99 + pub const Letters = struct { 100 + bits: u52 = 0, 101 + 102 + pub const empty: Letters = .{}; 103 + 104 + /// The bit a letter occupies, or null if `c` is not an ASCII letter. 105 + pub fn indexOf(c: u8) ?u6 { 106 + return switch (c) { 107 + 'A'...'Z' => @intCast(c - 'A'), 108 + 'a'...'z' => @intCast(c - 'a' + 26), 109 + else => null, 110 + }; 111 + } 112 + 113 + /// The letter a bit stands for. Asserts the index is in range. 114 + pub fn letterAt(index: u6) u8 { 115 + std.debug.assert(index < 52); 116 + return if (index < 26) 'A' + @as(u8, index) else 'a' + @as(u8, index - 26); 117 + } 118 + 119 + pub fn has(self: Letters, c: u8) bool { 120 + const index = indexOf(c) orelse return false; 121 + return self.bits & (@as(u52, 1) << index) != 0; 122 + } 123 + 124 + /// Adds or removes a letter. Asserts that `c` is an ASCII letter, since 125 + /// nothing else can be a flag. 126 + pub fn set(self: *Letters, c: u8, present: bool) void { 127 + const index = indexOf(c) orelse unreachable; 128 + const bit = @as(u52, 1) << index; 129 + if (present) self.bits |= bit else self.bits &= ~bit; 130 + } 131 + 132 + pub fn unionWith(a: Letters, b: Letters) Letters { 133 + return .{ .bits = a.bits | b.bits }; 134 + } 135 + 136 + pub fn subtract(a: Letters, b: Letters) Letters { 137 + return .{ .bits = a.bits & ~b.bits }; 138 + } 139 + 140 + pub fn count(self: Letters) usize { 141 + return @popCount(self.bits); 142 + } 143 + 144 + pub fn eql(a: Letters, b: Letters) bool { 145 + return a.bits == b.bits; 146 + } 147 + 148 + /// The letters in the set, in the order they must be written. 149 + pub fn iterator(self: Letters) Iterator { 150 + return .{ .remaining = self.bits }; 151 + } 152 + 153 + pub const Iterator = struct { 154 + remaining: u52, 155 + 156 + pub fn next(it: *Iterator) ?u8 { 157 + if (it.remaining == 0) return null; 158 + const index: u6 = @intCast(@ctz(it.remaining)); 159 + it.remaining &= it.remaining - 1; 160 + return letterAt(index); 161 + } 162 + }; 163 + }; 164 + 165 + /// Every letter set, as one `Letters`, so that the six named fields and the 166 + /// `other` set can be reasoned about together. 167 + fn letters(self: Flags) Letters { 168 + var result = self.other; 169 + if (self.draft) result.set('D', true); 170 + if (self.flagged) result.set('F', true); 171 + if (self.passed) result.set('P', true); 172 + if (self.replied) result.set('R', true); 173 + if (self.seen) result.set('S', true); 174 + if (self.trashed) result.set('T', true); 175 + return result; 176 + } 177 + 178 + pub fn has(self: Flags, flag: Flag) bool { 179 + return switch (flag) { 180 + .draft => self.draft, 181 + .flagged => self.flagged, 182 + .passed => self.passed, 183 + .replied => self.replied, 184 + .seen => self.seen, 185 + .trashed => self.trashed, 186 + }; 187 + } 188 + 189 + pub fn set(self: *Flags, flag: Flag, present: bool) void { 190 + switch (flag) { 191 + .draft => self.draft = present, 192 + .flagged => self.flagged = present, 193 + .passed => self.passed = present, 194 + .replied => self.replied = present, 195 + .seen => self.seen = present, 196 + .trashed => self.trashed = present, 197 + } 198 + } 199 + 200 + /// `self` with one flag set, for building a value in an expression: 201 + /// `Flags.none.with(.seen).with(.replied)`. 202 + pub fn with(self: Flags, flag: Flag) Flags { 203 + var result = self; 204 + result.set(flag, true); 205 + return result; 206 + } 207 + 208 + /// `self` with one flag cleared. 209 + pub fn without(self: Flags, flag: Flag) Flags { 210 + var result = self; 211 + result.set(flag, false); 212 + return result; 213 + } 214 + 215 + /// Whether a letter is set, whichever of the six or of `other` it belongs to. 216 + /// This is the way to ask about a Dovecot keyword. 217 + pub fn hasLetter(self: Flags, c: u8) bool { 218 + if (Flag.fromLetter(c)) |flag| return self.has(flag); 219 + return self.other.has(c); 220 + } 221 + 222 + /// Sets or clears a letter, routing the six to their own fields so that 223 + /// `setLetter('S', true)` and `set(.seen, true)` cannot disagree. Asserts 224 + /// that `c` is an ASCII letter. 225 + pub fn setLetter(self: *Flags, c: u8, present: bool) void { 226 + if (Flag.fromLetter(c)) |flag| return self.set(flag, present); 227 + self.other.set(c, present); 228 + } 229 + 230 + /// Everything set in either. 231 + pub fn unionWith(a: Flags, b: Flags) Flags { 232 + return .{ 233 + .draft = a.draft or b.draft, 234 + .flagged = a.flagged or b.flagged, 235 + .passed = a.passed or b.passed, 236 + .replied = a.replied or b.replied, 237 + .seen = a.seen or b.seen, 238 + .trashed = a.trashed or b.trashed, 239 + .other = a.other.unionWith(b.other), 240 + }; 241 + } 242 + 243 + /// Everything set in `a` and not in `b`. 244 + pub fn subtract(a: Flags, b: Flags) Flags { 245 + return .{ 246 + .draft = a.draft and !b.draft, 247 + .flagged = a.flagged and !b.flagged, 248 + .passed = a.passed and !b.passed, 249 + .replied = a.replied and !b.replied, 250 + .seen = a.seen and !b.seen, 251 + .trashed = a.trashed and !b.trashed, 252 + .other = a.other.subtract(b.other), 253 + }; 254 + } 255 + 256 + /// Whether two sets of flags name the same letters. Not `std.meta.eql`, 257 + /// because a flag set in `other` as well as in its own field is the same set 258 + /// of letters as one set only in its field. 259 + pub fn eql(a: Flags, b: Flags) bool { 260 + return a.letters().eql(b.letters()); 261 + } 262 + 263 + pub fn count(self: Flags) usize { 264 + return self.letters().count(); 265 + } 266 + 267 + pub const ParseError = error{ 268 + /// Something that is not an ASCII letter appeared where a flag was 269 + /// expected. The caller has the whole name and can keep it verbatim, 270 + /// which is what `maildir.Name` does. 271 + InvalidFlag, 272 + }; 273 + 274 + /// Reads the letters after `:2,`. 275 + /// 276 + /// The order they arrive in is not checked. Software that writes them 277 + /// unsorted is out there, the set is what the flags mean, and `format` writes 278 + /// a sorted one back — so accepting `SR` and writing `RS` repairs the name 279 + /// rather than rejecting a message. 280 + pub fn parse(text: []const u8) ParseError!Flags { 281 + var result: Flags = .none; 282 + for (text) |c| { 283 + if (Letters.indexOf(c) == null) return error.InvalidFlag; 284 + result.setLetter(c, true); 285 + } 286 + return result; 287 + } 288 + 289 + /// Writes the letters, in ASCII order, with nothing around them: the caller 290 + /// supplies the `:2,`. Writes nothing at all when no flag is set, which is 291 + /// what `:2,` on its own means. 292 + pub fn format(self: Flags, w: *Io.Writer) Io.Writer.Error!void { 293 + var it = self.letters().iterator(); 294 + while (it.next()) |c| try w.writeByte(c); 295 + } 296 + 297 + test "the six, in ASCII order whatever order they were written in" { 298 + const flags = try Flags.parse("TSRPFD"); 299 + try testing.expect(flags.draft and flags.flagged and flags.passed); 300 + try testing.expect(flags.replied and flags.seen and flags.trashed); 301 + try testing.expectFmt("DFPRST", "{f}", .{flags}); 302 + } 303 + 304 + test "no flags is the empty string, not an error" { 305 + try testing.expectEqual(Flags.none, try Flags.parse("")); 306 + try testing.expectFmt("", "{f}", .{Flags.none}); 307 + } 308 + 309 + test "a keyword survives a flag being changed" { 310 + // Dovecot wrote this: seen, plus the keyword it calls `a`. 311 + var flags = try Flags.parse("Sa"); 312 + flags.set(.replied, true); 313 + // `R` sorts before `S`, and `a` after both, because that is ASCII. 314 + try testing.expectFmt("RSa", "{f}", .{flags}); 315 + try testing.expect(flags.hasLetter('a')); 316 + } 317 + 318 + test "an uppercase letter nobody has defined is kept too" { 319 + const flags = try Flags.parse("SZ"); 320 + try testing.expect(flags.seen); 321 + try testing.expect(flags.hasLetter('Z')); 322 + try testing.expectFmt("SZ", "{f}", .{flags}); 323 + } 324 + 325 + test "a flag that is not a letter is refused" { 326 + try testing.expectError(error.InvalidFlag, Flags.parse("S,")); 327 + try testing.expectError(error.InvalidFlag, Flags.parse("2,S")); 328 + try testing.expectError(error.InvalidFlag, Flags.parse("S1")); 329 + } 330 + 331 + test "set operations" { 332 + const a = try Flags.parse("RSa"); 333 + const b = try Flags.parse("Sb"); 334 + try testing.expectFmt("RSab", "{f}", .{a.unionWith(b)}); 335 + try testing.expectFmt("Ra", "{f}", .{a.subtract(b)}); 336 + try testing.expectEqual(@as(usize, 3), a.count()); 337 + } 338 + 339 + test "eql ignores where a letter is recorded" { 340 + var odd: Flags = .none; 341 + odd.other.set('S', true); // deliberately in the wrong place 342 + const tidy: Flags = Flags.none.with(.seen); 343 + try testing.expect(odd.eql(tidy)); 344 + try testing.expectFmt("S", "{f}", .{odd}); 345 + } 346 + 347 + test "with and without build a value in an expression" { 348 + const flags: Flags = Flags.none.with(.seen).with(.replied).without(.seen); 349 + try testing.expectFmt("R", "{f}", .{flags}); 350 + } 351 + 352 + test "every letter round trips" { 353 + var all: Flags = .none; 354 + for ('A'..'Z' + 1) |c| all.setLetter(@intCast(c), true); 355 + for ('a'..'z' + 1) |c| all.setLetter(@intCast(c), true); 356 + try testing.expectEqual(@as(usize, 52), all.count()); 357 + 358 + var buffer: [64]u8 = undefined; 359 + const text = try std.fmt.bufPrint(&buffer, "{f}", .{all}); 360 + try testing.expectEqualStrings("ABCDEFGHIJKLMNOPQRSTUVWXYZ" ++ 361 + "abcdefghijklmnopqrstuvwxyz", text); 362 + try testing.expect(all.eql(try Flags.parse(text))); 363 + }
+534
src/Maildir.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! One maildir: a directory holding `tmp`, `new` and `cur`. 5 + //! 6 + //! The design is thirty years old and has one idea in it, which is that a 7 + //! message is a file whose *name* carries everything mutable about it. From 8 + //! that one idea everything else follows: 9 + //! 10 + //! * **Delivery takes no lock.** A message is written into `tmp` under a name 11 + //! nobody else will invent, and then renamed into `new`. A reader never 12 + //! sees a partial message because a message only appears in `new` once it 13 + //! is whole, and `rename` within a filesystem is atomic. Two mail servers, 14 + //! an IMAP daemon and a `procmail` can all deliver at once, over NFS, with 15 + //! nothing arbitrating between them. 16 + //! * **Reading is a directory listing.** There is no index to corrupt, no 17 + //! lock to hold while a slow client reads its mail, and no way for one 18 + //! crashed process to leave the mailbox unusable. 19 + //! * **Changing a flag is a `rename`.** Nothing is rewritten, so a message 20 + //! file is written exactly once and never modified — which is what lets 21 + //! `,S=` in the name be trusted, and what makes a maildir safe to back up 22 + //! while it is in use. 23 + //! 24 + //! The cost is that `tmp` accumulates the wreckage of interrupted deliveries, 25 + //! which is why the specification says to delete anything in there older than 26 + //! 36 hours, and why `cleanTemp` exists. 27 + //! 28 + //! ```zig 29 + //! var maildir: maildir_mod.Maildir = try .create(.cwd(), io, "Maildir", .{}); 30 + //! defer maildir.close(io); 31 + //! 32 + //! _ = try maildir.deliver(io, "From: jeff@example.com\r\n\r\nHello.\r\n", .{}); 33 + //! 34 + //! var it = maildir.iterate(.new); 35 + //! while (try it.next(io)) |message| { 36 + //! var m = message; 37 + //! try m.setFlags(&maildir, io, .{ .seen = true }); // and into `cur` 38 + //! } 39 + //! ``` 40 + 41 + const std = @import("std"); 42 + const Io = std.Io; 43 + const Dir = Io.Dir; 44 + const File = Io.File; 45 + const Allocator = std.mem.Allocator; 46 + const testing = std.testing; 47 + 48 + const Flags = @import("Flags.zig"); 49 + const Name = @import("Name.zig"); 50 + const unique = @import("unique.zig"); 51 + const Message = @import("Message.zig"); 52 + 53 + const Maildir = @This(); 54 + 55 + /// The maildir itself, the directory holding the other three. 56 + dir: Dir, 57 + /// Delivered messages that nothing has looked at yet. 58 + new: Dir, 59 + /// Messages that have been seen by a reader, whose names carry flags. 60 + cur: Dir, 61 + /// Deliveries in progress. Nothing here is a message yet. 62 + tmp: Dir, 63 + /// The character between a message's unique part and its flags. See 64 + /// `Name.default_separator` for why this is not simply a colon. 65 + separator: u8, 66 + /// Where the unique part of a delivered message's name comes from. 67 + generator: unique.Generator, 68 + 69 + /// A buffer big enough for any name this library will write or read. 70 + pub const NameBuffer = [Dir.max_name_bytes]u8; 71 + 72 + /// Which of the three directories a message is in. 73 + pub const Subdir = enum { 74 + tmp, 75 + new, 76 + cur, 77 + 78 + pub fn dirname(self: Subdir) []const u8 { 79 + return @tagName(self); 80 + } 81 + }; 82 + 83 + /// Mail is private, so the directories are created `rwx` for their owner and 84 + /// nothing for anybody else, rather than being left to whatever the process 85 + /// umask happens to be. A world-readable maildir is the sort of mistake that 86 + /// is only noticed afterwards. 87 + /// 88 + /// Systems with no POSIX mode at all get their platform default, which is the 89 + /// most that can be said there. 90 + pub const private_dir: Dir.Permissions = if (@hasDecl(Dir.Permissions, "fromMode")) 91 + Dir.Permissions.fromMode(0o700) 92 + else 93 + .default_dir; 94 + 95 + /// The same reasoning for the message files themselves. `0o600` rather than 96 + /// `0o666`-and-umask. 97 + pub const private_file: File.Permissions = if (@hasDecl(File.Permissions, "fromMode")) 98 + File.Permissions.fromMode(0o600) 99 + else 100 + .default_file; 101 + 102 + pub const Options = struct { 103 + /// The character before the info field. See `Name.default_separator`. 104 + separator: u8 = Name.default_separator, 105 + /// The host name that goes on the end of a delivered message's name. 106 + /// Null asks the system for it, which is what a mail program on the 107 + /// machine it delivers for wants. 108 + hostname: ?[]const u8 = null, 109 + /// The permissions the three directories are created with. 110 + permissions: Dir.Permissions = private_dir, 111 + }; 112 + 113 + pub const OpenError = Dir.OpenError || error{ 114 + /// The directory exists but is not a maildir: one or more of `tmp`, `new` 115 + /// and `cur` is missing. Deliberately not the same error as a missing 116 + /// directory, because delivering into a directory that merely looks like 117 + /// a mailbox is how mail gets lost. 118 + NotAMaildir, 119 + }; 120 + 121 + /// Opens an existing maildir. Fails if `tmp`, `new` or `cur` is missing. 122 + pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Maildir { 123 + const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); 124 + errdefer dir.close(io); 125 + return openDir(dir, io, options); 126 + } 127 + 128 + /// Like `open`, but takes a directory handle this maildir then owns and will 129 + /// close. The handle must have been opened with `.iterate = true`, since 130 + /// `Store` lists the folders beside it. 131 + pub fn openDir(dir: Dir, io: Io, options: Options) OpenError!Maildir { 132 + var opened: usize = 0; 133 + var handles: [3]Dir = undefined; 134 + errdefer Dir.closeMany(io, handles[0..opened]); 135 + 136 + for ([_]Subdir{ .tmp, .new, .cur }, 0..) |which, index| { 137 + handles[index] = dir.openDir(io, which.dirname(), .{ .iterate = true }) catch |err| switch (err) { 138 + error.FileNotFound, error.NotDir => return error.NotAMaildir, 139 + else => |e| return e, 140 + }; 141 + opened += 1; 142 + } 143 + 144 + var hostname_buffer: [unique.max_hostname]u8 = undefined; 145 + return .{ 146 + .dir = dir, 147 + .tmp = handles[0], 148 + .new = handles[1], 149 + .cur = handles[2], 150 + .separator = options.separator, 151 + .generator = .init(options.hostname orelse 152 + unique.systemHostname(&hostname_buffer)), 153 + }; 154 + } 155 + 156 + pub const CreateError = Dir.CreateDirError || OpenError; 157 + 158 + /// Creates a maildir, or opens one that is already there. 159 + /// 160 + /// The order matters and is the specification's: `tmp`, then `new`, then 161 + /// `cur`. A delivery finding `tmp` but not `new` would write a message it 162 + /// could not then deliver, so the directory a writer needs last is created 163 + /// last. 164 + pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Maildir { 165 + parent.createDir(io, sub_path, options.permissions) catch |err| switch (err) { 166 + error.PathAlreadyExists => {}, 167 + else => |e| return e, 168 + }; 169 + const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); 170 + errdefer dir.close(io); 171 + try createSubdirs(dir, io, options.permissions); 172 + return openDir(dir, io, options); 173 + } 174 + 175 + fn createSubdirs(dir: Dir, io: Io, permissions: Dir.Permissions) Dir.CreateDirError!void { 176 + for ([_]Subdir{ .tmp, .new, .cur }) |which| { 177 + dir.createDir(io, which.dirname(), permissions) catch |err| switch (err) { 178 + error.PathAlreadyExists => {}, 179 + else => |e| return e, 180 + }; 181 + } 182 + } 183 + 184 + /// Closes all four directory handles. The `Maildir` must not be used after 185 + /// this. 186 + pub fn close(self: *Maildir, io: Io) void { 187 + Dir.closeMany(io, &.{ self.tmp, self.new, self.cur, self.dir }); 188 + self.* = undefined; 189 + } 190 + 191 + /// The handle for one of the three subdirectories. 192 + pub fn subdir(self: *const Maildir, which: Subdir) Dir { 193 + return switch (which) { 194 + .tmp => self.tmp, 195 + .new => self.new, 196 + .cur => self.cur, 197 + }; 198 + } 199 + 200 + // -- delivery ---------------------------------------------------------------- 201 + 202 + /// Where a delivered message lands. 203 + pub const Destination = union(enum) { 204 + /// `new`, with no flags. An ordinary delivery: something arrived and 205 + /// nobody has looked at it. 206 + new, 207 + /// `cur`, with flags already set. This is what IMAP's `APPEND` does when 208 + /// the client says the message is already read, and what a client does 209 + /// when it saves a draft. 210 + cur: Flags, 211 + }; 212 + 213 + pub const DeliverOptions = struct { 214 + to: Destination = .new, 215 + /// Append `,S=<bytes>` to the name, so that the size can be had from a 216 + /// directory listing. Dovecot and Courier both do this and both trust it, 217 + /// which is safe only because a maildir message is never modified in 218 + /// place. 219 + record_size: bool = true, 220 + /// Also append `,W=<bytes>`, the size the message would be with CRLF line 221 + /// endings, which is the number IMAP reports. Off by default because it 222 + /// means counting the line endings, and because a message that is already 223 + /// CRLF makes it equal to `,S=`. 224 + record_virtual_size: bool = false, 225 + /// Ask the filesystem to put the message on the disk before it is named 226 + /// in `new`. Without this a crash can leave a message that exists in the 227 + /// directory and is empty on disk, which is worse than a message that 228 + /// never arrived. Costs a round trip to the storage on every delivery. 229 + sync: bool = true, 230 + /// How many times to invent a new name when the one invented collides 231 + /// with a file already in `tmp`. 232 + attempts: usize = 10, 233 + }; 234 + 235 + pub const DeliverError = File.OpenError || File.Writer.Error || File.SyncError || 236 + Dir.RenameError || error{ 237 + /// The buffered writer failed and could not say why, which should not 238 + /// happen: `File.Writer` records the real error and `commit` returns 239 + /// that instead wherever it is there. 240 + WriteFailed, 241 + /// `attempts` names in a row were already taken in `tmp`. Something 242 + /// is wrong that retrying will not fix — a clock stuck at a value a 243 + /// previous run also used, with `tmp` never cleaned. 244 + NameCollision, 245 + /// The generated name did not fit in `Dir.max_name_bytes`, which 246 + /// means the host name is absurd. 247 + NameTooLong, 248 + }; 249 + 250 + /// Writes a message into the maildir and returns where it landed. 251 + /// 252 + /// This is the whole protocol: a unique name, an exclusive create in `tmp`, 253 + /// the bytes, a sync, and a rename into `new` or `cur`. If anything fails 254 + /// before the rename the partial file is removed, so a failed delivery leaves 255 + /// nothing behind and — more to the point — never leaves half a message where 256 + /// a reader will find it. 257 + pub fn deliver( 258 + self: *Maildir, 259 + io: Io, 260 + bytes: []const u8, 261 + options: DeliverOptions, 262 + ) DeliverError!Message { 263 + var delivery = try self.beginDelivery(io, options.attempts); 264 + errdefer delivery.abort(io); 265 + 266 + try delivery.file.writeStreamingAll(io, bytes); 267 + delivery.size = bytes.len; 268 + if (options.record_virtual_size) delivery.virtual_size = virtualSizeOf(bytes); 269 + 270 + return delivery.commit(io, options); 271 + } 272 + 273 + /// The size `bytes` would have if every line ended in CRLF: the length plus 274 + /// one for every LF that is not already preceded by a CR. 275 + pub fn virtualSizeOf(bytes: []const u8) u64 { 276 + var total: u64 = bytes.len; 277 + for (bytes, 0..) |c, index| { 278 + if (c == '\n' and (index == 0 or bytes[index - 1] != '\r')) total += 1; 279 + } 280 + return total; 281 + } 282 + 283 + /// A message being written into `tmp`, not yet delivered. 284 + /// 285 + /// Use this rather than `deliver` when the message is not already a slice — 286 + /// when it is being copied from a socket, or written by `mime.Message.write`, 287 + /// or is large enough that a second copy of it in memory is not wanted. 288 + /// 289 + /// **The value must not be moved once `writer` has been called**, because 290 + /// what `writer` returns points into it. 291 + pub const Delivery = struct { 292 + /// Where the temporary file lives, so that `abort` can remove it and 293 + /// `commit` can rename it without being handed the maildir again. 294 + maildir: *const Maildir, 295 + file: File, 296 + name_buffer: NameBuffer, 297 + name_len: usize, 298 + /// The size of the message, if the caller already knows it. `commit` asks 299 + /// the file otherwise. 300 + size: ?u64 = null, 301 + /// The `,W=` size, if the caller knows it. Nothing here can work it out 302 + /// for a streamed message without reading every byte a second time, so a 303 + /// caller that wants the field recorded has to count as it writes. 304 + virtual_size: ?u64 = null, 305 + file_writer: ?File.Writer = null, 306 + file_open: bool = true, 307 + finished: bool = false, 308 + 309 + /// The name the file has in `tmp`. Not the name it will have once it is 310 + /// delivered, which gains the size fields and the flags. 311 + pub fn tempName(self: *const Delivery) []const u8 { 312 + return self.name_buffer[0..self.name_len]; 313 + } 314 + 315 + /// A writer for the message body. `buffer` must outlive the delivery, and 316 + /// the delivery must not be moved afterwards. 317 + pub fn writer(self: *Delivery, io: Io, buffer: []u8) *Io.Writer { 318 + self.file_writer = self.file.writer(io, buffer); 319 + return &self.file_writer.?.interface; 320 + } 321 + 322 + /// Flushes, syncs, and renames the message into place; returns where it 323 + /// landed. On failure the temporary file is removed, so a delivery either 324 + /// produces a whole message or produces nothing. 325 + pub fn commit(self: *Delivery, io: Io, options: DeliverOptions) DeliverError!Message { 326 + std.debug.assert(!self.finished); 327 + errdefer self.abort(io); 328 + 329 + // `Io.Writer.flush` reports only that something failed; the error 330 + // itself is kept on the `File.Writer` that produced it. 331 + if (self.file_writer) |*fw| fw.interface.flush() catch 332 + return fw.err orelse error.WriteFailed; 333 + if (options.sync) try self.file.sync(io); 334 + 335 + // A stat that fails costs the `,S=` field and nothing else, so it is 336 + // not worth failing a delivery over. 337 + const size = self.size orelse (self.file.length(io) catch null); 338 + 339 + var final_buffer: NameBuffer = undefined; 340 + var w: Io.Writer = .fixed(&final_buffer); 341 + w.writeAll(self.tempName()) catch return error.NameTooLong; 342 + if (options.record_size) if (size) |bytes| { 343 + w.print(",S={d}", .{bytes}) catch return error.NameTooLong; 344 + }; 345 + if (options.record_virtual_size) if (self.virtual_size) |bytes| { 346 + w.print(",W={d}", .{bytes}) catch return error.NameTooLong; 347 + }; 348 + const destination: Subdir = switch (options.to) { 349 + .new => .new, 350 + .cur => |flags| blk: { 351 + w.writeByte(self.maildir.separator) catch return error.NameTooLong; 352 + w.writeAll("2,") catch return error.NameTooLong; 353 + flags.format(&w) catch return error.NameTooLong; 354 + break :blk .cur; 355 + }, 356 + }; 357 + const final_name = w.buffered(); 358 + 359 + // Closed before the rename rather than after, so that the file is on 360 + // its way to the disk before anything can see it under its real name. 361 + self.file.close(io); 362 + self.file_open = false; 363 + 364 + try self.maildir.tmp.rename( 365 + self.tempName(), 366 + self.maildir.subdir(destination), 367 + final_name, 368 + io, 369 + ); 370 + self.finished = true; 371 + return .init(destination, self.maildir.separator, final_name); 372 + } 373 + 374 + /// Throws the delivery away: closes the file and removes it from `tmp`. 375 + /// Does nothing to a delivery that has already been committed or aborted, 376 + /// so it is safe as an `errdefer` beside a `commit`. 377 + pub fn abort(self: *Delivery, io: Io) void { 378 + if (self.finished) return; 379 + self.finished = true; 380 + if (self.file_open) { 381 + self.file.close(io); 382 + self.file_open = false; 383 + } 384 + // If this fails the file is wreckage in `tmp`, which is what 385 + // `cleanTemp` is for. There is nothing better to do about it here. 386 + self.maildir.tmp.deleteFile(io, self.tempName()) catch {}; 387 + } 388 + }; 389 + 390 + /// Creates the temporary file a message will be written into, inventing names 391 + /// until one of them is free. 392 + pub fn beginDelivery(self: *Maildir, io: Io, attempts: usize) DeliverError!Delivery { 393 + var delivery: Delivery = .{ 394 + .maildir = self, 395 + .file = undefined, 396 + .name_buffer = undefined, 397 + .name_len = 0, 398 + }; 399 + 400 + var attempt: usize = 0; 401 + while (attempt < @max(attempts, 1)) : (attempt += 1) { 402 + const name = self.generator.bufNext(io, &delivery.name_buffer) catch 403 + return error.NameTooLong; 404 + delivery.name_len = name.len; 405 + 406 + // Exclusive: the kernel refuses rather than truncating, so a name 407 + // that has somehow been used before costs an attempt rather than 408 + // somebody else's message. 409 + delivery.file = self.tmp.createFile(io, name, .{ 410 + .exclusive = true, 411 + .permissions = private_file, 412 + }) catch |err| switch (err) { 413 + error.PathAlreadyExists => continue, 414 + else => |e| return e, 415 + }; 416 + return delivery; 417 + } 418 + return error.NameCollision; 419 + } 420 + 421 + // -- reading ----------------------------------------------------------------- 422 + 423 + /// Walks one of the subdirectories, yielding a `Message` for each file in it. 424 + /// 425 + /// Entries whose names begin with a dot are skipped, which covers `.` and 426 + /// `..` and the marker files a Maildir++ store keeps beside its messages. 427 + /// Subdirectories are skipped too. 428 + pub const Iterator = struct { 429 + inner: Dir.Iterator, 430 + which: Subdir, 431 + separator: u8, 432 + 433 + pub const Error = Dir.Iterator.Error; 434 + 435 + /// The next message, or null at the end. The returned `Message` owns its 436 + /// name, so it stays valid after the following call. 437 + pub fn next(self: *Iterator, io: Io) Error!?Message { 438 + while (try self.inner.next(io)) |entry| { 439 + if (entry.name.len == 0 or entry.name[0] == '.') continue; 440 + switch (entry.kind) { 441 + // `unknown` is what a filesystem that does not report a kind 442 + // in its directory entries gives, and refusing those would 443 + // make this library useless on them. 444 + .file, .sym_link, .unknown => {}, 445 + else => continue, 446 + } 447 + if (entry.name.len > Dir.max_name_bytes) continue; 448 + return .init(self.which, self.separator, entry.name); 449 + } 450 + return null; 451 + } 452 + }; 453 + 454 + pub fn iterate(self: *const Maildir, which: Subdir) Iterator { 455 + return .{ 456 + .inner = self.subdir(which).iterate(), 457 + .which = which, 458 + .separator = self.separator, 459 + }; 460 + } 461 + 462 + pub const ListError = Iterator.Error || Allocator.Error; 463 + 464 + /// Every message in the given subdirectories, in one allocation-owning list. 465 + /// 466 + /// Iteration is the cheaper way to walk a maildir and should be preferred, 467 + /// but a caller that needs to sort the messages, or to know how many there 468 + /// are before it starts, needs them all at once. The order is the 469 + /// filesystem's, which is not chronological. 470 + pub fn list( 471 + self: *const Maildir, 472 + gpa: Allocator, 473 + io: Io, 474 + which: []const Subdir, 475 + ) ListError![]Message { 476 + var messages: std.ArrayList(Message) = .empty; 477 + defer messages.deinit(gpa); 478 + for (which) |subdir_kind| { 479 + var it = self.iterate(subdir_kind); 480 + while (try it.next(io)) |message| try messages.append(gpa, message); 481 + } 482 + return messages.toOwnedSlice(gpa); 483 + } 484 + 485 + /// Looks for a message by the unchanging part of its name — `Name.base`, 486 + /// which survives every flag change — in `new` and then in `cur`. 487 + /// 488 + /// This is how a program that remembered a message finds it again, and it is 489 + /// a linear scan because a maildir has no index. A caller doing this for 490 + /// every message of many should list the directory once instead. 491 + pub fn find(self: *const Maildir, io: Io, base: []const u8) Iterator.Error!?Message { 492 + for ([_]Subdir{ .new, .cur }) |which| { 493 + var it = self.iterate(which); 494 + while (try it.next(io)) |message| { 495 + if (std.mem.eql(u8, message.id(), base)) return message; 496 + } 497 + } 498 + return null; 499 + } 500 + 501 + pub const CleanTempError = Iterator.Error || Dir.DeleteFileError; 502 + 503 + /// Deletes anything in `tmp` older than `max_age`, which is the housekeeping 504 + /// the specification asks for: a delivery that died between creating its file 505 + /// and renaming it leaves that file behind forever otherwise. 506 + /// 507 + /// Thirty-six hours is the specified threshold, and it is long for a reason — 508 + /// it must exceed the longest a legitimate delivery could take, since 509 + /// deleting a file out from under a delivery in progress loses the message. 510 + /// 511 + /// Returns how many files were removed. 512 + pub fn cleanTemp(self: *const Maildir, io: Io, max_age: Io.Duration) CleanTempError!usize { 513 + const now = Io.Timestamp.now(io, .real); 514 + var removed: usize = 0; 515 + var it = self.tmp.iterate(); 516 + while (try it.next(io)) |entry| { 517 + if (entry.name.len == 0 or entry.name[0] == '.') continue; 518 + const stat = self.tmp.statFile(io, entry.name, .{}) catch continue; 519 + if (stat.mtime.durationTo(now).nanoseconds < max_age.nanoseconds) continue; 520 + self.tmp.deleteFile(io, entry.name) catch |err| switch (err) { 521 + error.FileNotFound => continue, 522 + else => |e| return e, 523 + }; 524 + removed += 1; 525 + } 526 + return removed; 527 + } 528 + 529 + /// The age at which the specification says a file in `tmp` is wreckage. 530 + pub const temp_max_age: Io.Duration = .{ .nanoseconds = 36 * 60 * 60 * std.time.ns_per_s }; 531 + 532 + test { 533 + _ = Message; 534 + }
+294
src/Message.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! One message in a maildir: which of the three directories it is in, and 5 + //! what it is called. 6 + //! 7 + //! That is genuinely all a message is. There is no file handle here and no 8 + //! content — a `Message` is a name, and every operation on it either reads 9 + //! the file that name points at or renames it. It owns a copy of the name 10 + //! rather than borrowing one, so it stays valid after the iterator that 11 + //! produced it has moved on, and it is small enough to copy freely. 12 + //! 13 + //! The operations that change a flag take the message by pointer, because 14 + //! changing a flag changes the name and the caller's `Message` has to follow 15 + //! it. A `Message` whose name no longer exists — because another process 16 + //! moved or expunged it — is not detected until the next operation on it 17 + //! fails, which is the price of a mailbox with no lock in it. 18 + 19 + const std = @import("std"); 20 + const Io = std.Io; 21 + const Dir = Io.Dir; 22 + const File = Io.File; 23 + const Allocator = std.mem.Allocator; 24 + const testing = std.testing; 25 + 26 + const mime = @import("mime"); 27 + 28 + const Flags = @import("Flags.zig"); 29 + const Name = @import("Name.zig"); 30 + const Maildir = @import("Maildir.zig"); 31 + 32 + const Message = @This(); 33 + 34 + /// Which directory the file is in. A message in `new` has no flags, because 35 + /// that is what makes it new. 36 + subdir: Maildir.Subdir, 37 + /// The separator this message's name was read with, so that its flags can be 38 + /// parsed and rewritten without the maildir being passed in to do it. 39 + separator: u8, 40 + name_buffer: Maildir.NameBuffer, 41 + name_len: usize, 42 + 43 + /// Asserts the name fits in `Dir.max_name_bytes`, which every name a 44 + /// filesystem handed us does by construction. 45 + pub fn init(subdir: Maildir.Subdir, separator: u8, basename: []const u8) Message { 46 + std.debug.assert(basename.len <= Dir.max_name_bytes); 47 + var self: Message = .{ 48 + .subdir = subdir, 49 + .separator = separator, 50 + .name_buffer = undefined, 51 + .name_len = basename.len, 52 + }; 53 + @memcpy(self.name_buffer[0..basename.len], basename); 54 + return self; 55 + } 56 + 57 + /// The file name, as it is on disk. 58 + pub fn filename(self: *const Message) []const u8 { 59 + return self.name_buffer[0..self.name_len]; 60 + } 61 + 62 + /// The name taken apart. Borrows from `self`, so it must not outlive it. 63 + pub fn name(self: *const Message) Name { 64 + return .parse(self.filename(), self.separator); 65 + } 66 + 67 + /// The flags on the message. A message in `new` has none. 68 + pub fn flags(self: *const Message) Flags { 69 + return self.name().flags(); 70 + } 71 + 72 + /// The part of the name that identifies the message and survives every flag 73 + /// change — what to remember a message by, and what `Maildir.find` looks for. 74 + pub fn id(self: *const Message) []const u8 { 75 + return self.name().base(); 76 + } 77 + 78 + fn dir(self: *const Message, maildir: *const Maildir) Dir { 79 + return maildir.subdir(self.subdir); 80 + } 81 + 82 + // -- reading ----------------------------------------------------------------- 83 + 84 + /// Opens the message file for reading. 85 + pub fn open(self: *const Message, maildir: *const Maildir, io: Io) File.OpenError!File { 86 + return self.dir(maildir).openFile(io, self.filename(), .{ .allow_directory = false }); 87 + } 88 + 89 + pub fn stat(self: *const Message, maildir: *const Maildir, io: Io) Dir.StatFileError!File.Stat { 90 + return self.dir(maildir).statFile(io, self.filename(), .{}); 91 + } 92 + 93 + /// How big the message is, from the `,S=` field in its name if it has one and 94 + /// from the filesystem otherwise. 95 + /// 96 + /// Trusting the name is safe because a maildir message is written once and 97 + /// never modified, and it is what makes totalling a mailbox's size a 98 + /// directory listing rather than one `stat` per message. 99 + pub fn size(self: *const Message, maildir: *const Maildir, io: Io) Dir.StatFileError!u64 { 100 + if (self.name().size()) |bytes| return bytes; 101 + return (try self.stat(maildir, io)).size; 102 + } 103 + 104 + /// The largest message this will read into memory by default. Mail is not 105 + /// supposed to be bigger than this, and a maildir that has been handed 106 + /// something enormous should not take the reader down with it. 107 + pub const default_read_limit: Io.Limit = .limited(64 * 1024 * 1024); 108 + 109 + pub const ReadError = Dir.ReadFileAllocError; 110 + 111 + /// The whole message, headers and body, exactly as it is on disk. The caller 112 + /// owns the result. 113 + pub fn read( 114 + self: *const Message, 115 + maildir: *const Maildir, 116 + gpa: Allocator, 117 + io: Io, 118 + limit: Io.Limit, 119 + ) ReadError![]u8 { 120 + return self.dir(maildir).readFileAlloc(io, self.filename(), gpa, limit); 121 + } 122 + 123 + /// `mime.Message.parse` only ever fails to allocate, so the errors here 124 + /// are the ones reading the file can produce. 125 + pub const ParseError = ReadError; 126 + 127 + /// The message, parsed: headers, addresses, dates, and the MIME tree. 128 + /// 129 + /// This is where <https://git.jcollie.dev/jeff/zig-mime> takes over. It parses 130 + /// from a slice rather than streaming, so the message is read into memory 131 + /// first and the returned `mime.Message` owns that copy — `deinit` frees 132 + /// both. A message written back out with `mime.Message.write` is byte for 133 + /// byte the one that was read, which is what makes it safe to open a signed 134 + /// message and forward it. 135 + /// 136 + /// ```zig 137 + /// var parsed = try message.parse(&maildir, gpa, io, .unlimited, .{}); 138 + /// defer parsed.deinit(); 139 + /// std.debug.print("{s}\n", .{(try parsed.root.subject()) orelse "(none)"}); 140 + /// ``` 141 + pub fn parse( 142 + self: *const Message, 143 + maildir: *const Maildir, 144 + gpa: Allocator, 145 + io: Io, 146 + limit: Io.Limit, 147 + options: mime.Message.ParseOptions, 148 + ) ParseError!mime.Message { 149 + const bytes = try self.read(maildir, gpa, io, limit); 150 + defer gpa.free(bytes); 151 + return mime.Message.parse(gpa, bytes, options); 152 + } 153 + 154 + // -- changing the name ------------------------------------------------------- 155 + 156 + pub const RenameError = Dir.RenameError || error{ 157 + /// The new name did not fit in `Dir.max_name_bytes`. 158 + NameTooLong, 159 + }; 160 + 161 + /// Renames the message so that it has exactly these flags, moving it from 162 + /// `new` to `cur` if it is still in `new`. 163 + /// 164 + /// Moving it is not a convenience: a name in `new` has no info field, so 165 + /// there is nowhere in `new` for a flag to be written. Marking a new message 166 + /// read and leaving it in `new` is not a thing a maildir can express, and 167 + /// every other implementation does the same move. 168 + /// 169 + /// On success `self` is updated to the new name. On failure it is untouched 170 + /// and still names the file that is still there. 171 + pub fn setFlags( 172 + self: *Message, 173 + maildir: *const Maildir, 174 + io: Io, 175 + new_flags: Flags, 176 + ) RenameError!void { 177 + var buffer: Maildir.NameBuffer = undefined; 178 + const renamed = self.name().withFlags(new_flags).bufWrite(&buffer, maildir.separator) catch 179 + return error.NameTooLong; 180 + 181 + // A message already in `cur` whose flags are unchanged would be a rename 182 + // onto itself, which is a no-op on POSIX but still a syscall and still a 183 + // change of mtime on the directory. 184 + if (self.subdir == .cur and std.mem.eql(u8, renamed, self.filename())) return; 185 + 186 + try self.dir(maildir).rename(self.filename(), maildir.cur, renamed, io); 187 + self.* = .init(.cur, maildir.separator, renamed); 188 + } 189 + 190 + /// Adds flags, leaving the others as they are. 191 + pub fn addFlags( 192 + self: *Message, 193 + maildir: *const Maildir, 194 + io: Io, 195 + to_add: Flags, 196 + ) RenameError!void { 197 + return self.setFlags(maildir, io, self.flags().unionWith(to_add)); 198 + } 199 + 200 + /// Removes flags, leaving the others as they are. 201 + pub fn removeFlags( 202 + self: *Message, 203 + maildir: *const Maildir, 204 + io: Io, 205 + to_remove: Flags, 206 + ) RenameError!void { 207 + return self.setFlags(maildir, io, self.flags().subtract(to_remove)); 208 + } 209 + 210 + /// Moves a message from `new` into `cur` without changing what it means — 211 + /// that is, with no flags — which is what a reader does when it has listed a 212 + /// mailbox and taken note of what was in it. 213 + /// 214 + /// A message already in `cur` is left exactly as it is, flags and all. 215 + pub fn moveToCur(self: *Message, maildir: *const Maildir, io: Io) RenameError!void { 216 + if (self.subdir == .cur) return; 217 + return self.setFlags(maildir, io, self.flags()); 218 + } 219 + 220 + pub const MoveError = RenameError || Maildir.DeliverError; 221 + 222 + /// Moves the message into another maildir, keeping its flags and giving it a 223 + /// name that is unique there. 224 + /// 225 + /// The name changes, and it has to. Two maildirs are two directories and 226 + /// nothing coordinates the names in them, so a message carrying its name into 227 + /// a folder that already has one like it would overwrite a message — and 228 + /// `rename` would do it silently. This is what IMAP's `MOVE` does, and it is 229 + /// why an IMAP server cannot promise that a moved message keeps its UID. 230 + /// 231 + /// `self` is updated to name the message in its new home. The two maildirs 232 + /// must be on the same filesystem, since this is a `rename` and not a copy. 233 + /// 234 + /// One sharp edge, and it is not this library's to fix: a **keyword does not 235 + /// survive the move with its meaning intact**. The letters in `Flags.other` 236 + /// are carried across unchanged, but what a letter *means* is recorded in a 237 + /// `dovecot-keywords` file inside each mailbox, so a message labelled 238 + /// "Important" in the inbox arrives in the archive carrying a letter that 239 + /// mailbox has never assigned. Dovecot's own `MOVE` updates the destination's 240 + /// mapping; nothing outside Dovecot can, because the mapping is Dovecot's 241 + /// rather than the maildir's. The six standard flags have no such problem — 242 + /// they mean the same thing everywhere. 243 + pub fn moveTo( 244 + self: *Message, 245 + from: *const Maildir, 246 + to: *Maildir, 247 + io: Io, 248 + ) MoveError!void { 249 + var unique_buffer: Maildir.NameBuffer = undefined; 250 + const fresh = to.generator.bufNext(io, &unique_buffer) catch return error.NameTooLong; 251 + 252 + // The flags travel; the unique part does not. Whatever `,S=` said is 253 + // still true, so it is carried across rather than recomputed. 254 + var buffer: Maildir.NameBuffer = undefined; 255 + var w: Io.Writer = .fixed(&buffer); 256 + w.writeAll(fresh) catch return error.NameTooLong; 257 + const old = self.name(); 258 + if (old.size()) |bytes| w.print(",S={d}", .{bytes}) catch return error.NameTooLong; 259 + if (old.virtualSize()) |bytes| w.print(",W={d}", .{bytes}) catch return error.NameTooLong; 260 + 261 + const destination: Maildir.Subdir = switch (self.subdir) { 262 + .new => .new, 263 + .cur, .tmp => blk: { 264 + w.writeByte(to.separator) catch return error.NameTooLong; 265 + w.writeAll("2,") catch return error.NameTooLong; 266 + old.flags().format(&w) catch return error.NameTooLong; 267 + break :blk .cur; 268 + }, 269 + }; 270 + const renamed = w.buffered(); 271 + 272 + try self.dir(from).rename(self.filename(), to.subdir(destination), renamed, io); 273 + self.* = .init(destination, to.separator, renamed); 274 + } 275 + 276 + /// Deletes the message. This is IMAP's expunge, not its `\Deleted`: setting 277 + /// `trashed` marks a message, and this is what actually removes it. 278 + pub fn remove(self: *const Message, maildir: *const Maildir, io: Io) Dir.DeleteFileError!void { 279 + return self.dir(maildir).deleteFile(io, self.filename()); 280 + } 281 + 282 + test "a message knows its own name" { 283 + const message: Message = .init(.cur, ':', "1757700000.M1R2Q3.host,S=42:2,RS"); 284 + try testing.expectEqualStrings("1757700000.M1R2Q3.host,S=42:2,RS", message.filename()); 285 + try testing.expectEqualStrings("1757700000.M1R2Q3.host", message.id()); 286 + try testing.expect(message.flags().seen and message.flags().replied); 287 + try testing.expectEqual(@as(?u64, 42), message.name().size()); 288 + } 289 + 290 + test "a message in new has no flags" { 291 + const message: Message = .init(.new, ':', "1757700000.M1R2Q3.host"); 292 + try testing.expectEqual(Flags.none, message.flags()); 293 + try testing.expectEqualStrings("1757700000.M1R2Q3.host", message.id()); 294 + }
+277
src/Name.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! The name of a message file, taken apart. 5 + //! 6 + //! A maildir keeps a message's metadata in its file name, which is why 7 + //! changing a flag is a `rename` and why the name has to be parsed and 8 + //! rebuilt rather than treated as opaque. The shape is 9 + //! 10 + //! ```text 11 + //! 1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com,S=4211:2,RS 12 + //! \_________________ unique ________________________/\____/ \_____/ 13 + //! fields info 14 + //! ``` 15 + //! 16 + //! * The **unique** part is everything before the separator, and this library 17 + //! never invents meaning for it beyond the fields below. It is produced at 18 + //! delivery and then left exactly alone, because two programs sharing a 19 + //! maildir agree on a message's identity by its unique part and nothing 20 + //! else — change it and every IMAP UID, every read/unread record and every 21 + //! synchronisation state keyed to it is lost. 22 + //! * The **fields** are `,`-separated `letter=value` pairs that Dovecot and 23 + //! Courier append to the unique part. `S` is the size of the file in bytes 24 + //! and `W` is its size once every line ends in CRLF, and both are there so 25 + //! that a quota can be totalled and an IMAP `RFC822.SIZE` answered from a 26 + //! directory listing rather than from a `stat` of every message. They are 27 + //! part of the unique part as far as everything else is concerned. 28 + //! * The **info** is `2,` followed by the flags. A message in `new` has no 29 + //! info at all, which is exactly what makes it new. 30 + //! 31 + //! `parse` cannot fail. A name it does not understand keeps its info verbatim 32 + //! in `Info.other` and is written back byte for byte, because the alternative 33 + //! — refusing to list a message because something else wrote its name in a 34 + //! dialect this library has not heard of — loses mail that is sitting right 35 + //! there. 36 + 37 + const std = @import("std"); 38 + const Io = std.Io; 39 + const testing = std.testing; 40 + 41 + const Flags = @import("Flags.zig"); 42 + 43 + const Name = @This(); 44 + 45 + /// Everything before the separator, including any `,S=` and `,W=` fields. 46 + /// Borrowed from whatever the name was parsed out of. 47 + unique: []const u8, 48 + /// What followed the separator. 49 + info: Info, 50 + 51 + /// The character between the unique part and the info. 52 + /// 53 + /// A colon is what the maildir defines and what every Unix mail program 54 + /// expects. It is also illegal in a FAT, exFAT or NTFS file name, so a 55 + /// maildir on a memory stick or a Windows share is written by isync and 56 + /// Dovecot with some other character — usually `!` or `;` — and a reader that 57 + /// insists on a colon sees every message in it as new and flagless. That is 58 + /// why the separator is a parameter of every function here rather than a 59 + /// constant. 60 + pub const default_separator: u8 = ':'; 61 + 62 + /// What follows the separator in a message's name. 63 + pub const Info = union(enum) { 64 + /// There was no separator. A message in `new` has no info, and that is 65 + /// the whole of what "new" means. 66 + none, 67 + /// `2,` followed by flags: the only info semantics ever defined. 68 + flags: Flags, 69 + /// A separator followed by something else — the experimental `1,` 70 + /// semantics, or a name written by software that has its own ideas. 71 + /// Kept as written, and written back unchanged. 72 + other: []const u8, 73 + }; 74 + 75 + /// Takes a file name apart. Never fails: a name that makes no sense is a name 76 + /// with no flags and an `Info.other` that reproduces it. 77 + /// 78 + /// The result borrows from `basename`, which must outlive it. When that is a 79 + /// directory entry, "outlive it" means "until the next call to `next`". 80 + pub fn parse(basename: []const u8, separator: u8) Name { 81 + const index = std.mem.findScalarLast(u8, basename, separator) orelse return .{ 82 + .unique = basename, 83 + .info = .none, 84 + }; 85 + const unique = basename[0..index]; 86 + const rest = basename[index + 1 ..]; 87 + 88 + // Only `2,` was ever defined. `1,` was reserved for experiments that 89 + // never happened, and anything else is somebody's extension. 90 + if (std.mem.startsWith(u8, rest, "2,")) { 91 + if (Flags.parse(rest[2..])) |parsed| { 92 + return .{ .unique = unique, .info = .{ .flags = parsed } }; 93 + } else |_| {} 94 + } 95 + return .{ .unique = unique, .info = .{ .other = rest } }; 96 + } 97 + 98 + /// The flags on the message, treating a name with no info or an info this 99 + /// library does not understand as having none — which is the truth as far as 100 + /// anything can tell. 101 + pub fn flags(self: Name) Flags { 102 + return switch (self.info) { 103 + .flags => |f| f, 104 + .none, .other => .none, 105 + }; 106 + } 107 + 108 + /// `self` with different flags, and the same unique part. 109 + pub fn withFlags(self: Name, new_flags: Flags) Name { 110 + return .{ .unique = self.unique, .info = .{ .flags = new_flags } }; 111 + } 112 + 113 + /// The unique part with the `,`-separated fields removed: the part that 114 + /// identifies the message and never changes, even when its size is recorded 115 + /// or its flags are set. 116 + pub fn base(self: Name) []const u8 { 117 + const index = std.mem.findScalar(u8, self.unique, ',') orelse return self.unique; 118 + return self.unique[0..index]; 119 + } 120 + 121 + /// The value of a `,<letter>=<value>` field appended to the unique part, or 122 + /// null if the name does not carry one. See `size` and `virtualSize` for the 123 + /// two that are defined. 124 + pub fn field(self: Name, letter: u8) ?[]const u8 { 125 + var rest = self.unique; 126 + while (std.mem.findScalar(u8, rest, ',')) |comma| { 127 + rest = rest[comma + 1 ..]; 128 + const end = std.mem.findScalar(u8, rest, ',') orelse rest.len; 129 + const item = rest[0..end]; 130 + if (item.len >= 2 and item[0] == letter and item[1] == '=') return item[2..]; 131 + } 132 + return null; 133 + } 134 + 135 + fn fieldInt(self: Name, letter: u8) ?u64 { 136 + const text = self.field(letter) orelse return null; 137 + return std.fmt.parseInt(u64, text, 10) catch null; 138 + } 139 + 140 + /// The size of the message in bytes, from the `,S=` field, or null if the 141 + /// name does not carry one — in which case the only way to know is to `stat` 142 + /// the file, which is what `Message.size` does. 143 + /// 144 + /// It is not checked against the file. A name that disagrees with its content 145 + /// was written by something that got it wrong, or the file was modified in 146 + /// place, which a maildir forbids. 147 + pub fn size(self: Name) ?u64 { 148 + return self.fieldInt('S'); 149 + } 150 + 151 + /// The size the message would have if every line ended in CRLF, from the 152 + /// `,W=` field. This is the number IMAP's `RFC822.SIZE` wants, and it differs 153 + /// from `size` for a message stored with bare newlines. 154 + pub fn virtualSize(self: Name) ?u64 { 155 + return self.fieldInt('W'); 156 + } 157 + 158 + /// Writes the name back out. 159 + pub fn write(self: Name, w: *Io.Writer, separator: u8) Io.Writer.Error!void { 160 + try w.writeAll(self.unique); 161 + switch (self.info) { 162 + .none => {}, 163 + .flags => |f| { 164 + try w.writeByte(separator); 165 + try w.writeAll("2,"); 166 + try f.format(w); 167 + }, 168 + .other => |text| { 169 + try w.writeByte(separator); 170 + try w.writeAll(text); 171 + }, 172 + } 173 + } 174 + 175 + /// Writes the name with the default separator, for `{f}`. 176 + pub fn format(self: Name, w: *Io.Writer) Io.Writer.Error!void { 177 + return self.write(w, default_separator); 178 + } 179 + 180 + /// Writes the name into `buffer` and returns the part used. 181 + pub fn bufWrite(self: Name, buffer: []u8, separator: u8) error{NoSpaceLeft}![]u8 { 182 + var w: Io.Writer = .fixed(buffer); 183 + self.write(&w, separator) catch return error.NoSpaceLeft; 184 + return w.buffered(); 185 + } 186 + 187 + test "a message in new has no info" { 188 + const name: Name = .parse("1757700000.M1R2Q3.host", ':'); 189 + try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.unique); 190 + try testing.expectEqual(Name.Info.none, name.info); 191 + try testing.expectEqual(Flags.none, name.flags()); 192 + try testing.expectFmt("1757700000.M1R2Q3.host", "{f}", .{name}); 193 + } 194 + 195 + test "a message in cur has flags" { 196 + const name: Name = .parse("1757700000.M1R2Q3.host:2,RS", ':'); 197 + try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.unique); 198 + try testing.expect(name.flags().seen and name.flags().replied); 199 + try testing.expectFmt("1757700000.M1R2Q3.host:2,RS", "{f}", .{name}); 200 + } 201 + 202 + test "an empty flag list is a message in cur with nothing set" { 203 + const name: Name = .parse("x:2,", ':'); 204 + try testing.expectEqual(Flags.none, name.flags()); 205 + try testing.expectFmt("x:2,", "{f}", .{name}); 206 + } 207 + 208 + test "the size fields are part of the unique name and readable from it" { 209 + const name: Name = .parse("1757700000.M1R2Q3.host,S=4211,W=4300:2,S", ':'); 210 + try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.base()); 211 + try testing.expectEqual(@as(?u64, 4211), name.size()); 212 + try testing.expectEqual(@as(?u64, 4300), name.virtualSize()); 213 + try testing.expect(name.flags().seen); 214 + } 215 + 216 + test "a name with no size field says so rather than guessing" { 217 + const name: Name = .parse("1757700000.M1R2Q3.host:2,S", ':'); 218 + try testing.expectEqual(@as(?u64, null), name.size()); 219 + try testing.expectEqual(@as(?u64, null), name.virtualSize()); 220 + } 221 + 222 + test "a size field that is not a number is not a number" { 223 + const name: Name = .parse("x,S=beef:2,", ':'); 224 + try testing.expectEqual(@as(?u64, null), name.size()); 225 + try testing.expectEqualStrings("beef", name.field('S').?); 226 + } 227 + 228 + test "changing a flag leaves the unique part alone" { 229 + const name: Name = .parse("1757700000.M1R2Q3.host,S=4211:2,S", ':'); 230 + const replied = name.withFlags(name.flags().with(.replied)); 231 + try testing.expectFmt("1757700000.M1R2Q3.host,S=4211:2,RS", "{f}", .{replied}); 232 + } 233 + 234 + test "the experimental info semantics are kept rather than understood" { 235 + const name: Name = .parse("x:1,whatever", ':'); 236 + try testing.expectEqualStrings("1,whatever", name.info.other); 237 + try testing.expectEqual(Flags.none, name.flags()); 238 + try testing.expectFmt("x:1,whatever", "{f}", .{name}); 239 + } 240 + 241 + test "an info that is not flags at all round trips byte for byte" { 242 + // `2,` followed by something that cannot be a flag: kept verbatim rather 243 + // than parsed into nothing, so that whatever wrote it can read it back. 244 + const name: Name = .parse("x:2,S=1", ':'); 245 + try testing.expectEqualStrings("2,S=1", name.info.other); 246 + try testing.expectFmt("x:2,S=1", "{f}", .{name}); 247 + } 248 + 249 + test "a separator that is not a colon" { 250 + // What isync writes on a filesystem that will not take a colon. Read with 251 + // the wrong separator, the flags vanish and the message looks new -- which 252 + // is the whole reason this is a parameter. 253 + const name: Name = .parse("1757700000.M1R2Q3.host!2,S", '!'); 254 + try testing.expect(name.flags().seen); 255 + try testing.expectFmt("1757700000.M1R2Q3.host!2,S", "{f}", .{ 256 + struct { 257 + n: Name, 258 + pub fn format(s: @This(), w: *Io.Writer) Io.Writer.Error!void { 259 + return s.n.write(w, '!'); 260 + } 261 + }{ .n = name }, 262 + }); 263 + 264 + const misread: Name = .parse("1757700000.M1R2Q3.host!2,S", ':'); 265 + try testing.expectEqual(Name.Info.none, misread.info); 266 + } 267 + 268 + test "bufWrite" { 269 + const name: Name = .parse("x:2,S", ':'); 270 + var buffer: [64]u8 = undefined; 271 + try testing.expectEqualStrings("x:2,RS", try name.withFlags( 272 + name.flags().with(.replied), 273 + ).bufWrite(&buffer, ':')); 274 + 275 + var tiny: [3]u8 = undefined; 276 + try testing.expectError(error.NoSpaceLeft, name.bufWrite(&tiny, ':')); 277 + }
+418
src/Store.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! A Maildir++ store: a top-level maildir, the tree of folders beside it, and 5 + //! the quota file covering both. 6 + //! 7 + //! Use this rather than `Maildir` when there is more than one mailbox — an 8 + //! IMAP account, a mail client's local store, anything with an Inbox and a 9 + //! Sent and an Archive. A single maildir with nothing beside it needs nothing 10 + //! from here. 11 + //! 12 + //! ```zig 13 + //! var store: maildir.Store = try .create(.cwd(), io, "Maildir", .{}); 14 + //! defer store.close(io); 15 + //! 16 + //! var inbox = try store.inbox(io); 17 + //! defer inbox.close(io); 18 + //! 19 + //! var sent = try store.createFolder(io, &.{"Sent"}); 20 + //! defer sent.close(io); 21 + //! ``` 22 + //! 23 + //! Every `Maildir` handed out here owns its own directory handles and has to 24 + //! be closed by the caller. The store does not keep track of them, on purpose: 25 + //! a mail client holds one folder open for a long time and touches forty 26 + //! others briefly, and a store that cached handles would either hold forty 27 + //! file descriptors or make the caller say which. 28 + //! 29 + //! See `folder` for what a folder name may contain, which is the one place 30 + //! Maildir++ is genuinely restrictive: a folder name cannot contain a dot. 31 + 32 + const std = @import("std"); 33 + const Io = std.Io; 34 + const Dir = Io.Dir; 35 + const File = Io.File; 36 + const Allocator = std.mem.Allocator; 37 + const testing = std.testing; 38 + 39 + const Maildir = @import("Maildir.zig"); 40 + const Message = @import("Message.zig"); 41 + const folder = @import("folder.zig"); 42 + const quota = @import("quota.zig"); 43 + const unique = @import("unique.zig"); 44 + 45 + const Store = @This(); 46 + 47 + /// The top-level maildir, which is both a mailbox in its own right — IMAP 48 + /// calls it INBOX — and the directory the folders live in. 49 + dir: Dir, 50 + separator: u8, 51 + permissions: Dir.Permissions, 52 + /// Held inline so that a store needs no allocator and outlives nothing. 53 + hostname_buffer: [unique.max_hostname]u8, 54 + hostname_len: usize, 55 + 56 + pub const Options = Maildir.Options; 57 + 58 + pub const OpenError = Maildir.OpenError; 59 + pub const CreateError = Maildir.CreateError; 60 + 61 + /// Opens an existing store. The top-level maildir must already be one. 62 + pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Store { 63 + const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); 64 + errdefer dir.close(io); 65 + 66 + // Opening it as a maildir and closing it again is the cheapest way to 67 + // insist that `tmp`, `new` and `cur` are all there. A store whose top 68 + // level is not a maildir is not a store, and finding that out now beats 69 + // finding it out on the first delivery. 70 + const probe = try Maildir.openDir(dir, io, options); 71 + Dir.closeMany(io, &.{ probe.tmp, probe.new, probe.cur }); 72 + 73 + return init(dir, options); 74 + } 75 + 76 + /// Creates the store, or opens one that is already there. 77 + pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Store { 78 + const maildir = try Maildir.create(parent, io, sub_path, options); 79 + // The maildir's own handle becomes the store's; only the three 80 + // subdirectories are let go. 81 + Dir.closeMany(io, &.{ maildir.tmp, maildir.new, maildir.cur }); 82 + return init(maildir.dir, options); 83 + } 84 + 85 + fn init(dir: Dir, options: Options) Store { 86 + var self: Store = .{ 87 + .dir = dir, 88 + .separator = options.separator, 89 + .permissions = options.permissions, 90 + .hostname_buffer = undefined, 91 + .hostname_len = 0, 92 + }; 93 + const host = options.hostname orelse unique.systemHostname(&self.hostname_buffer); 94 + self.hostname_len = @min(host.len, unique.max_hostname); 95 + // `host` may already be `hostname_buffer`, which `@memcpy` forbids 96 + // overlapping; copying it to itself is not needed either way. 97 + if (host.ptr != &self.hostname_buffer) { 98 + @memcpy(self.hostname_buffer[0..self.hostname_len], host[0..self.hostname_len]); 99 + } 100 + return self; 101 + } 102 + 103 + pub fn close(self: *Store, io: Io) void { 104 + self.dir.close(io); 105 + self.* = undefined; 106 + } 107 + 108 + pub fn hostname(self: *const Store) []const u8 { 109 + return self.hostname_buffer[0..self.hostname_len]; 110 + } 111 + 112 + fn maildirOptions(self: *const Store) Maildir.Options { 113 + return .{ 114 + .separator = self.separator, 115 + .hostname = self.hostname(), 116 + .permissions = self.permissions, 117 + }; 118 + } 119 + 120 + /// The top-level maildir as a mailbox: IMAP's INBOX. 121 + /// 122 + /// The returned `Maildir` has directory handles of its own and must be closed 123 + /// separately from the store. 124 + pub fn inbox(self: *const Store, io: Io) OpenError!Maildir { 125 + return Maildir.open(self.dir, io, ".", self.maildirOptions()); 126 + } 127 + 128 + pub const FolderError = folder.Error; 129 + 130 + /// Opens a folder by its components: `&.{"Work", "Reports"}` is the folder 131 + /// Maildir++ stores in `.Work.Reports`. 132 + pub fn openFolder( 133 + self: *const Store, 134 + io: Io, 135 + path: []const []const u8, 136 + ) (OpenError || FolderError)!Maildir { 137 + var buffer: Maildir.NameBuffer = undefined; 138 + const dirname = try folder.bufComponents(&buffer, path); 139 + return Maildir.open(self.dir, io, dirname, self.maildirOptions()); 140 + } 141 + 142 + /// `openFolder`, for a caller holding the path as one delimited string — 143 + /// `"Work/Reports"` with `/`, which is the shape an IMAP server has it in. 144 + pub fn openFolderPath( 145 + self: *const Store, 146 + io: Io, 147 + path: []const u8, 148 + path_delimiter: u8, 149 + ) (OpenError || FolderError)!Maildir { 150 + var buffer: Maildir.NameBuffer = undefined; 151 + const dirname = try folder.bufPath(&buffer, path, path_delimiter); 152 + return Maildir.open(self.dir, io, dirname, self.maildirOptions()); 153 + } 154 + 155 + /// Opens a folder by the directory name it has on disk, `.Work.Reports`. This 156 + /// is what `folders` hands back, so it is what to use when walking the tree. 157 + pub fn openFolderDirname( 158 + self: *const Store, 159 + io: Io, 160 + dirname: []const u8, 161 + ) OpenError!Maildir { 162 + return Maildir.open(self.dir, io, dirname, self.maildirOptions()); 163 + } 164 + 165 + pub const CreateFolderError = CreateError || FolderError || File.OpenError; 166 + 167 + /// Creates a folder, and every folder above it that is not there yet. 168 + /// 169 + /// Creating the ancestors is not in the specification, which leaves a folder 170 + /// whose parent is missing undefined. It is what Dovecot does, and the 171 + /// alternative — a store where `.Work.Reports` exists and `.Work` does not — 172 + /// is one that IMAP cannot describe, since `LIST` would report a folder with 173 + /// no parent. 174 + pub fn createFolder( 175 + self: *const Store, 176 + io: Io, 177 + path: []const []const u8, 178 + ) CreateFolderError!Maildir { 179 + if (path.len == 0) return error.EmptyPath; 180 + 181 + var buffer: Maildir.NameBuffer = undefined; 182 + // Each prefix in turn, so `.Work` is made before `.Work.Reports`. 183 + var depth: usize = 1; 184 + while (depth < path.len) : (depth += 1) { 185 + const dirname = try folder.bufComponents(&buffer, path[0..depth]); 186 + var ancestor = try self.createFolderDirname(io, dirname); 187 + ancestor.close(io); 188 + } 189 + const dirname = try folder.bufComponents(&buffer, path); 190 + return self.createFolderDirname(io, dirname); 191 + } 192 + 193 + /// `createFolder`, for a delimited path. 194 + pub fn createFolderPath( 195 + self: *const Store, 196 + io: Io, 197 + path: []const u8, 198 + path_delimiter: u8, 199 + ) CreateFolderError!Maildir { 200 + var components: [max_depth][]const u8 = undefined; 201 + var count: usize = 0; 202 + var it = std.mem.splitScalar(u8, path, path_delimiter); 203 + while (it.next()) |component| { 204 + if (count == components.len) return error.NameTooLong; 205 + components[count] = component; 206 + count += 1; 207 + } 208 + return self.createFolder(io, components[0..count]); 209 + } 210 + 211 + /// As deep as a folder path may be. A name only has room for so many 212 + /// components, and this is well past what a mail store has ever needed. 213 + pub const max_depth = 32; 214 + 215 + fn createFolderDirname( 216 + self: *const Store, 217 + io: Io, 218 + dirname: []const u8, 219 + ) CreateFolderError!Maildir { 220 + const maildir = try Maildir.create(self.dir, io, dirname, self.maildirOptions()); 221 + 222 + // The marker that says this is a folder. Courier will not treat a 223 + // directory without one as a mailbox. 224 + if (maildir.dir.createFile(io, folder.marker, .{ .exclusive = true })) |file| { 225 + file.close(io); 226 + } else |err| switch (err) { 227 + error.PathAlreadyExists => {}, 228 + else => |e| { 229 + var open_maildir = maildir; 230 + open_maildir.close(io); 231 + return e; 232 + }, 233 + } 234 + return maildir; 235 + } 236 + 237 + pub const DeleteFolderError = Dir.DeleteTreeError || FolderError || ListError || 238 + error{ 239 + /// The folder has folders below it and `recursive` was not set. 240 + /// Deleting it anyway would leave them unreachable through IMAP while 241 + /// still occupying the store. 242 + FolderNotEmpty, 243 + }; 244 + 245 + pub const DeleteFolderOptions = struct { 246 + /// Delete every folder below this one as well. 247 + recursive: bool = false, 248 + }; 249 + 250 + /// Deletes a folder and the messages in it. 251 + pub fn deleteFolder( 252 + self: *const Store, 253 + gpa: Allocator, 254 + io: Io, 255 + path: []const []const u8, 256 + options: DeleteFolderOptions, 257 + ) DeleteFolderError!void { 258 + var buffer: Maildir.NameBuffer = undefined; 259 + const dirname = try folder.bufComponents(&buffer, path); 260 + 261 + var list = try self.folders(gpa, io); 262 + defer list.deinit(gpa); 263 + 264 + for (list.names) |name| { 265 + if (!folder.isBelow(name, dirname)) continue; 266 + if (!options.recursive) return error.FolderNotEmpty; 267 + try self.dir.deleteTree(io, name); 268 + } 269 + try self.dir.deleteTree(io, dirname); 270 + } 271 + 272 + pub const ListError = Dir.Iterator.Error || Allocator.Error; 273 + 274 + /// Every folder in the store, by the directory name it has on disk. 275 + /// 276 + /// Sorted, which makes the parent of a folder come before it and lets a 277 + /// caller build a tree in one pass. The top-level maildir is not in the list: 278 + /// it is not a folder. 279 + pub const Folders = struct { 280 + names: [][]u8, 281 + 282 + pub fn deinit(self: *Folders, gpa: Allocator) void { 283 + for (self.names) |name| gpa.free(name); 284 + gpa.free(self.names); 285 + self.* = undefined; 286 + } 287 + }; 288 + 289 + pub fn folders(self: *const Store, gpa: Allocator, io: Io) ListError!Folders { 290 + var names: std.ArrayList([]u8) = .empty; 291 + errdefer { 292 + for (names.items) |name| gpa.free(name); 293 + names.deinit(gpa); 294 + } 295 + 296 + var it = self.dir.iterate(); 297 + while (try it.next(io)) |entry| { 298 + switch (entry.kind) { 299 + .directory, .sym_link, .unknown => {}, 300 + else => continue, 301 + } 302 + if (!folder.isFolder(entry.name)) continue; 303 + try names.append(gpa, try gpa.dupe(u8, entry.name)); 304 + } 305 + 306 + const owned = try names.toOwnedSlice(gpa); 307 + std.mem.sort([]u8, owned, {}, struct { 308 + fn lessThan(_: void, a: []u8, b: []u8) bool { 309 + return std.mem.order(u8, a, b) == .lt; 310 + } 311 + }.lessThan); 312 + return .{ .names = owned }; 313 + } 314 + 315 + /// Whether a folder exists. 316 + pub fn hasFolder( 317 + self: *const Store, 318 + io: Io, 319 + path: []const []const u8, 320 + ) (FolderError || Dir.StatFileError)!bool { 321 + var buffer: Maildir.NameBuffer = undefined; 322 + const dirname = try folder.bufComponents(&buffer, path); 323 + const stat = self.dir.statFile(io, dirname, .{}) catch |err| switch (err) { 324 + error.FileNotFound, error.NotDir => return false, 325 + else => |e| return e, 326 + }; 327 + return stat.kind == .directory; 328 + } 329 + 330 + // -- quota ------------------------------------------------------------------- 331 + 332 + /// What `maildirsize` says, or null if the store has no quota file — which is 333 + /// how a store with no quota configured looks, and is not an error. 334 + /// 335 + /// The number is the ledger's, not the filesystem's: see `quota` for why that 336 + /// is a running total that drifts, and use `isStale` on the result to decide 337 + /// whether to spend a `recalculateQuota` on it. 338 + pub fn quotaState(self: *const Store, gpa: Allocator, io: Io) quota.ReadError!?quota.State { 339 + return quota.read(self.dir, io, gpa); 340 + } 341 + 342 + /// Notes a change to the store's usage in `maildirsize`: positive for a 343 + /// delivery, negative for a message removed. Does nothing if the store has no 344 + /// quota file. 345 + pub fn recordUsage(self: *const Store, io: Io, delta: quota.Usage) quota.RecordError!void { 346 + return quota.record(self.dir, io, delta); 347 + } 348 + 349 + /// Sets the quota, replacing `maildirsize` with a ledger holding the given 350 + /// total. Use `recalculateQuota` to have the total worked out. 351 + pub fn setQuota( 352 + self: *const Store, 353 + io: Io, 354 + limits: quota.Limits, 355 + usage: quota.Usage, 356 + ) quota.WriteError!void { 357 + return quota.write(self.dir, io, limits, usage); 358 + } 359 + 360 + pub const RecalculateError = ListError || OpenError || Dir.StatFileError || 361 + quota.ReadError || quota.WriteError; 362 + 363 + /// Adds up every message in the store and writes the total to `maildirsize`. 364 + /// 365 + /// This is the slow path the ledger exists to avoid: it opens every folder 366 + /// and lists `new` and `cur` in each. Messages in `tmp` are not counted, 367 + /// since nothing there is a message yet. 368 + /// 369 + /// A message whose name carries `,S=` is counted from its name; the rest are 370 + /// `stat`ed. Keeping `record_size` on at delivery is therefore what makes 371 + /// this a directory walk rather than a `stat` of every message in the store. 372 + /// 373 + /// The limits already in `maildirsize` are kept. If there is no quota file 374 + /// yet, one is written with no limits in it, which records the usage without 375 + /// imposing anything. 376 + pub fn recalculateQuota(self: *const Store, gpa: Allocator, io: Io) RecalculateError!quota.Usage { 377 + const existing = try quota.read(self.dir, io, gpa); 378 + const limits: quota.Limits = if (existing) |state| state.limits else .none; 379 + 380 + var total: quota.Usage = .zero; 381 + 382 + var root = try self.inbox(io); 383 + defer root.close(io); 384 + total = total.plus(try measure(&root, io)); 385 + 386 + var list = try self.folders(gpa, io); 387 + defer list.deinit(gpa); 388 + for (list.names) |name| { 389 + var maildir = self.openFolderDirname(io, name) catch |err| switch (err) { 390 + // A directory that looks like a folder and is not one is not a 391 + // reason to abandon the count. 392 + error.NotAMaildir, error.FileNotFound => continue, 393 + else => |e| return e, 394 + }; 395 + defer maildir.close(io); 396 + total = total.plus(try measure(&maildir, io)); 397 + } 398 + 399 + try quota.write(self.dir, io, limits, total); 400 + return total; 401 + } 402 + 403 + fn measure(maildir: *const Maildir, io: Io) (Dir.Iterator.Error || Dir.StatFileError)!quota.Usage { 404 + var total: quota.Usage = .zero; 405 + for ([_]Maildir.Subdir{ .new, .cur }) |which| { 406 + var it = maildir.iterate(which); 407 + while (try it.next(io)) |message| { 408 + const bytes = message.size(maildir, io) catch |err| switch (err) { 409 + // Removed between the listing and the stat: it is not in the 410 + // mailbox any more, so it does not count. 411 + error.FileNotFound => continue, 412 + else => |e| return e, 413 + }; 414 + total = total.plus(.{ .bytes = @intCast(bytes), .messages = 1 }); 415 + } 416 + } 417 + return total; 418 + }
+260
src/folder.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! Maildir++ folder names: the convention that turns one maildir into a tree 5 + //! of them. 6 + //! 7 + //! A maildir has no room for a second mailbox in it, so Maildir++ puts the 8 + //! folders *beside* the messages, as hidden directories in the top-level 9 + //! maildir, each a complete maildir of its own: 10 + //! 11 + //! ```text 12 + //! Maildir/ the top-level maildir, which IMAP calls INBOX 13 + //! tmp/ new/ cur/ its own messages 14 + //! maildirsize the quota file, covering everything below 15 + //! .Work/ the folder "Work" 16 + //! tmp/ new/ cur/ 17 + //! maildirfolder the marker that says this is a folder and not a stray 18 + //! .Work.Reports/ the folder "Work/Reports" 19 + //! tmp/ new/ cur/ 20 + //! maildirfolder 21 + //! ``` 22 + //! 23 + //! The hierarchy is **flat on disk and nested in the name**: `.Work.Reports` 24 + //! is a sibling directory of `.Work`, not a child of it, which is what lets a 25 + //! whole folder tree be listed with one `readdir` and why renaming a folder 26 + //! means renaming every descendant. 27 + //! 28 + //! The dot is the hierarchy delimiter, and that has a consequence worth being 29 + //! explicit about: **a folder name cannot contain a dot**. There is no escape 30 + //! for one — Maildir++ never defined one — so a mailbox the user calls 31 + //! `example.com` is either the folder `com` inside the folder `example` or it 32 + //! is not representable, and this library says so with 33 + //! `error.InvalidComponent` rather than silently creating the wrong thing. 34 + //! 35 + //! What is *not* here is subscriptions. Which folders a client has subscribed 36 + //! to is IMAP's business and every server keeps it differently — Courier in 37 + //! `courierimapsubscribed`, Dovecot in `subscriptions` — so a file with that 38 + //! name is left alone rather than guessed at. 39 + 40 + const std = @import("std"); 41 + const Io = std.Io; 42 + const Dir = Io.Dir; 43 + const testing = std.testing; 44 + 45 + /// The character between one level of the hierarchy and the next, and the 46 + /// character a directory name begins with to mark it as a folder. 47 + pub const delimiter = '.'; 48 + 49 + /// The empty file that says a directory is a Maildir++ folder rather than 50 + /// something else that happens to be named with a leading dot. Courier 51 + /// requires it; Dovecot writes it and does not insist on it. 52 + pub const marker = "maildirfolder"; 53 + 54 + pub const Error = error{ 55 + /// A path with no components. The top-level maildir is not a folder, and 56 + /// naming it as one is a mistake worth reporting rather than resolving. 57 + EmptyPath, 58 + /// A component that is the empty string: `Work//Reports`, or a path with 59 + /// a leading or trailing delimiter. 60 + EmptyComponent, 61 + /// A component containing a dot or a slash. A dot is the hierarchy 62 + /// delimiter and Maildir++ has no escape for one; a slash would make the 63 + /// name a path. 64 + InvalidComponent, 65 + /// The resulting directory name is longer than a file name may be. 66 + NameTooLong, 67 + }; 68 + 69 + /// Whether a component can be part of a folder name. 70 + pub fn validComponent(component: []const u8) bool { 71 + if (component.len == 0) return false; 72 + for (component) |c| switch (c) { 73 + delimiter, '/', 0 => return false, 74 + else => {}, 75 + }; 76 + return true; 77 + } 78 + 79 + /// Writes the directory name for a folder given its components: 80 + /// `.{"Work", "Reports"}` becomes `.Work.Reports`. 81 + pub fn writeComponents(w: *Io.Writer, path: []const []const u8) (Error || Io.Writer.Error)!void { 82 + if (path.len == 0) return error.EmptyPath; 83 + for (path) |component| { 84 + if (component.len == 0) return error.EmptyComponent; 85 + if (!validComponent(component)) return error.InvalidComponent; 86 + try w.writeByte(delimiter); 87 + try w.writeAll(component); 88 + } 89 + } 90 + 91 + /// Writes the directory name for a folder given a path with a delimiter of 92 + /// the caller's choosing: `"Work/Reports"` with `/` becomes `.Work.Reports`. 93 + /// 94 + /// This is the form an IMAP server has, since IMAP carries the hierarchy 95 + /// delimiter in the protocol and it is very often a slash even when the store 96 + /// underneath uses a dot. 97 + pub fn writePath( 98 + w: *Io.Writer, 99 + path: []const u8, 100 + path_delimiter: u8, 101 + ) (Error || Io.Writer.Error)!void { 102 + if (path.len == 0) return error.EmptyPath; 103 + var it = std.mem.splitScalar(u8, path, path_delimiter); 104 + while (it.next()) |component| { 105 + if (component.len == 0) return error.EmptyComponent; 106 + if (!validComponent(component)) return error.InvalidComponent; 107 + try w.writeByte(delimiter); 108 + try w.writeAll(component); 109 + } 110 + } 111 + 112 + /// `writeComponents`, into a buffer. 113 + pub fn bufComponents(buffer: []u8, path: []const []const u8) Error![]u8 { 114 + var w: Io.Writer = .fixed(buffer); 115 + writeComponents(&w, path) catch |err| switch (err) { 116 + error.WriteFailed => return error.NameTooLong, 117 + else => |e| return e, 118 + }; 119 + return w.buffered(); 120 + } 121 + 122 + /// `writePath`, into a buffer. 123 + pub fn bufPath(buffer: []u8, path: []const u8, path_delimiter: u8) Error![]u8 { 124 + var w: Io.Writer = .fixed(buffer); 125 + writePath(&w, path, path_delimiter) catch |err| switch (err) { 126 + error.WriteFailed => return error.NameTooLong, 127 + else => |e| return e, 128 + }; 129 + return w.buffered(); 130 + } 131 + 132 + /// Whether a directory name in the top-level maildir names a folder. `.` and 133 + /// `..` are not folders, and neither is anything without a leading dot. 134 + pub fn isFolder(dirname: []const u8) bool { 135 + if (dirname.len < 2 or dirname[0] != delimiter) return false; 136 + if (std.mem.eql(u8, dirname, "..")) return false; 137 + // `.Work..Reports` has an empty component in it and is not a name this 138 + // library would have produced. 139 + var it = std.mem.splitScalar(u8, dirname[1..], delimiter); 140 + while (it.next()) |component| if (component.len == 0) return false; 141 + return true; 142 + } 143 + 144 + /// The components of a folder's directory name, outermost first. 145 + /// `.Work.Reports` yields `Work` then `Reports`. 146 + pub fn components(dirname: []const u8) Iterator { 147 + return .{ .rest = if (dirname.len > 0 and dirname[0] == delimiter) dirname[1..] else dirname }; 148 + } 149 + 150 + pub const Iterator = struct { 151 + rest: []const u8, 152 + done: bool = false, 153 + 154 + pub fn next(self: *Iterator) ?[]const u8 { 155 + if (self.done) return null; 156 + if (std.mem.findScalar(u8, self.rest, delimiter)) |index| { 157 + const component = self.rest[0..index]; 158 + self.rest = self.rest[index + 1 ..]; 159 + return component; 160 + } 161 + self.done = true; 162 + return self.rest; 163 + } 164 + }; 165 + 166 + /// Writes a folder's directory name as a path with the caller's delimiter: 167 + /// `.Work.Reports` becomes `Work/Reports`. The reverse of `writePath`. 168 + pub fn writeName( 169 + w: *Io.Writer, 170 + dirname: []const u8, 171 + path_delimiter: u8, 172 + ) Io.Writer.Error!void { 173 + var it = components(dirname); 174 + var first = true; 175 + while (it.next()) |component| { 176 + if (!first) try w.writeByte(path_delimiter); 177 + first = false; 178 + try w.writeAll(component); 179 + } 180 + } 181 + 182 + /// The directory name of a folder's parent, or null if it has none because it 183 + /// is directly below the top-level maildir. 184 + pub fn parent(dirname: []const u8) ?[]const u8 { 185 + const index = std.mem.findScalarLast(u8, dirname, delimiter) orelse return null; 186 + if (index == 0) return null; 187 + return dirname[0..index]; 188 + } 189 + 190 + /// Whether one folder is somewhere below another. `.Work.Reports.Q1` is under 191 + /// `.Work`; `.Workshop` is not, which is the case a plain `startsWith` gets 192 + /// wrong and the reason this exists. 193 + pub fn isBelow(dirname: []const u8, ancestor: []const u8) bool { 194 + if (dirname.len <= ancestor.len) return false; 195 + if (!std.mem.startsWith(u8, dirname, ancestor)) return false; 196 + return dirname[ancestor.len] == delimiter; 197 + } 198 + 199 + test "a folder name is its components with a dot in front of each" { 200 + var buffer: [64]u8 = undefined; 201 + try testing.expectEqualStrings(".Work", try bufComponents(&buffer, &.{"Work"})); 202 + try testing.expectEqualStrings(".Work.Reports", try bufComponents(&buffer, &.{ "Work", "Reports" })); 203 + } 204 + 205 + test "a path is split on whatever delimiter the caller uses" { 206 + var buffer: [64]u8 = undefined; 207 + try testing.expectEqualStrings(".Work.Reports", try bufPath(&buffer, "Work/Reports", '/')); 208 + try testing.expectEqualStrings(".Work.Reports", try bufPath(&buffer, "Work.Reports", '.')); 209 + } 210 + 211 + test "a folder name cannot contain the delimiter, and says so" { 212 + var buffer: [64]u8 = undefined; 213 + try testing.expectError(error.InvalidComponent, bufComponents(&buffer, &.{"example.com"})); 214 + try testing.expectError(error.InvalidComponent, bufComponents(&buffer, &.{"a/b"})); 215 + try testing.expectError(error.EmptyComponent, bufComponents(&buffer, &.{""})); 216 + try testing.expectError(error.EmptyPath, bufComponents(&buffer, &.{})); 217 + try testing.expectError(error.EmptyComponent, bufPath(&buffer, "Work//Reports", '/')); 218 + try testing.expectError(error.EmptyComponent, bufPath(&buffer, "/Work", '/')); 219 + } 220 + 221 + test "a name too long for a file name is refused rather than truncated" { 222 + var buffer: [8]u8 = undefined; 223 + try testing.expectError(error.NameTooLong, bufComponents(&buffer, &.{"a rather long folder name"})); 224 + } 225 + 226 + test "what is and is not a folder" { 227 + try testing.expect(isFolder(".Work")); 228 + try testing.expect(isFolder(".Work.Reports")); 229 + try testing.expect(!isFolder(".")); 230 + try testing.expect(!isFolder("..")); 231 + try testing.expect(!isFolder("cur")); 232 + try testing.expect(!isFolder("")); 233 + try testing.expect(!isFolder(".Work.")); 234 + try testing.expect(!isFolder(".Work..Reports")); 235 + } 236 + 237 + test "components round trip through a path" { 238 + var it = components(".Work.Reports"); 239 + try testing.expectEqualStrings("Work", it.next().?); 240 + try testing.expectEqualStrings("Reports", it.next().?); 241 + try testing.expectEqual(@as(?[]const u8, null), it.next()); 242 + 243 + var buffer: [64]u8 = undefined; 244 + var w: Io.Writer = .fixed(&buffer); 245 + try writeName(&w, ".Work.Reports", '/'); 246 + try testing.expectEqualStrings("Work/Reports", w.buffered()); 247 + } 248 + 249 + test "parent" { 250 + try testing.expectEqualStrings(".Work", parent(".Work.Reports").?); 251 + try testing.expectEqualStrings(".Work.Reports", parent(".Work.Reports.Q1").?); 252 + try testing.expectEqual(@as(?[]const u8, null), parent(".Work")); 253 + } 254 + 255 + test "a folder below another, and one that only looks like it" { 256 + try testing.expect(isBelow(".Work.Reports", ".Work")); 257 + try testing.expect(isBelow(".Work.Reports.Q1", ".Work")); 258 + try testing.expect(!isBelow(".Workshop", ".Work")); 259 + try testing.expect(!isBelow(".Work", ".Work")); 260 + }
+389
src/main.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! `zig-maildir`, a command-line tool over the library. 5 + //! 6 + //! It exists to show what the library looks like from outside, and to be 7 + //! something the NixOS tests can point at a maildir that Dovecot is also 8 + //! looking at — an interoperability claim needs two programs, and this is the 9 + //! second one. 10 + 11 + const std = @import("std"); 12 + const Io = std.Io; 13 + const Dir = Io.Dir; 14 + const Allocator = std.mem.Allocator; 15 + 16 + const mime = @import("mime"); 17 + const maildir = @import("maildir"); 18 + const Maildir = maildir.Maildir; 19 + const Store = maildir.Store; 20 + const Flags = maildir.Flags; 21 + 22 + const usage = 23 + \\usage: zig-maildir <command> [arguments] 24 + \\ 25 + \\ create <maildir> make a maildir, or a whole store 26 + \\ deliver <maildir> [file] deliver a message; `-` is stdin 27 + \\ list <maildir> every message, with its flags 28 + \\ headers <maildir> <id> one message's headers, decoded 29 + \\ flag <maildir> <id> <+-><F>.. set or clear flags: +S -F 30 + \\ move <maildir> <id> <folder> move a message to another folder 31 + \\ remove <maildir> <id> delete a message 32 + \\ folders <maildir> the Maildir++ folders in a store 33 + \\ mkfolder <maildir> <folder> create one, with its parents 34 + \\ rmfolder <maildir> <folder> delete one and everything below it 35 + \\ quota <maildir> [bytes] [msgs] show the quota, or set it 36 + \\ recalc <maildir> add the store up again 37 + \\ clean <maildir> remove old wreckage from tmp 38 + \\ 39 + \\A folder is named with `/` between its levels: "Work/Reports". 40 + \\An id is the unchanging part of a message's name, as `list` prints it. 41 + \\ 42 + ; 43 + 44 + pub fn main(init: std.process.Init) !void { 45 + const io = init.io; 46 + const gpa = init.gpa; 47 + 48 + var stdout_buffer: [4096]u8 = undefined; 49 + var stdout = Io.File.stdout().writer(io, &stdout_buffer); 50 + const out = &stdout.interface; 51 + 52 + var args: std.process.Args.Iterator = .init(init.minimal.args); 53 + _ = args.skip(); 54 + 55 + const command = args.next() orelse { 56 + try out.writeAll(usage); 57 + try out.flush(); 58 + return error.MissingCommand; 59 + }; 60 + const path = args.next() orelse { 61 + try out.writeAll(usage); 62 + try out.flush(); 63 + return error.MissingMaildir; 64 + }; 65 + 66 + const cwd: Dir = .cwd(); 67 + 68 + if (std.mem.eql(u8, command, "create")) { 69 + var store = try Store.create(cwd, io, path, .{}); 70 + store.close(io); 71 + try out.print("created {s}\n", .{path}); 72 + } else if (std.mem.eql(u8, command, "deliver")) { 73 + try deliver(gpa, io, out, cwd, path, args.next()); 74 + } else if (std.mem.eql(u8, command, "list")) { 75 + try list(gpa, io, out, cwd, path); 76 + } else if (std.mem.eql(u8, command, "headers")) { 77 + try headers(gpa, io, out, cwd, path, args.next() orelse return error.MissingId); 78 + } else if (std.mem.eql(u8, command, "flag")) { 79 + const id = args.next() orelse return error.MissingId; 80 + try flag(io, out, cwd, path, id, &args); 81 + } else if (std.mem.eql(u8, command, "move")) { 82 + const id = args.next() orelse return error.MissingId; 83 + const destination = args.next() orelse return error.MissingFolder; 84 + try move(io, out, cwd, path, id, destination); 85 + } else if (std.mem.eql(u8, command, "remove")) { 86 + try remove(io, out, cwd, path, args.next() orelse return error.MissingId); 87 + } else if (std.mem.eql(u8, command, "folders")) { 88 + try folders(gpa, io, out, cwd, path); 89 + } else if (std.mem.eql(u8, command, "mkfolder")) { 90 + var store = try Store.open(cwd, io, path, .{}); 91 + defer store.close(io); 92 + var folder = try store.createFolderPath(io, args.next() orelse return error.MissingFolder, '/'); 93 + folder.close(io); 94 + } else if (std.mem.eql(u8, command, "rmfolder")) { 95 + try removeFolder(gpa, io, cwd, path, args.next() orelse return error.MissingFolder); 96 + } else if (std.mem.eql(u8, command, "quota")) { 97 + try quota(gpa, io, out, cwd, path, &args); 98 + } else if (std.mem.eql(u8, command, "recalc")) { 99 + var store = try Store.open(cwd, io, path, .{}); 100 + defer store.close(io); 101 + const total = try store.recalculateQuota(gpa, io); 102 + try out.print("{d} bytes in {d} messages\n", .{ total.bytes, total.messages }); 103 + } else if (std.mem.eql(u8, command, "clean")) { 104 + var m = try Maildir.open(cwd, io, path, .{}); 105 + defer m.close(io); 106 + const removed = try m.cleanTemp(io, Maildir.temp_max_age); 107 + try out.print("removed {d}\n", .{removed}); 108 + } else { 109 + try out.writeAll(usage); 110 + try out.flush(); 111 + return error.UnknownCommand; 112 + } 113 + 114 + try out.flush(); 115 + } 116 + 117 + /// Everything in the store: the top-level maildir first, then each folder. 118 + /// The folder name is printed beside every message so that the output can be 119 + /// read without knowing which mailbox is which. 120 + fn forEachMailbox( 121 + gpa: Allocator, 122 + io: Io, 123 + cwd: Dir, 124 + path: []const u8, 125 + context: anytype, 126 + comptime visit: fn (@TypeOf(context), []const u8, *Maildir) anyerror!void, 127 + ) !void { 128 + var store = try Store.open(cwd, io, path, .{}); 129 + defer store.close(io); 130 + 131 + var inbox = try store.inbox(io); 132 + defer inbox.close(io); 133 + try visit(context, "INBOX", &inbox); 134 + 135 + var names = try store.folders(gpa, io); 136 + defer names.deinit(gpa); 137 + for (names.names) |dirname| { 138 + var folder_maildir = store.openFolderDirname(io, dirname) catch continue; 139 + defer folder_maildir.close(io); 140 + 141 + var buffer: [Dir.max_name_bytes]u8 = undefined; 142 + var w: Io.Writer = .fixed(&buffer); 143 + try maildir.folder.writeName(&w, dirname, '/'); 144 + try visit(context, w.buffered(), &folder_maildir); 145 + } 146 + } 147 + 148 + fn list(gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8) !void { 149 + const Context = struct { 150 + out: *Io.Writer, 151 + io: Io, 152 + 153 + fn visit(self: @This(), name: []const u8, m: *Maildir) anyerror!void { 154 + for ([_]Maildir.Subdir{ .new, .cur }) |which| { 155 + var it = m.iterate(which); 156 + while (try it.next(self.io)) |message| { 157 + const size = message.size(m, self.io) catch 0; 158 + try self.out.print("{s}\t{s}\t{f}\t{d}\t{s}\n", .{ 159 + @tagName(which), 160 + name, 161 + message.flags(), 162 + size, 163 + message.id(), 164 + }); 165 + } 166 + } 167 + } 168 + }; 169 + try forEachMailbox(gpa, io, cwd, path, Context{ .out = out, .io = io }, Context.visit); 170 + } 171 + 172 + fn folders(gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8) !void { 173 + var store = try Store.open(cwd, io, path, .{}); 174 + defer store.close(io); 175 + 176 + var names = try store.folders(gpa, io); 177 + defer names.deinit(gpa); 178 + for (names.names) |dirname| { 179 + try maildir.folder.writeName(out, dirname, '/'); 180 + try out.writeByte('\n'); 181 + } 182 + } 183 + 184 + fn deliver( 185 + gpa: Allocator, 186 + io: Io, 187 + out: *Io.Writer, 188 + cwd: Dir, 189 + path: []const u8, 190 + file: ?[]const u8, 191 + ) !void { 192 + var m = try Maildir.open(cwd, io, path, .{}); 193 + defer m.close(io); 194 + 195 + const source = file orelse "-"; 196 + const bytes = if (std.mem.eql(u8, source, "-")) blk: { 197 + var buffer: [4096]u8 = undefined; 198 + var stdin = Io.File.stdin().readerStreaming(io, &buffer); 199 + break :blk try stdin.interface.allocRemaining(gpa, .unlimited); 200 + } else try cwd.readFileAlloc(io, source, gpa, .unlimited); 201 + defer gpa.free(bytes); 202 + 203 + const message = try m.deliver(io, bytes, .{}); 204 + try out.print("{s}\n", .{message.id()}); 205 + } 206 + 207 + /// Finds a message by its id anywhere in the store, so that the tool's 208 + /// commands can take the id `list` printed without also being told which 209 + /// mailbox it was in. 210 + const Found = struct { 211 + store: Store, 212 + mailbox: Maildir, 213 + message: maildir.Message, 214 + 215 + fn close(self: *Found, io: Io) void { 216 + self.mailbox.close(io); 217 + self.store.close(io); 218 + } 219 + }; 220 + 221 + fn find(gpa: Allocator, io: Io, cwd: Dir, path: []const u8, id: []const u8) !Found { 222 + var store = try Store.open(cwd, io, path, .{}); 223 + errdefer store.close(io); 224 + 225 + var inbox = try store.inbox(io); 226 + if (try inbox.find(io, id)) |message| { 227 + return .{ .store = store, .mailbox = inbox, .message = message }; 228 + } 229 + inbox.close(io); 230 + 231 + var names = try store.folders(gpa, io); 232 + defer names.deinit(gpa); 233 + for (names.names) |dirname| { 234 + var folder_maildir = store.openFolderDirname(io, dirname) catch continue; 235 + if (try folder_maildir.find(io, id)) |message| { 236 + return .{ .store = store, .mailbox = folder_maildir, .message = message }; 237 + } 238 + folder_maildir.close(io); 239 + } 240 + return error.MessageNotFound; 241 + } 242 + 243 + fn headers( 244 + gpa: Allocator, 245 + io: Io, 246 + out: *Io.Writer, 247 + cwd: Dir, 248 + path: []const u8, 249 + id: []const u8, 250 + ) !void { 251 + var found = try find(gpa, io, cwd, path, id); 252 + defer found.close(io); 253 + 254 + var message = try found.message.parse(&found.mailbox, gpa, io, .unlimited, .{}); 255 + defer message.deinit(); 256 + 257 + // Decoded, which is the whole reason this goes through zig-mime rather 258 + // than printing the first few lines of the file. 259 + if (try message.root.subject()) |subject| try out.print("Subject: {s}\n", .{subject}); 260 + for ([_][]const u8{ "from", "to", "cc" }) |field| { 261 + var addresses = message.root.addresses(gpa, field) catch continue; 262 + defer addresses.deinit(gpa); 263 + if (addresses.addresses.len == 0) continue; 264 + try out.print("{s}:", .{field}); 265 + for (addresses.addresses) |address| try out.print(" {f}", .{address}); 266 + try out.writeByte('\n'); 267 + } 268 + } 269 + 270 + fn flag( 271 + io: Io, 272 + out: *Io.Writer, 273 + cwd: Dir, 274 + path: []const u8, 275 + id: []const u8, 276 + args: *std.process.Args.Iterator, 277 + ) !void { 278 + // The store has to stay open while the message is renamed, so this cannot 279 + // use `find`'s allocator-free path; the arena is the process allocator. 280 + var buffer: [4096]u8 = undefined; 281 + var fba: std.heap.FixedBufferAllocator = .init(&buffer); 282 + 283 + var found = try find(fba.allocator(), io, cwd, path, id); 284 + defer found.close(io); 285 + 286 + var add: Flags = .none; 287 + var clear: Flags = .none; 288 + while (args.next()) |argument| { 289 + if (argument.len < 2) return error.BadFlagArgument; 290 + const adding = switch (argument[0]) { 291 + '+' => true, 292 + '-' => false, 293 + else => return error.BadFlagArgument, 294 + }; 295 + for (argument[1..]) |letter| { 296 + if (maildir.Flags.Letters.indexOf(letter) == null) return error.BadFlagArgument; 297 + if (adding) add.setLetter(letter, true) else clear.setLetter(letter, true); 298 + } 299 + } 300 + 301 + try found.message.setFlags( 302 + &found.mailbox, 303 + io, 304 + found.message.flags().unionWith(add).subtract(clear), 305 + ); 306 + try out.print("{f}\n", .{found.message.flags()}); 307 + } 308 + 309 + fn move( 310 + io: Io, 311 + out: *Io.Writer, 312 + cwd: Dir, 313 + path: []const u8, 314 + id: []const u8, 315 + destination: []const u8, 316 + ) !void { 317 + var buffer: [4096]u8 = undefined; 318 + var fba: std.heap.FixedBufferAllocator = .init(&buffer); 319 + 320 + var found = try find(fba.allocator(), io, cwd, path, id); 321 + defer found.close(io); 322 + 323 + var target = if (std.mem.eql(u8, destination, "INBOX")) 324 + try found.store.inbox(io) 325 + else 326 + try found.store.openFolderPath(io, destination, '/'); 327 + defer target.close(io); 328 + 329 + try found.message.moveTo(&found.mailbox, &target, io); 330 + try out.print("{s}\n", .{found.message.id()}); 331 + } 332 + 333 + fn remove(io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, id: []const u8) !void { 334 + var buffer: [4096]u8 = undefined; 335 + var fba: std.heap.FixedBufferAllocator = .init(&buffer); 336 + 337 + var found = try find(fba.allocator(), io, cwd, path, id); 338 + defer found.close(io); 339 + try found.message.remove(&found.mailbox, io); 340 + try out.print("removed {s}\n", .{id}); 341 + } 342 + 343 + fn removeFolder(gpa: Allocator, io: Io, cwd: Dir, path: []const u8, name: []const u8) !void { 344 + var store = try Store.open(cwd, io, path, .{}); 345 + defer store.close(io); 346 + 347 + var components: [Store.max_depth][]const u8 = undefined; 348 + var count: usize = 0; 349 + var it = std.mem.splitScalar(u8, name, '/'); 350 + while (it.next()) |component| : (count += 1) { 351 + if (count == components.len) return error.FolderTooDeep; 352 + components[count] = component; 353 + } 354 + try store.deleteFolder(gpa, io, components[0..count], .{ .recursive = true }); 355 + } 356 + 357 + fn quota( 358 + gpa: Allocator, 359 + io: Io, 360 + out: *Io.Writer, 361 + cwd: Dir, 362 + path: []const u8, 363 + args: *std.process.Args.Iterator, 364 + ) !void { 365 + var store = try Store.open(cwd, io, path, .{}); 366 + defer store.close(io); 367 + 368 + if (args.next()) |bytes_text| { 369 + const limits: maildir.quota.Limits = .{ 370 + .bytes = std.fmt.parseInt(u64, bytes_text, 10) catch null, 371 + .messages = if (args.next()) |messages_text| 372 + std.fmt.parseInt(u64, messages_text, 10) catch null 373 + else 374 + null, 375 + }; 376 + const existing = try store.quotaState(gpa, io); 377 + try store.setQuota(io, limits, if (existing) |state| state.usage else .zero); 378 + } 379 + 380 + const state = (try store.quotaState(gpa, io)) orelse { 381 + try out.writeAll("no quota\n"); 382 + return; 383 + }; 384 + const used = state.usage.clamped(); 385 + try out.print("limit\t{f}\n", .{state.limits}); 386 + try out.print("used\t{d} bytes\t{d} messages\n", .{ used.bytes, used.messages }); 387 + try out.print("over\t{}\n", .{state.exceeded()}); 388 + try out.print("stale\t{}\n", .{state.isStale(io, .{})}); 389 + }
+451
src/quota.zig
··· 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 + 36 + const std = @import("std"); 37 + const Io = std.Io; 38 + const Dir = Io.Dir; 39 + const File = Io.File; 40 + const Allocator = std.mem.Allocator; 41 + const 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. 45 + pub const filename = "maildirsize"; 46 + 47 + /// What the store is allowed to hold. Absent limits are no limit. 48 + pub 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. 97 + pub 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. 136 + pub 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. 160 + pub 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. 179 + pub 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 + 226 + pub 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. 235 + pub const read_limit: Io.Limit = .limited(1024 * 1024); 236 + 237 + pub 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. 242 + pub 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 + 264 + fn 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 + 272 + fn 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 + 283 + pub 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. 299 + pub 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 + 319 + pub 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. 330 + pub 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 + 348 + test "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 + 355 + test "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 + 362 + test "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 + 368 + test "an empty quota line is no quota" { 369 + try testing.expect(!Limits.parse("").isSet()); 370 + } 371 + 372 + test "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 + 382 + test "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 + 398 + test "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 + 410 + test "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 + 422 + test "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 + 430 + test "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 + 439 + test "an empty file is an empty ledger, not an error" { 440 + const ledger = parseLedger(""); 441 + try testing.expectEqual(Ledger.empty, ledger); 442 + } 443 + 444 + test "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 + }
+22
src/root.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + pub const Flags = @import("Flags.zig"); 5 + pub const Name = @import("Name.zig"); 6 + pub const unique = @import("unique.zig"); 7 + pub const Maildir = @import("Maildir.zig"); 8 + pub const Message = @import("Message.zig"); 9 + pub const folder = @import("folder.zig"); 10 + pub const quota = @import("quota.zig"); 11 + pub const Store = @import("Store.zig"); 12 + 13 + test { 14 + _ = Flags; 15 + _ = Name; 16 + _ = unique; 17 + _ = Maildir; 18 + _ = Message; 19 + _ = folder; 20 + _ = quota; 21 + _ = Store; 22 + }
+188
src/unique.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! Making a name no other delivery will produce. 5 + //! 6 + //! This is the part of a maildir that has to be right, because it is the only 7 + //! thing standing between two programs delivering at the same moment and one 8 + //! of them writing over the other's message. There is no lock anywhere in the 9 + //! design: a maildir is safe over NFS, between processes that cannot see each 10 + //! other, precisely because every delivery invents a name nobody else will. 11 + //! 12 + //! The original specification asks for three parts joined by dots — the time, 13 + //! something unique within the process, and the host — and lists the 14 + //! identifiers that may make up the middle: `M` microseconds, `P` a process 15 + //! id, `Q` a delivery counter, `R` random bytes, `V` and `I` a device and 16 + //! inode. What this generator writes is 17 + //! 18 + //! ```text 19 + //! 1757700000.M492817R3f0a1c2b4d5e6f70Q3.mail.example.com 20 + //! ``` 21 + //! 22 + //! — the second, the microsecond within it, sixty-four random bits, and a 23 + //! counter that advances once per delivery from this generator. 24 + //! 25 + //! There is deliberately **no `P` field**, which is the one thing here that 26 + //! departs from what everyone else writes. A process id is a portable idea 27 + //! and not a portable call: Zig 0.16 has `getpid` on Linux and through libc, 28 + //! and nowhere else, so a library that wanted one would either drag in libc 29 + //! or stop working on a platform it has no reason to stop working on. What a 30 + //! process id buys is that two processes that start in the same microsecond 31 + //! do not collide, and sixty-four random bits buy that far more convincingly 32 + //! — a birthday collision needs about five billion deliveries in the same 33 + //! microsecond. The counter then covers the case the random number generator 34 + //! cannot: two deliveries from *this* generator, which must differ even if 35 + //! the clock does not move and the entropy source is repeating itself. 36 + //! 37 + //! None of that is trusted on its own. The file in `tmp` is created 38 + //! exclusively, so a name that has somehow been used before is refused by the 39 + //! kernel rather than silently overwritten, and `Maildir.deliver` tries 40 + //! again with a fresh one. 41 + 42 + const std = @import("std"); 43 + const Io = std.Io; 44 + const testing = std.testing; 45 + 46 + /// The longest host name this will keep. Longer than `HOST_NAME_MAX` on every 47 + /// system that defines it, and stored inline so that a generator needs no 48 + /// allocator and no lifetime beyond its own. 49 + pub const max_hostname = 255; 50 + 51 + /// Writes a host name with the two characters a maildir name cannot contain 52 + /// replaced by their octal escapes: `/`, which would make the name a path, 53 + /// and `:`, which would look like the start of the flags. 54 + /// 55 + /// This is the escaping the specification defines, and it is not reversed 56 + /// anywhere here — the host name is part of an opaque unique string, and 57 + /// nothing reads it back. A host name containing a separator other than `:` 58 + /// is not escaped, on the grounds that no real one contains anything but 59 + /// letters, digits, hyphens and dots. 60 + pub fn writeEscapedHostname(w: *Io.Writer, host: []const u8) Io.Writer.Error!void { 61 + for (host) |c| switch (c) { 62 + '/' => try w.writeAll("\\057"), 63 + ':' => try w.writeAll("\\072"), 64 + else => try w.writeByte(c), 65 + }; 66 + } 67 + 68 + /// The system's host name, into a buffer the caller owns. Falls back to 69 + /// `localhost` rather than failing, because a delivery that cannot name the 70 + /// host is still a delivery that must not be lost, and the host name is only 71 + /// one of four things making the name unique. 72 + pub fn systemHostname(buffer: *[max_hostname]u8) []const u8 { 73 + var raw: [std.posix.HOST_NAME_MAX]u8 = undefined; 74 + const host = std.posix.gethostname(&raw) catch return fallback(buffer); 75 + if (host.len == 0 or host.len > buffer.len) return fallback(buffer); 76 + @memcpy(buffer[0..host.len], host); 77 + return buffer[0..host.len]; 78 + } 79 + 80 + fn fallback(buffer: *[max_hostname]u8) []const u8 { 81 + const name = "localhost"; 82 + @memcpy(buffer[0..name.len], name); 83 + return buffer[0..name.len]; 84 + } 85 + 86 + /// Produces the unique part of a message name. Holds its host name inline, so 87 + /// it needs no allocator, and its counter is atomic, so one generator can be 88 + /// shared by every thread delivering into a maildir. 89 + pub const Generator = struct { 90 + hostname_buffer: [max_hostname]u8, 91 + hostname_len: usize, 92 + counter: std.atomic.Value(u32), 93 + 94 + /// Takes a copy of `host`, truncated to `max_hostname`. Pass what 95 + /// `systemHostname` returned unless there is a reason not to — the reason 96 + /// usually being a machine whose mail is delivered under a name other 97 + /// than the one `uname` gives. 98 + pub fn init(host: []const u8) Generator { 99 + var self: Generator = .{ 100 + .hostname_buffer = undefined, 101 + .hostname_len = @min(host.len, max_hostname), 102 + .counter = .init(0), 103 + }; 104 + @memcpy(self.hostname_buffer[0..self.hostname_len], host[0..self.hostname_len]); 105 + return self; 106 + } 107 + 108 + /// A generator naming this machine. 109 + pub fn initSystem() Generator { 110 + var buffer: [max_hostname]u8 = undefined; 111 + return .init(systemHostname(&buffer)); 112 + } 113 + 114 + pub fn hostname(self: *const Generator) []const u8 { 115 + return self.hostname_buffer[0..self.hostname_len]; 116 + } 117 + 118 + /// Writes one unique name. Advances the counter, so two calls never 119 + /// produce the same thing even with a stopped clock. 120 + pub fn next(self: *Generator, io: Io, w: *Io.Writer) Io.Writer.Error!void { 121 + const nanoseconds = Io.Timestamp.now(io, .real).toNanoseconds(); 122 + const seconds = @divFloor(nanoseconds, std.time.ns_per_s); 123 + const microseconds = @divFloor( 124 + nanoseconds - seconds * std.time.ns_per_s, 125 + std.time.ns_per_us, 126 + ); 127 + 128 + var entropy: [8]u8 = undefined; 129 + io.random(&entropy); 130 + 131 + try w.print("{d}.M{d}R{x:0>16}Q{d}.", .{ 132 + seconds, 133 + microseconds, 134 + std.mem.readInt(u64, &entropy, .little), 135 + self.counter.fetchAdd(1, .monotonic), 136 + }); 137 + try writeEscapedHostname(w, self.hostname()); 138 + } 139 + 140 + /// `next`, into a buffer. The name is short — about sixty characters plus 141 + /// the host — so `Io.Dir.max_name_bytes` is always enough room. 142 + pub fn bufNext(self: *Generator, io: Io, buffer: []u8) error{NoSpaceLeft}![]u8 { 143 + var w: Io.Writer = .fixed(buffer); 144 + self.next(io, &w) catch return error.NoSpaceLeft; 145 + return w.buffered(); 146 + } 147 + }; 148 + 149 + test "a name has the four parts, and the host on the end" { 150 + var generator: Generator = .init("mail.example.com"); 151 + var buffer: [std.Io.Dir.max_name_bytes]u8 = undefined; 152 + 153 + // `Io.failing`'s clock reads zero rather than panicking, and its `random` 154 + // is a real one, which is exactly enough to exercise this without a 155 + // thread pool. 156 + const name = try generator.bufNext(std.Io.failing, &buffer); 157 + try std.testing.expect(std.mem.startsWith(u8, name, "0.M0R")); 158 + try std.testing.expect(std.mem.endsWith(u8, name, "Q0.mail.example.com")); 159 + } 160 + 161 + test "the counter advances even when the clock does not" { 162 + var generator: Generator = .init("host"); 163 + var a: [std.Io.Dir.max_name_bytes]u8 = undefined; 164 + var b: [std.Io.Dir.max_name_bytes]u8 = undefined; 165 + const first = try generator.bufNext(std.Io.failing, &a); 166 + const second = try generator.bufNext(std.Io.failing, &b); 167 + try testing.expect(!std.mem.eql(u8, first, second)); 168 + try testing.expect(std.mem.endsWith(u8, first, "Q0.host")); 169 + try testing.expect(std.mem.endsWith(u8, second, "Q1.host")); 170 + } 171 + 172 + test "the two characters that would break a name are escaped" { 173 + var buffer: [64]u8 = undefined; 174 + var w: Io.Writer = .fixed(&buffer); 175 + try writeEscapedHostname(&w, "a/b:c"); 176 + try testing.expectEqualStrings("a\\057b\\072c", w.buffered()); 177 + } 178 + 179 + test "a host name longer than the buffer is truncated rather than refused" { 180 + const long = "x" ** (max_hostname + 10); 181 + var generator: Generator = .init(long); 182 + try testing.expectEqual(@as(usize, max_hostname), generator.hostname().len); 183 + } 184 + 185 + test "the system host name is never empty" { 186 + var buffer: [max_hostname]u8 = undefined; 187 + try testing.expect(systemHostname(&buffer).len > 0); 188 + }
+777
tests/e2e.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! What the library does to a real directory. 5 + //! 6 + //! Everything in `src` that can be tested without touching a filesystem is 7 + //! tested beside the code it tests; this is the rest, and the rest is most of 8 + //! what a maildir *is*. A name parser that round trips proves nothing about 9 + //! whether a delivery is atomic, whether a flag change moves the file from 10 + //! `new` to `cur`, or whether the quota ledger adds up — those are claims 11 + //! about directories, and they need directories. 12 + //! 13 + //! These are a module of their own so that `zig build test` runs them while a 14 + //! consumer of the library never compiles them. 15 + 16 + const std = @import("std"); 17 + const Io = std.Io; 18 + const Dir = Io.Dir; 19 + const testing = std.testing; 20 + const io = testing.io; 21 + 22 + const maildir = @import("maildir"); 23 + const Maildir = maildir.Maildir; 24 + const Store = maildir.Store; 25 + const Flags = maildir.Flags; 26 + 27 + const message_text = 28 + "From: jeff@example.com\r\n" ++ 29 + "To: someone@example.net\r\n" ++ 30 + "Subject: A test\r\n" ++ 31 + "\r\n" ++ 32 + "Hello.\r\n"; 33 + 34 + /// Counts what is in one of the three subdirectories, skipping the dotfiles 35 + /// the way the library does. 36 + fn count(m: *const Maildir, which: Maildir.Subdir) !usize { 37 + var total: usize = 0; 38 + var it = m.iterate(which); 39 + while (try it.next(io)) |_| total += 1; 40 + return total; 41 + } 42 + 43 + test "create makes the three directories, and open insists on them" { 44 + var tmp = testing.tmpDir(.{ .iterate = true }); 45 + defer tmp.cleanup(); 46 + 47 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 48 + m.close(io); 49 + 50 + // Opening it again works, and finds the same three. 51 + var reopened = try Maildir.open(tmp.dir, io, "Maildir", .{}); 52 + reopened.close(io); 53 + 54 + // A directory that is not a maildir is refused, and with an error that 55 + // says which problem it is: delivering into a directory that merely looks 56 + // like a mailbox is how mail gets lost. 57 + try tmp.dir.createDir(io, "NotAMaildir", .default_dir); 58 + try testing.expectError( 59 + error.NotAMaildir, 60 + Maildir.open(tmp.dir, io, "NotAMaildir", .{}), 61 + ); 62 + try testing.expectError( 63 + error.FileNotFound, 64 + Maildir.open(tmp.dir, io, "NoSuchThing", .{}), 65 + ); 66 + } 67 + 68 + test "create is idempotent" { 69 + var tmp = testing.tmpDir(.{ .iterate = true }); 70 + defer tmp.cleanup(); 71 + 72 + var first = try Maildir.create(tmp.dir, io, "Maildir", .{}); 73 + _ = try first.deliver(io, message_text, .{}); 74 + first.close(io); 75 + 76 + var second = try Maildir.create(tmp.dir, io, "Maildir", .{}); 77 + defer second.close(io); 78 + // The message that was already there is still there. 79 + try testing.expectEqual(@as(usize, 1), try count(&second, .new)); 80 + } 81 + 82 + test "a delivered message lands in new, whole, and leaves tmp empty" { 83 + var tmp = testing.tmpDir(.{ .iterate = true }); 84 + defer tmp.cleanup(); 85 + 86 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .hostname = "test.example" }); 87 + defer m.close(io); 88 + 89 + const delivered = try m.deliver(io, message_text, .{}); 90 + try testing.expectEqual(Maildir.Subdir.new, delivered.subdir); 91 + try testing.expectEqual(Flags.none, delivered.flags()); 92 + 93 + // The whole point of the tmp/new dance: nothing is left behind, and what 94 + // arrived in `new` is the whole message. 95 + try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); 96 + try testing.expectEqual(@as(usize, 1), try count(&m, .new)); 97 + 98 + const bytes = try delivered.read(&m, testing.allocator, io, .unlimited); 99 + defer testing.allocator.free(bytes); 100 + try testing.expectEqualStrings(message_text, bytes); 101 + } 102 + 103 + test "the name carries the host, the size, and no flags" { 104 + var tmp = testing.tmpDir(.{ .iterate = true }); 105 + defer tmp.cleanup(); 106 + 107 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .hostname = "test.example" }); 108 + defer m.close(io); 109 + 110 + const delivered = try m.deliver(io, message_text, .{}); 111 + const name = delivered.name(); 112 + // The host is the end of the identifying part. `unique` is longer than 113 + // that, because the size field is appended to it. 114 + try testing.expect(std.mem.endsWith(u8, name.base(), "test.example")); 115 + try testing.expect(std.mem.endsWith(u8, name.unique, ",S=76")); 116 + try testing.expectEqual(@as(?u64, message_text.len), name.size()); 117 + try testing.expectEqual(maildir.Name.Info.none, name.info); 118 + 119 + // And the size in the name is the size on disk, which is the whole reason 120 + // anything is allowed to trust it. 121 + try testing.expectEqual( 122 + @as(u64, message_text.len), 123 + try delivered.size(&m, io), 124 + ); 125 + } 126 + 127 + test "record_size off leaves the size out rather than guessing it" { 128 + var tmp = testing.tmpDir(.{ .iterate = true }); 129 + defer tmp.cleanup(); 130 + 131 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 132 + defer m.close(io); 133 + 134 + const delivered = try m.deliver(io, message_text, .{ .record_size = false }); 135 + try testing.expectEqual(@as(?u64, null), delivered.name().size()); 136 + // It still knows how big the message is; it just has to ask the 137 + // filesystem. 138 + try testing.expectEqual(@as(u64, message_text.len), try delivered.size(&m, io)); 139 + } 140 + 141 + test "the virtual size counts the line endings a bare-LF message is missing" { 142 + const unix_text = "From: a@b\n\nOne\nTwo\n"; 143 + try testing.expectEqual( 144 + @as(u64, unix_text.len + 4), 145 + Maildir.virtualSizeOf(unix_text), 146 + ); 147 + // A message that is already CRLF needs nothing added. 148 + try testing.expectEqual( 149 + @as(u64, message_text.len), 150 + Maildir.virtualSizeOf(message_text), 151 + ); 152 + 153 + var tmp = testing.tmpDir(.{ .iterate = true }); 154 + defer tmp.cleanup(); 155 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 156 + defer m.close(io); 157 + 158 + const delivered = try m.deliver(io, unix_text, .{ .record_virtual_size = true }); 159 + try testing.expectEqual(@as(?u64, unix_text.len), delivered.name().size()); 160 + try testing.expectEqual(@as(?u64, unix_text.len + 4), delivered.name().virtualSize()); 161 + } 162 + 163 + test "delivering into cur with flags is what an IMAP append does" { 164 + var tmp = testing.tmpDir(.{ .iterate = true }); 165 + defer tmp.cleanup(); 166 + 167 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 168 + defer m.close(io); 169 + 170 + const delivered = try m.deliver(io, message_text, .{ 171 + .to = .{ .cur = .{ .seen = true, .draft = true } }, 172 + }); 173 + try testing.expectEqual(Maildir.Subdir.cur, delivered.subdir); 174 + try testing.expect(delivered.flags().seen and delivered.flags().draft); 175 + try testing.expectEqual(@as(usize, 0), try count(&m, .new)); 176 + try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); 177 + 178 + // `D` sorts before `S`, and the flags come after the size field. 179 + try testing.expect(std.mem.endsWith(u8, delivered.filename(), ":2,DS")); 180 + } 181 + 182 + test "two deliveries in a row do not collide" { 183 + var tmp = testing.tmpDir(.{ .iterate = true }); 184 + defer tmp.cleanup(); 185 + 186 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 187 + defer m.close(io); 188 + 189 + var seen: std.StringHashMapUnmanaged(void) = .empty; 190 + defer { 191 + var it = seen.keyIterator(); 192 + while (it.next()) |key| testing.allocator.free(key.*); 193 + seen.deinit(testing.allocator); 194 + } 195 + 196 + for (0..200) |_| { 197 + const delivered = try m.deliver(io, message_text, .{ .sync = false }); 198 + const name = try testing.allocator.dupe(u8, delivered.filename()); 199 + errdefer testing.allocator.free(name); 200 + try testing.expect(!seen.contains(name)); 201 + try seen.put(testing.allocator, name, {}); 202 + } 203 + try testing.expectEqual(@as(usize, 200), try count(&m, .new)); 204 + } 205 + 206 + test "a streamed delivery writes the same message as a sliced one" { 207 + var tmp = testing.tmpDir(.{ .iterate = true }); 208 + defer tmp.cleanup(); 209 + 210 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 211 + defer m.close(io); 212 + 213 + var buffer: [64]u8 = undefined; // deliberately smaller than the message 214 + var delivery = try m.beginDelivery(io, 10); 215 + errdefer delivery.abort(io); 216 + const w = delivery.writer(io, &buffer); 217 + try w.writeAll(message_text[0..10]); 218 + try w.writeAll(message_text[10..]); 219 + const delivered = try delivery.commit(io, .{}); 220 + 221 + const bytes = try delivered.read(&m, testing.allocator, io, .unlimited); 222 + defer testing.allocator.free(bytes); 223 + try testing.expectEqualStrings(message_text, bytes); 224 + // The size was not given, so `commit` asked the file. 225 + try testing.expectEqual(@as(?u64, message_text.len), delivered.name().size()); 226 + } 227 + 228 + test "an aborted delivery leaves nothing anywhere" { 229 + var tmp = testing.tmpDir(.{ .iterate = true }); 230 + defer tmp.cleanup(); 231 + 232 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 233 + defer m.close(io); 234 + 235 + var delivery = try m.beginDelivery(io, 10); 236 + var buffer: [64]u8 = undefined; 237 + const w = delivery.writer(io, &buffer); 238 + try w.writeAll("half a mess"); 239 + delivery.abort(io); 240 + 241 + try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); 242 + try testing.expectEqual(@as(usize, 0), try count(&m, .new)); 243 + 244 + // Aborting twice, or aborting a committed delivery, is harmless -- which 245 + // is what lets it be an errdefer beside a commit. 246 + delivery.abort(io); 247 + } 248 + 249 + test "setting a flag moves the message from new to cur" { 250 + var tmp = testing.tmpDir(.{ .iterate = true }); 251 + defer tmp.cleanup(); 252 + 253 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 254 + defer m.close(io); 255 + 256 + var message = try m.deliver(io, message_text, .{}); 257 + const id = try testing.allocator.dupe(u8, message.id()); 258 + defer testing.allocator.free(id); 259 + 260 + try message.setFlags(&m, io, .{ .seen = true }); 261 + 262 + // There is nowhere in `new` for a flag to be written, so marking a 263 + // message read necessarily moves it. 264 + try testing.expectEqual(Maildir.Subdir.cur, message.subdir); 265 + try testing.expectEqual(@as(usize, 0), try count(&m, .new)); 266 + try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); 267 + try testing.expect(message.flags().seen); 268 + 269 + // And the thing that identifies the message did not change, which is what 270 + // makes it the same message. 271 + try testing.expectEqualStrings(id, message.id()); 272 + 273 + // The file really is under the new name and not the old one. 274 + const bytes = try message.read(&m, testing.allocator, io, .unlimited); 275 + defer testing.allocator.free(bytes); 276 + try testing.expectEqualStrings(message_text, bytes); 277 + } 278 + 279 + test "adding and removing flags leaves the others alone" { 280 + var tmp = testing.tmpDir(.{ .iterate = true }); 281 + defer tmp.cleanup(); 282 + 283 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 284 + defer m.close(io); 285 + 286 + var message = try m.deliver(io, message_text, .{}); 287 + try message.setFlags(&m, io, .{ .seen = true, .flagged = true }); 288 + try message.addFlags(&m, io, .{ .replied = true }); 289 + try testing.expect(message.flags().seen and message.flags().flagged and message.flags().replied); 290 + 291 + try message.removeFlags(&m, io, .{ .flagged = true }); 292 + try testing.expect(message.flags().seen and message.flags().replied); 293 + try testing.expect(!message.flags().flagged); 294 + try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,RS")); 295 + } 296 + 297 + test "a keyword another program set survives a flag change" { 298 + var tmp = testing.tmpDir(.{ .iterate = true }); 299 + defer tmp.cleanup(); 300 + 301 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 302 + defer m.close(io); 303 + 304 + // Deliver with a Dovecot keyword already on it, as Dovecot would. 305 + var flagged: Flags = .{ .seen = true }; 306 + flagged.setLetter('b', true); 307 + var message = try m.deliver(io, message_text, .{ .to = .{ .cur = flagged } }); 308 + 309 + try message.addFlags(&m, io, .{ .replied = true }); 310 + try testing.expect(message.flags().hasLetter('b')); 311 + try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,RSb")); 312 + } 313 + 314 + test "moveToCur files a message without claiming anybody read it" { 315 + var tmp = testing.tmpDir(.{ .iterate = true }); 316 + defer tmp.cleanup(); 317 + 318 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 319 + defer m.close(io); 320 + 321 + var message = try m.deliver(io, message_text, .{}); 322 + try message.moveToCur(&m, io); 323 + try testing.expectEqual(Maildir.Subdir.cur, message.subdir); 324 + try testing.expectEqual(Flags.none, message.flags()); 325 + try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,")); 326 + 327 + // Doing it again is a no-op rather than a second rename. 328 + const before = try testing.allocator.dupe(u8, message.filename()); 329 + defer testing.allocator.free(before); 330 + try message.moveToCur(&m, io); 331 + try testing.expectEqualStrings(before, message.filename()); 332 + } 333 + 334 + test "find locates a message again after its flags have changed" { 335 + var tmp = testing.tmpDir(.{ .iterate = true }); 336 + defer tmp.cleanup(); 337 + 338 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 339 + defer m.close(io); 340 + 341 + var message = try m.deliver(io, message_text, .{}); 342 + const id = try testing.allocator.dupe(u8, message.id()); 343 + defer testing.allocator.free(id); 344 + 345 + try testing.expect((try m.find(io, id)) != null); 346 + try message.setFlags(&m, io, .{ .seen = true, .replied = true }); 347 + 348 + const found = (try m.find(io, id)).?; 349 + try testing.expectEqual(Maildir.Subdir.cur, found.subdir); 350 + try testing.expect(found.flags().seen); 351 + try testing.expectEqual(@as(?maildir.Message, null), try m.find(io, "not-a-message")); 352 + } 353 + 354 + test "remove is the expunge that trashed only asks for" { 355 + var tmp = testing.tmpDir(.{ .iterate = true }); 356 + defer tmp.cleanup(); 357 + 358 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 359 + defer m.close(io); 360 + 361 + var message = try m.deliver(io, message_text, .{}); 362 + try message.addFlags(&m, io, .{ .trashed = true }); 363 + // Marked, and still there. 364 + try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); 365 + 366 + try message.remove(&m, io); 367 + try testing.expectEqual(@as(usize, 0), try count(&m, .cur)); 368 + } 369 + 370 + test "list gathers both subdirectories" { 371 + var tmp = testing.tmpDir(.{ .iterate = true }); 372 + defer tmp.cleanup(); 373 + 374 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 375 + defer m.close(io); 376 + 377 + _ = try m.deliver(io, message_text, .{}); 378 + _ = try m.deliver(io, message_text, .{}); 379 + _ = try m.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true } } }); 380 + 381 + const all = try m.list(testing.allocator, io, &.{ .new, .cur }); 382 + defer testing.allocator.free(all); 383 + try testing.expectEqual(@as(usize, 3), all.len); 384 + 385 + const just_new = try m.list(testing.allocator, io, &.{.new}); 386 + defer testing.allocator.free(just_new); 387 + try testing.expectEqual(@as(usize, 2), just_new.len); 388 + } 389 + 390 + test "cleanTemp removes old wreckage and leaves a delivery in progress alone" { 391 + var tmp = testing.tmpDir(.{ .iterate = true }); 392 + defer tmp.cleanup(); 393 + 394 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 395 + defer m.close(io); 396 + 397 + // A delivery that died: a file in `tmp` nobody will ever rename. 398 + const wreck = try m.tmp.createFile(io, "abandoned", .{}); 399 + wreck.close(io); 400 + 401 + // With the specified threshold it is far too young to touch, which is the 402 + // property that matters -- deleting a file out from under a delivery in 403 + // progress loses the message. 404 + try testing.expectEqual(@as(usize, 0), try m.cleanTemp(io, Maildir.temp_max_age)); 405 + 406 + // With no grace period at all it goes. 407 + try testing.expectEqual(@as(usize, 1), try m.cleanTemp(io, .{ .nanoseconds = 0 })); 408 + try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); 409 + } 410 + 411 + test "a message parses through zig-mime" { 412 + var tmp = testing.tmpDir(.{ .iterate = true }); 413 + defer tmp.cleanup(); 414 + 415 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 416 + defer m.close(io); 417 + 418 + const delivered = try m.deliver(io, message_text, .{}); 419 + 420 + var parsed = try delivered.parse(&m, testing.allocator, io, .unlimited, .{}); 421 + defer parsed.deinit(); 422 + 423 + try testing.expectEqualStrings("A test", (try parsed.root.subject()).?); 424 + 425 + var from = try parsed.root.addresses(testing.allocator, "from"); 426 + defer from.deinit(testing.allocator); 427 + try testing.expectEqual(@as(usize, 1), from.addresses.len); 428 + } 429 + 430 + test "a message written by zig-mime is delivered and reads back the same" { 431 + var tmp = testing.tmpDir(.{ .iterate = true }); 432 + defer tmp.cleanup(); 433 + 434 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 435 + defer m.close(io); 436 + 437 + const mime = @import("mime"); 438 + var built: mime.Message = try .init(testing.allocator); 439 + defer built.deinit(); 440 + try built.root.setSubject("Grüße"); 441 + try built.setText("Schöne Grüße.\n", .{}); 442 + 443 + // The streaming delivery exists for exactly this: the message is written 444 + // straight into `tmp` rather than into a buffer and then into `tmp`. 445 + var buffer: [4096]u8 = undefined; 446 + var delivery = try m.beginDelivery(io, 10); 447 + errdefer delivery.abort(io); 448 + try built.write(delivery.writer(io, &buffer)); 449 + const delivered = try delivery.commit(io, .{}); 450 + 451 + var parsed = try delivered.parse(&m, testing.allocator, io, .unlimited, .{}); 452 + defer parsed.deinit(); 453 + try testing.expectEqualStrings("Grüße", (try parsed.root.subject()).?); 454 + } 455 + 456 + // -- Maildir++ ---------------------------------------------------------------- 457 + 458 + test "a store has an inbox and folders beside it" { 459 + var tmp = testing.tmpDir(.{ .iterate = true }); 460 + defer tmp.cleanup(); 461 + 462 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 463 + defer store.close(io); 464 + 465 + var inbox = try store.inbox(io); 466 + defer inbox.close(io); 467 + _ = try inbox.deliver(io, message_text, .{}); 468 + 469 + var sent = try store.createFolder(io, &.{"Sent"}); 470 + defer sent.close(io); 471 + _ = try sent.deliver(io, message_text, .{}); 472 + 473 + // Two mailboxes, one message each, and neither can see the other's. 474 + try testing.expectEqual(@as(usize, 1), try count(&inbox, .new)); 475 + try testing.expectEqual(@as(usize, 1), try count(&sent, .new)); 476 + 477 + // On disk the folder is a hidden directory in the top-level maildir. 478 + const stat = try store.dir.statFile(io, ".Sent", .{}); 479 + try testing.expectEqual(Io.File.Kind.directory, stat.kind); 480 + // And it carries the marker that says it is a folder. 481 + _ = try store.dir.statFile(io, ".Sent/maildirfolder", .{}); 482 + } 483 + 484 + test "a nested folder creates the folders above it" { 485 + var tmp = testing.tmpDir(.{ .iterate = true }); 486 + defer tmp.cleanup(); 487 + 488 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 489 + defer store.close(io); 490 + 491 + var reports = try store.createFolder(io, &.{ "Work", "Reports" }); 492 + reports.close(io); 493 + 494 + // `.Work.Reports` is a sibling of `.Work` on disk, not a child of it. 495 + try testing.expect(try store.hasFolder(io, &.{"Work"})); 496 + try testing.expect(try store.hasFolder(io, &.{ "Work", "Reports" })); 497 + try testing.expect(!try store.hasFolder(io, &.{"Nothing"})); 498 + 499 + var list = try store.folders(testing.allocator, io); 500 + defer list.deinit(testing.allocator); 501 + try testing.expectEqual(@as(usize, 2), list.names.len); 502 + // Sorted, so a parent comes before its children. 503 + try testing.expectEqualStrings(".Work", list.names[0]); 504 + try testing.expectEqualStrings(".Work.Reports", list.names[1]); 505 + } 506 + 507 + test "a folder path with a delimiter of the caller's choosing" { 508 + var tmp = testing.tmpDir(.{ .iterate = true }); 509 + defer tmp.cleanup(); 510 + 511 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 512 + defer store.close(io); 513 + 514 + var created = try store.createFolderPath(io, "Work/Reports", '/'); 515 + created.close(io); 516 + 517 + var opened = try store.openFolderPath(io, "Work/Reports", '/'); 518 + opened.close(io); 519 + 520 + // The same folder, reached the other way. 521 + var by_components = try store.openFolder(io, &.{ "Work", "Reports" }); 522 + by_components.close(io); 523 + 524 + // A name with a dot in it cannot be a Maildir++ folder, and saying so is 525 + // better than creating the wrong one. 526 + try testing.expectError( 527 + error.InvalidComponent, 528 + store.createFolder(io, &.{"example.com"}), 529 + ); 530 + } 531 + 532 + test "deleting a folder with children needs saying so" { 533 + var tmp = testing.tmpDir(.{ .iterate = true }); 534 + defer tmp.cleanup(); 535 + 536 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 537 + defer store.close(io); 538 + 539 + var reports = try store.createFolder(io, &.{ "Work", "Reports" }); 540 + _ = try reports.deliver(io, message_text, .{}); 541 + reports.close(io); 542 + 543 + try testing.expectError( 544 + error.FolderNotEmpty, 545 + store.deleteFolder(testing.allocator, io, &.{"Work"}, .{}), 546 + ); 547 + try testing.expect(try store.hasFolder(io, &.{ "Work", "Reports" })); 548 + 549 + try store.deleteFolder(testing.allocator, io, &.{"Work"}, .{ .recursive = true }); 550 + try testing.expect(!try store.hasFolder(io, &.{"Work"})); 551 + try testing.expect(!try store.hasFolder(io, &.{ "Work", "Reports" })); 552 + } 553 + 554 + test "a folder whose name only looks like a child is not deleted with it" { 555 + var tmp = testing.tmpDir(.{ .iterate = true }); 556 + defer tmp.cleanup(); 557 + 558 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 559 + defer store.close(io); 560 + 561 + var work = try store.createFolder(io, &.{"Work"}); 562 + work.close(io); 563 + var workshop = try store.createFolder(io, &.{"Workshop"}); 564 + workshop.close(io); 565 + 566 + try store.deleteFolder(testing.allocator, io, &.{"Work"}, .{ .recursive = true }); 567 + try testing.expect(!try store.hasFolder(io, &.{"Work"})); 568 + try testing.expect(try store.hasFolder(io, &.{"Workshop"})); 569 + } 570 + 571 + test "moving a message between folders keeps its flags and changes its name" { 572 + var tmp = testing.tmpDir(.{ .iterate = true }); 573 + defer tmp.cleanup(); 574 + 575 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 576 + defer store.close(io); 577 + 578 + var inbox = try store.inbox(io); 579 + defer inbox.close(io); 580 + var archive = try store.createFolder(io, &.{"Archive"}); 581 + defer archive.close(io); 582 + 583 + var message = try inbox.deliver(io, message_text, .{}); 584 + try message.setFlags(&inbox, io, .{ .seen = true, .replied = true }); 585 + const before = try testing.allocator.dupe(u8, message.id()); 586 + defer testing.allocator.free(before); 587 + 588 + try message.moveTo(&inbox, &archive, io); 589 + 590 + try testing.expectEqual(@as(usize, 0), try count(&inbox, .cur)); 591 + try testing.expectEqual(@as(usize, 1), try count(&archive, .cur)); 592 + // The flags travelled... 593 + try testing.expect(message.flags().seen and message.flags().replied); 594 + // ...and the size the name recorded is still true... 595 + try testing.expectEqual(@as(?u64, message_text.len), message.name().size()); 596 + // ...but the identity did not, because nothing coordinates names between 597 + // two directories. 598 + try testing.expect(!std.mem.eql(u8, before, message.id())); 599 + 600 + const bytes = try message.read(&archive, testing.allocator, io, .unlimited); 601 + defer testing.allocator.free(bytes); 602 + try testing.expectEqualStrings(message_text, bytes); 603 + } 604 + 605 + test "a message moved out of new stays new" { 606 + var tmp = testing.tmpDir(.{ .iterate = true }); 607 + defer tmp.cleanup(); 608 + 609 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 610 + defer store.close(io); 611 + 612 + var inbox = try store.inbox(io); 613 + defer inbox.close(io); 614 + var other = try store.createFolder(io, &.{"Other"}); 615 + defer other.close(io); 616 + 617 + var message = try inbox.deliver(io, message_text, .{}); 618 + try message.moveTo(&inbox, &other, io); 619 + try testing.expectEqual(Maildir.Subdir.new, message.subdir); 620 + try testing.expectEqual(@as(usize, 1), try count(&other, .new)); 621 + } 622 + 623 + // -- quota -------------------------------------------------------------------- 624 + 625 + test "a store with no quota file says so rather than failing" { 626 + var tmp = testing.tmpDir(.{ .iterate = true }); 627 + defer tmp.cleanup(); 628 + 629 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 630 + defer store.close(io); 631 + 632 + try testing.expectEqual( 633 + @as(?maildir.quota.State, null), 634 + try store.quotaState(testing.allocator, io), 635 + ); 636 + // Recording usage against a store with no quota is a no-op, not an error: 637 + // a store nobody set a quota on is not one to start a ledger for. 638 + try store.recordUsage(io, .{ .bytes = 100, .messages = 1 }); 639 + try testing.expectEqual( 640 + @as(?maildir.quota.State, null), 641 + try store.quotaState(testing.allocator, io), 642 + ); 643 + } 644 + 645 + test "the ledger totals the changes appended to it" { 646 + var tmp = testing.tmpDir(.{ .iterate = true }); 647 + defer tmp.cleanup(); 648 + 649 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 650 + defer store.close(io); 651 + 652 + try store.setQuota(io, .{ .bytes = 10485760, .messages = 1000 }, .zero); 653 + 654 + try store.recordUsage(io, .{ .bytes = 4211, .messages = 1 }); 655 + try store.recordUsage(io, .{ .bytes = 8320, .messages = 1 }); 656 + try store.recordUsage(io, .{ .bytes = -4211, .messages = -1 }); 657 + 658 + const state = (try store.quotaState(testing.allocator, io)).?; 659 + try testing.expectEqual(@as(i64, 8320), state.usage.bytes); 660 + try testing.expectEqual(@as(i64, 1), state.usage.messages); 661 + try testing.expectEqual(@as(?u64, 10485760), state.limits.bytes); 662 + // One line from `setQuota` and three appended. 663 + try testing.expectEqual(@as(usize, 4), state.records); 664 + try testing.expect(!state.exceeded()); 665 + try testing.expect(!state.damaged); 666 + } 667 + 668 + test "over quota" { 669 + var tmp = testing.tmpDir(.{ .iterate = true }); 670 + defer tmp.cleanup(); 671 + 672 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 673 + defer store.close(io); 674 + 675 + try store.setQuota(io, .{ .bytes = 100 }, .{ .bytes = 500, .messages = 2 }); 676 + const state = (try store.quotaState(testing.allocator, io)).?; 677 + try testing.expect(state.exceeded()); 678 + } 679 + 680 + test "recalculating walks every folder and replaces the ledger" { 681 + var tmp = testing.tmpDir(.{ .iterate = true }); 682 + defer tmp.cleanup(); 683 + 684 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 685 + defer store.close(io); 686 + 687 + try store.setQuota(io, .{ .bytes = 10485760 }, .zero); 688 + 689 + var inbox = try store.inbox(io); 690 + defer inbox.close(io); 691 + _ = try inbox.deliver(io, message_text, .{}); 692 + _ = try inbox.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true } } }); 693 + 694 + var work = try store.createFolder(io, &.{"Work"}); 695 + defer work.close(io); 696 + _ = try work.deliver(io, message_text, .{}); 697 + 698 + // A message in `tmp` is not a message and must not be counted. 699 + const wreck = try inbox.tmp.createFile(io, "abandoned", .{}); 700 + try wreck.writeStreamingAll(io, "x" ** 1000); 701 + wreck.close(io); 702 + 703 + const total = try store.recalculateQuota(testing.allocator, io); 704 + try testing.expectEqual(@as(i64, 3), total.messages); 705 + try testing.expectEqual(@as(i64, 3 * message_text.len), total.bytes); 706 + 707 + // It was written back, the limits were kept, and the ledger is one line 708 + // again. 709 + const state = (try store.quotaState(testing.allocator, io)).?; 710 + try testing.expectEqual(@as(?u64, 10485760), state.limits.bytes); 711 + try testing.expectEqual(@as(i64, 3 * message_text.len), state.usage.bytes); 712 + try testing.expectEqual(@as(usize, 1), state.records); 713 + } 714 + 715 + test "recalculating a store that never had a quota records the usage anyway" { 716 + var tmp = testing.tmpDir(.{ .iterate = true }); 717 + defer tmp.cleanup(); 718 + 719 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 720 + defer store.close(io); 721 + 722 + var inbox = try store.inbox(io); 723 + defer inbox.close(io); 724 + _ = try inbox.deliver(io, message_text, .{}); 725 + 726 + _ = try store.recalculateQuota(testing.allocator, io); 727 + const state = (try store.quotaState(testing.allocator, io)).?; 728 + try testing.expect(!state.limits.isSet()); 729 + try testing.expectEqual(@as(i64, 1), state.usage.messages); 730 + } 731 + 732 + test "a damaged ledger is reported rather than silently miscounted" { 733 + var tmp = testing.tmpDir(.{ .iterate = true }); 734 + defer tmp.cleanup(); 735 + 736 + var store = try Store.create(tmp.dir, io, "Maildir", .{}); 737 + defer store.close(io); 738 + 739 + try store.dir.writeFile(io, .{ 740 + .sub_path = "maildirsize", 741 + .data = "1000S\n100 1\nnonsense\n50 1\n", 742 + }); 743 + 744 + const state = (try store.quotaState(testing.allocator, io)).?; 745 + try testing.expect(state.damaged); 746 + // The lines it could read still count. 747 + try testing.expectEqual(@as(i64, 150), state.usage.bytes); 748 + // And a damaged ledger always asks to be recomputed. 749 + try testing.expect(state.isStale(io, .{})); 750 + } 751 + 752 + test "a separator other than a colon is used throughout" { 753 + var tmp = testing.tmpDir(.{ .iterate = true }); 754 + defer tmp.cleanup(); 755 + 756 + var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .separator = '!' }); 757 + defer m.close(io); 758 + 759 + var message = try m.deliver(io, message_text, .{}); 760 + try message.setFlags(&m, io, .{ .seen = true }); 761 + try testing.expect(std.mem.endsWith(u8, message.filename(), "!2,S")); 762 + 763 + // And reading the maildir back with the same separator finds the flag. 764 + var reopened = try Maildir.open(tmp.dir, io, "Maildir", .{ .separator = '!' }); 765 + defer reopened.close(io); 766 + var it = reopened.iterate(.cur); 767 + const found = (try it.next(io)).?; 768 + try testing.expect(found.flags().seen); 769 + 770 + // Read with the default separator, the same file looks flagless -- which 771 + // is exactly the corruption the option exists to avoid. 772 + var misread = try Maildir.open(tmp.dir, io, "Maildir", .{}); 773 + defer misread.close(io); 774 + var misread_it = misread.iterate(.cur); 775 + const misfound = (try misread_it.next(io)).?; 776 + try testing.expect(!misfound.flags().seen); 777 + }
+334
tests/fuzz.zig
··· 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 + 33 + const builtin = @import("builtin"); 34 + const std = @import("std"); 35 + const Io = std.Io; 36 + const Dir = Io.Dir; 37 + const Allocator = std.mem.Allocator; 38 + const testing = std.testing; 39 + 40 + const maildir = @import("maildir"); 41 + const Flags = maildir.Flags; 42 + const Name = maildir.Name; 43 + const folder = maildir.folder; 44 + const 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. 52 + pub 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. 56 + pub 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 + 68 + pub 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 + 77 + const name_max = Dir.max_name_bytes; 78 + 79 + const 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. 100 + fn 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 + 131 + test "fuzz names" { 132 + for (name_seeds) |seed| try nameProperty(seed); 133 + try testing.fuzz({}, fuzzName, .{}); 134 + } 135 + 136 + fn fuzzName(_: void, smith: *testing.Smith) !void { 137 + var buffer: [name_max]u8 = undefined; 138 + try nameProperty(buffer[0..smith.slice(&buffer)]); 139 + } 140 + 141 + fn 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 + 149 + const flag_max = 256; 150 + 151 + const 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. 166 + fn 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 + 195 + test "fuzz flags" { 196 + for (flag_seeds) |seed| try flagsProperty(seed); 197 + try testing.fuzz({}, fuzzFlags, .{}); 198 + } 199 + 200 + fn fuzzFlags(_: void, smith: *testing.Smith) !void { 201 + var buffer: [flag_max]u8 = undefined; 202 + try flagsProperty(buffer[0..smith.slice(&buffer)]); 203 + } 204 + 205 + fn 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 + 213 + const folder_max = Dir.max_name_bytes; 214 + 215 + const 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. 232 + fn 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 + 259 + test "fuzz folder names" { 260 + for (folder_seeds) |seed| try folderProperty(seed); 261 + try testing.fuzz({}, fuzzFolder, .{}); 262 + } 263 + 264 + fn fuzzFolder(_: void, smith: *testing.Smith) !void { 265 + var buffer: [folder_max]u8 = undefined; 266 + try folderProperty(buffer[0..smith.slice(&buffer)]); 267 + } 268 + 269 + fn 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 + 277 + const quota_max = 4096; 278 + 279 + const 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. 294 + fn 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 + 320 + test "fuzz the quota ledger" { 321 + for (quota_seeds) |seed| try quotaProperty(seed); 322 + try testing.fuzz({}, fuzzQuota, .{}); 323 + } 324 + 325 + fn fuzzQuota(_: void, smith: *testing.Smith) !void { 326 + var buffer: [quota_max]u8 = undefined; 327 + try quotaProperty(buffer[0..smith.slice(&buffer)]); 328 + } 329 + 330 + fn 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 + }
+171
tools/docs_server.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! Serves the generated API documentation over HTTP, the way `zig std` serves 5 + //! the standard library's. 6 + //! 7 + //! A server is needed rather than just opening `index.html`, because the 8 + //! viewer fetches `sources.tar` and `main.wasm` at runtime and a browser 9 + //! refuses those requests from a `file://` page. 10 + //! 11 + //! Run through the build system: `zig build docs-serve`. It is deliberately 12 + //! minimal, serving one directory to one person on the loopback interface — 13 + //! it is a convenience for reading `zig build docs`, not a web server, and 14 + //! nothing about it should be pointed at a network. 15 + //! 16 + //! It is worth noting what this is *not*: this repository implements a file 17 + //! transfer protocol, and none of that is used here. HTTP is what a browser 18 + //! speaks, so HTTP is what this speaks. 19 + //! 20 + //! Every connection gets its own thread, which is not a throughput concern but 21 + //! a correctness one: a browser opens several connections at once and holds 22 + //! some of them open without sending anything, so a server that reads them one 23 + //! at a time blocks on a speculative connection and never answers the real 24 + //! requests. 25 + //! 26 + //! A thread each, rather than a pool: a connection handler blocks until its 27 + //! client goes away, which can be minutes, and a pool sized for short tasks 28 + //! wedges once every worker is parked on an idle socket. A browser opens a 29 + //! handful of connections, so the thread count stays small in practice. 30 + 31 + const std = @import("std"); 32 + const Io = std.Io; 33 + 34 + /// Nothing in a documentation bundle comes close to this; it exists so that a 35 + /// stray huge file cannot exhaust memory. 36 + const max_file_size = 64 * 1024 * 1024; 37 + 38 + pub fn main(init: std.process.Init) !void { 39 + const gpa = init.gpa; 40 + const io = init.io; 41 + const args = try init.minimal.args.toSlice(init.arena.allocator()); 42 + 43 + var stderr_buffer: [512]u8 = undefined; 44 + var stderr_writer: Io.File.Writer = .init(.stderr(), io, &stderr_buffer); 45 + const stderr = &stderr_writer.interface; 46 + 47 + if (args.len != 3) { 48 + try stderr.writeAll("usage: docs-server <directory> <port>\n"); 49 + try stderr.flush(); 50 + std.process.exit(2); 51 + } 52 + const docs_path = args[1]; 53 + const port = try std.fmt.parseInt(u16, args[2], 10); 54 + 55 + var docs_dir = Io.Dir.cwd().openDir(io, docs_path, .{}) catch |err| { 56 + try stderr.print("cannot open {s}: {s}\n", .{ docs_path, @errorName(err) }); 57 + try stderr.flush(); 58 + std.process.exit(1); 59 + }; 60 + defer docs_dir.close(io); 61 + 62 + const address: Io.net.IpAddress = .{ .ip4 = .{ .bytes = .{ 127, 0, 0, 1 }, .port = port } }; 63 + var server = address.listen(io, .{ .reuse_address = true }) catch |err| { 64 + try stderr.print("cannot listen on 127.0.0.1:{d}: {s}\n", .{ port, @errorName(err) }); 65 + if (err == error.AddressInUse) { 66 + try stderr.writeAll("another port can be chosen with -Ddocs-port=N\n"); 67 + } 68 + try stderr.flush(); 69 + std.process.exit(1); 70 + }; 71 + defer server.deinit(io); 72 + 73 + try stderr.print("serving {s} at http://127.0.0.1:{d}/\npress ctrl-c to stop\n", .{ docs_path, port }); 74 + try stderr.flush(); 75 + 76 + while (true) { 77 + const stream = server.accept(io) catch |err| switch (err) { 78 + // One client giving up is not a reason to stop serving. 79 + error.ConnectionAborted, error.WouldBlock, error.ProtocolFailure => continue, 80 + else => return err, 81 + }; 82 + 83 + const thread = std.Thread.spawn(.{}, handleConnection, .{ io, gpa, docs_dir, stream }) catch { 84 + // Out of threads. Serving it here blocks the ones behind it, but 85 + // dropping it silently would look like the same hang from the 86 + // browser's side with none of the progress. 87 + handleConnection(io, gpa, docs_dir, stream); 88 + continue; 89 + }; 90 + // Nothing joins these: each ends when its client disconnects, and the 91 + // process runs until interrupted. 92 + thread.detach(); 93 + } 94 + } 95 + 96 + /// Serves one connection until the client goes away, then closes it. 97 + fn handleConnection(io: Io, gpa: std.mem.Allocator, docs_dir: Io.Dir, stream: Io.net.Stream) void { 98 + defer stream.close(io); 99 + 100 + var recv_buffer: [16 * 1024]u8 = undefined; 101 + var send_buffer: [64 * 1024]u8 = undefined; 102 + var stream_reader = stream.reader(io, &recv_buffer); 103 + var stream_writer = stream.writer(io, &send_buffer); 104 + var http_server: std.http.Server = .init(&stream_reader.interface, &stream_writer.interface); 105 + 106 + // Keep answering on the same connection, so that one page load does not 107 + // need a connection per file. 108 + while (true) { 109 + var request = http_server.receiveHead() catch return; 110 + serve(&request, io, gpa, docs_dir) catch return; 111 + // A client that asked to close is waiting for exactly that; waiting 112 + // for another request it will never send would hang it until it times 113 + // out. 114 + if (!request.head.keep_alive) return; 115 + } 116 + } 117 + 118 + fn serve( 119 + request: *std.http.Server.Request, 120 + io: Io, 121 + gpa: std.mem.Allocator, 122 + docs_dir: Io.Dir, 123 + ) !void { 124 + const target = request.head.target; 125 + const path_end = std.mem.indexOfAny(u8, target, "?#") orelse target.len; 126 + var path = target[0..path_end]; 127 + if (std.mem.startsWith(u8, path, "/")) path = path[1..]; 128 + if (path.len == 0) path = "index.html"; 129 + 130 + // The documentation directory is the whole world this server knows about. 131 + if (std.mem.indexOf(u8, path, "..") != null or std.fs.path.isAbsolute(path)) { 132 + return request.respond("bad request\n", .{ .status = .bad_request }); 133 + } 134 + 135 + const content = docs_dir.readFileAlloc(io, path, gpa, .limited(max_file_size)) catch { 136 + return request.respond("not found\n", .{ .status = .not_found }); 137 + }; 138 + defer gpa.free(content); 139 + 140 + try request.respond(content, .{ 141 + .extra_headers = &.{.{ .name = "content-type", .value = mimeType(path) }}, 142 + }); 143 + } 144 + 145 + /// Enough of a MIME table for what `zig build docs` emits. 146 + fn mimeType(path: []const u8) []const u8 { 147 + const extension = std.fs.path.extension(path); 148 + const table = [_]struct { []const u8, []const u8 }{ 149 + .{ ".html", "text/html; charset=utf-8" }, 150 + .{ ".js", "text/javascript; charset=utf-8" }, 151 + .{ ".css", "text/css; charset=utf-8" }, 152 + .{ ".wasm", "application/wasm" }, 153 + .{ ".tar", "application/x-tar" }, 154 + .{ ".json", "application/json" }, 155 + .{ ".svg", "image/svg+xml" }, 156 + }; 157 + for (table) |entry| { 158 + if (std.mem.eql(u8, extension, entry[0])) return entry[1]; 159 + } 160 + return "application/octet-stream"; 161 + } 162 + 163 + const testing = std.testing; 164 + 165 + test mimeType { 166 + try testing.expectEqualStrings("text/html; charset=utf-8", mimeType("index.html")); 167 + try testing.expectEqualStrings("application/wasm", mimeType("main.wasm")); 168 + try testing.expectEqualStrings("application/x-tar", mimeType("sources.tar")); 169 + try testing.expectEqualStrings("text/javascript; charset=utf-8", mimeType("main.js")); 170 + try testing.expectEqualStrings("application/octet-stream", mimeType("noextension")); 171 + }
+393
tools/fuzz.zig
··· 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 + 64 + const std = @import("std"); 65 + const targets = @import("fuzz_targets"); 66 + 67 + const Smith = std.testing.Smith; 68 + 69 + /// Milliseconds on a clock that only goes forwards while the machine is up. 70 + fn 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. 75 + const 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 + 85 + var watch: Watch = .{}; 86 + 87 + pub 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 + 208 + fn 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 + 213 + fn 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. 232 + fn 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 + 274 + fn 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. 309 + const 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. 319 + fn 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. 347 + fn 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. 366 + fn 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 + }
+78
.forgejo/workflows/test.yaml
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + name: test 5 + 6 + on: 7 + push: 8 + workflow_dispatch: 9 + 10 + jobs: 11 + test: 12 + runs-on: nixos-do-s 13 + steps: 14 + - name: Check out 15 + uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 16 + 17 + - name: Check REUSE compliance 18 + run: nix develop -c reuse lint 19 + 20 + # `zig-pkg` is where Zig puts fetched dependencies, and it is full of 21 + # other people's source. It does not exist yet on a fresh checkout, but 22 + # it does as soon as anything has been built, so excluding it is what 23 + # makes this command mean the same thing here and on a developer's 24 + # machine. 25 + - name: Check Zig formatting compliance 26 + run: nix develop -c zig fmt --check --exclude zig-pkg . 27 + 28 + - name: Test 29 + run: nix develop -c zig build test --summary all 30 + 31 + - name: Build 32 + run: nix develop -c zig build 33 + 34 + # Nothing else builds the fuzz driver or the docs server, so without 35 + # this they could stop compiling and the test step would not notice. 36 + - name: Build everything that is not built by the steps above 37 + run: nix develop -c zig build check 38 + 39 + # Bounded by a count rather than a clock so that the run is the same on 40 + # every machine, and short because this is here to catch a property that 41 + # a change has made false rather than to search: a real campaign is 42 + # `zig build fuzz-run -- --seconds 600` on somebody's own machine. 43 + - name: Fuzz the parsers 44 + run: nix develop -c zig build fuzz-run -- --iterations 500000 45 + 46 + # The interoperability test boots a NixOS guest to run a real Dovecot 47 + # against a maildir this library wrote, so it needs a runner that can nest 48 + # virtualisation. The ephemeral DigitalOcean tiers can; the always-on one 49 + # deliberately cannot. 50 + interop: 51 + runs-on: nixos-do-s 52 + steps: 53 + - name: Check out 54 + uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 55 + 56 + - name: Dovecot reads what this library writes 57 + run: nix flake check --print-build-logs 58 + 59 + # Published only from main, so that a branch build cannot replace the site 60 + # with a work in progress, and only after `test` has passed, so that it 61 + # cannot document a tree that does not compile. 62 + docs: 63 + needs: test 64 + if: ${{ forge.ref == 'refs/heads/main' }} 65 + runs-on: nixos-do-s 66 + steps: 67 + - name: Check out 68 + uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 69 + 70 + - name: Build API docs 71 + run: nix develop -c zig build docs 72 + 73 + - name: Publish docs 74 + run: > 75 + nix develop -c git-pages-cli https://jeff.jcollie.page/zig-maildir/ 76 + --server jcollie.page 77 + --token ${{ forge.token }} 78 + --upload-dir zig-out/docs
+225
tests/nixos/dovecot.nix
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + # This library against a real Dovecot, which is the implementation everyone 5 + # else's maildir is checked against: it is what most IMAP mail is served from, 6 + # and where the conventions this library follows -- the `,S=` size field, the 7 + # lowercase keyword letters, the `maildirfolder` marker -- actually come from. 8 + # 9 + # It needs a virtual machine rather than a fixture in `zig build test` because 10 + # the claim being tested is that a *second program* agrees, and that program 11 + # wants a system: users, a mail directory it owns, and a daemon that indexes 12 + # what it finds there. 13 + # 14 + # Everything here is driven through `doveadm` rather than over IMAP. It is the 15 + # same mail code with the protocol taken off, and it does not need a login, 16 + # which keeps the test about maildirs rather than about PAM. 17 + 18 + { zig-maildir }: 19 + 20 + { 21 + name = "zig-maildir-dovecot"; 22 + 23 + nodes.machine = 24 + { config, pkgs, ... }: 25 + { 26 + environment.systemPackages = [ zig-maildir ]; 27 + 28 + users.users.alice = { 29 + isNormalUser = true; 30 + uid = 1000; 31 + home = "/home/alice"; 32 + }; 33 + 34 + services.dovecot2 = { 35 + enable = true; 36 + # Without a password database the auth service refuses to start at 37 + # all -- "No passdbs specified in configuration file" -- and then even 38 + # `doveadm`, which authenticates nobody, cannot look a user up. 39 + enablePAM = true; 40 + settings = { 41 + # Dovecot 2.4 refuses to start without being told which version of 42 + # the configuration format the file is written in, and which version 43 + # of the on-disk format the mail should stay readable by. Pinned to 44 + # whatever nixpkgs ships, since the test is rebuilt with it. 45 + dovecot_config_version = config.services.dovecot2.package.version; 46 + dovecot_storage_version = config.services.dovecot2.package.version; 47 + 48 + protocols.imap = true; 49 + mail_driver = "maildir"; 50 + mail_home = "/home/%{user | username}"; 51 + mail_path = "~/Maildir"; 52 + # A slash between the levels of the hierarchy, so that the names 53 + # `doveadm` prints are the ones `zig-maildir` is given. Dovecot 54 + # still stores them the Maildir++ way, as `.Work.Reports`; the 55 + # separator is only how they are spelled in the protocol, which is 56 + # exactly the distinction `folder.writeName` exists for. 57 + "namespace inbox" = { 58 + inbox = true; 59 + separator = "/"; 60 + }; 61 + }; 62 + }; 63 + }; 64 + 65 + testScript = '' 66 + machine.wait_for_unit("dovecot.service") 67 + 68 + # -- this library writes, Dovecot reads --------------------------------- 69 + 70 + machine.succeed("su alice -c 'zig-maildir create /home/alice/Maildir'") 71 + machine.succeed( 72 + "su alice -c \"printf 'From: jeff@example.com\\r\\n" 73 + "To: alice@example.com\\r\\n" 74 + "Subject: Written by zig-maildir\\r\\n" 75 + "Message-ID: <one@example.com>\\r\\n" 76 + "\\r\\nHello.\\r\\n' > /tmp/one.eml\"" 77 + ) 78 + machine.succeed("su alice -c 'zig-maildir deliver /home/alice/Maildir /tmp/one.eml'") 79 + 80 + # Dovecot finds the message this library delivered, and reads it as a 81 + # message rather than as a file: the subject comes out of its own index. 82 + status = machine.succeed("doveadm mailbox status -u alice messages INBOX") 83 + assert "messages=1" in status, status 84 + subjects = machine.succeed("doveadm fetch -u alice hdr mailbox INBOX all") 85 + assert "Written by zig-maildir" in subjects, subjects 86 + 87 + # The size this library wrote into the name is the size Dovecot reports, 88 + # which is the whole reason `,S=` is allowed to be trusted. 89 + on_disk = machine.succeed( 90 + "su alice -c 'zig-maildir list /home/alice/Maildir' | cut -f4" 91 + ).strip() 92 + reported = machine.succeed( 93 + "doveadm fetch -u alice size.physical mailbox INBOX all | tail -1" 94 + ).strip() 95 + assert on_disk in reported, "zig-maildir says " + on_disk + ", dovecot says " + reported 96 + 97 + # -- flags, in both directions ------------------------------------------ 98 + 99 + # Dovecot sets a flag; this library sees it. 100 + machine.succeed("doveadm flags add -u alice '\\Seen' mailbox INBOX all") 101 + listing = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") 102 + assert "\tS\t" in listing, listing 103 + 104 + # And it is still in `new`, which is the whole reason this library parses 105 + # a name leniently rather than by the rule. The specification says a 106 + # message in `new` has no info field; Dovecot, which has an index of its 107 + # own and does not need the directory to tell it what is unread, renames 108 + # the file *in place* and leaves `new/...,S=121:2,S` sitting there. A 109 + # reader that took the rule literally would show that message as unread 110 + # forever. It also kept the `,S=` this library wrote, rather than 111 + # recomputing or dropping it. 112 + assert listing.split("\t")[0] == "new", listing 113 + names = machine.succeed("ls -1 /home/alice/Maildir/new") 114 + assert ":2,S" in names, names 115 + assert ",S=" in names, names 116 + assert machine.succeed("ls -1 /home/alice/Maildir/cur").strip() == "" 117 + 118 + # This library sets a flag; Dovecot sees it. 119 + message_id = listing.split("\t")[4].strip() 120 + machine.succeed( 121 + "su alice -c 'zig-maildir flag /home/alice/Maildir " + message_id + " +R +F'" 122 + ) 123 + flags = machine.succeed("doveadm fetch -u alice flags mailbox INBOX all") 124 + assert "\\Answered" in flags, flags 125 + assert "\\Flagged" in flags, flags 126 + assert "\\Seen" in flags, flags 127 + 128 + # -- the one that would lose somebody's data ----------------------------- 129 + 130 + # Dovecot stores an IMAP keyword as a lowercase letter in the name, with a 131 + # `dovecot-keywords` file mapping the letter to the label. A program that 132 + # read `:2,FRSa`, changed a flag and wrote back `:2,FRS` would have 133 + # silently deleted a label the user applied -- so this is the assertion 134 + # that the `other` letters are carried through untouched. 135 + machine.succeed("doveadm flags add -u alice 'Important' mailbox INBOX all") 136 + before = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") 137 + assert "a" in before.split("\t")[2], before 138 + 139 + machine.succeed( 140 + "su alice -c 'zig-maildir flag /home/alice/Maildir " + message_id + " -F'" 141 + ) 142 + after = machine.succeed("doveadm fetch -u alice flags mailbox INBOX all") 143 + assert "Important" in after, after 144 + assert "\\Flagged" not in after, after 145 + 146 + # -- Maildir++ folders, in both directions ------------------------------- 147 + 148 + machine.succeed("su alice -c 'zig-maildir mkfolder /home/alice/Maildir Work/Reports'") 149 + mailboxes = machine.succeed("doveadm mailbox list -u alice") 150 + assert "Work" in mailboxes, mailboxes 151 + assert "Work/Reports" in mailboxes, mailboxes 152 + 153 + # On disk it is flat, which is what Maildir++ means by a hierarchy. 154 + machine.succeed("test -d /home/alice/Maildir/.Work.Reports/cur") 155 + machine.succeed("test -f /home/alice/Maildir/.Work.Reports/maildirfolder") 156 + 157 + # And a folder Dovecot made is one this library lists. 158 + machine.succeed("doveadm mailbox create -u alice Archive") 159 + folders = machine.succeed("su alice -c 'zig-maildir folders /home/alice/Maildir'") 160 + assert "Archive" in folders, folders 161 + assert "Work/Reports" in folders, folders 162 + 163 + # -- a message moved between folders ------------------------------------- 164 + 165 + machine.succeed( 166 + "su alice -c 'zig-maildir move /home/alice/Maildir " + message_id + " Archive'" 167 + ) 168 + inbox_count = machine.succeed("doveadm mailbox status -u alice messages INBOX") 169 + assert "messages=0" in inbox_count, inbox_count 170 + archive_count = machine.succeed("doveadm mailbox status -u alice messages Archive") 171 + assert "messages=1" in archive_count, archive_count 172 + 173 + # The standard flags travelled with it, even though the name did not: two 174 + # directories do not coordinate their names, so a moved message 175 + # necessarily gets a new one. 176 + moved = machine.succeed("doveadm fetch -u alice flags mailbox Archive all") 177 + assert "\\Seen" in moved, moved 178 + assert "\\Answered" in moved, moved 179 + 180 + # The keyword did not, and this is the sharp edge worth pinning down: a 181 + # keyword's *letter* lives in the file name, but what that letter means 182 + # lives in a `dovecot-keywords` file inside each mailbox. The letter is 183 + # carried across by the rename; the mapping is not, so Dovecot reads it in 184 + # Archive as a keyword it has never heard of. `Message.moveTo` says so, and 185 + # there is nothing this library can do about it -- that mapping file is 186 + # Dovecot's, not the maildir's. 187 + assert "unknown-" in moved, moved 188 + assert "Important" not in moved, moved 189 + 190 + # -- Dovecot delivers, this library reads -------------------------------- 191 + 192 + machine.succeed( 193 + "printf 'From: someone@example.net\\r\\n" 194 + "To: alice@example.com\\r\\n" 195 + "Subject: Written by dovecot\\r\\n" 196 + "\\r\\nBody.\\r\\n' | doveadm save -u alice -m INBOX" 197 + ) 198 + listing = machine.succeed("su alice -c 'zig-maildir list /home/alice/Maildir'") 199 + assert "INBOX" in listing, listing 200 + 201 + saved_id = [ 202 + line.split("\t")[4] 203 + for line in listing.strip().split("\n") 204 + if line.split("\t")[1] == "INBOX" 205 + ][0] 206 + headers = machine.succeed( 207 + "su alice -c 'zig-maildir headers /home/alice/Maildir " + saved_id + "'" 208 + ) 209 + assert "Written by dovecot" in headers, headers 210 + assert "someone@example.net" in headers, headers 211 + 212 + # -- the quota ledger ---------------------------------------------------- 213 + 214 + machine.succeed("su alice -c 'zig-maildir quota /home/alice/Maildir 10485760 1000'") 215 + machine.succeed("su alice -c 'zig-maildir recalc /home/alice/Maildir'") 216 + quota = machine.succeed("su alice -c 'zig-maildir quota /home/alice/Maildir'") 217 + assert "2 messages" in quota, quota 218 + assert "over\tFalse" in quota or "over\tfalse" in quota, quota 219 + 220 + # `maildirsize` is Courier's file, and Dovecot leaves a file it does not 221 + # use alone rather than deleting it -- which is what makes it safe to keep 222 + # one in a store Dovecot is also serving. 223 + machine.succeed("test -f /home/alice/Maildir/maildirsize") 224 + ''; 225 + }