A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-maildir / src / unique.zig
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}