A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
8.3 kB
188 lines
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
42const std = @import("std");
43const Io = std.Io;
44const 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.
49pub 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.
60pub 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.
72pub 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
80fn 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.
89pub 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
149test "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
161test "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
172test "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
179test "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
185test "the system host name is never empty" {
186 var buffer: [max_hostname]u8 = undefined;
187 try testing.expect(systemHostname(&buffer).len > 0);
188}