//! Refcounted certificate holder for the DoH/DoT listeners (PLAN §9, //! milestone-10 rulings 6-7). One `CertStore` owns the published //! `tls_server.ServerContext` generation for one endpoint; listeners `acquire` //! it per connection and `release` it when the connection ends, so a reload //! never frees a context while a handshake or stream still uses it. //! //! Reload is publish-nothing-on-failure (PLAN:297): both PEM files are read //! and a whole new context built before anything is swapped, and any failure //! leaves the old generation serving. The watcher polls `statFile` every //! `poll_interval_s` — there is no usable file-change notification inside //! `std.Io` — and compares mtime+size of BOTH files against the pair recorded //! at the last successful load, so a same-second atomic rename still registers //! through the size. const std = @import("std"); const events = @import("../storage/events.zig"); const tls_server = @import("../platform/tls_server.zig"); const log = std.log.scoped(.cert_store); pub const poll_interval_s = 30; /// Largest PEM file `reload` accepts, applied to the certificate chain and the /// key separately. Real chains are a few KiB; anything bigger is a wrong file. pub const max_pem_bytes = 64 * 1024; pub const ReloadError = error{ OutOfMemory, /// The certificate file could not be opened, read, or stat'ed. CertUnreadable, CertTooLarge, /// The private key file could not be opened, read, or stat'ed. KeyUnreadable, KeyTooLarge, /// The certificate chain PEM could not be parsed. CertParse, /// The private key PEM could not be parsed. KeyParse, /// The private key does not belong to the leaf certificate. KeyMismatch, /// Seeding the CTR-DRBG from the platform entropy source failed. EntropyFailed, /// Mbed TLS rejected the server configuration. ConfigFailed, }; /// One line for the operator, used by `POST /api/certs/reload` as the `error` /// payload field and by the watcher's warning. pub fn humanMessage(err: ReloadError) []const u8 { return switch (err) { error.OutOfMemory => "out of memory", error.CertUnreadable => "certificate file is not readable", error.CertTooLarge => "certificate file exceeds 64 KiB", error.KeyUnreadable => "private key file is not readable", error.KeyTooLarge => "private key file exceeds 64 KiB", error.CertParse => "certificate PEM could not be parsed", error.KeyParse => "private key PEM could not be parsed", error.KeyMismatch => "private key does not belong to the certificate", error.EntropyFailed => "seeding the TLS random generator failed", error.ConfigFailed => "building the TLS server configuration failed", }; } /// The identity of one file's content as far as the watcher can see it /// without reading: modification time and size together, because an atomic /// rename within one filesystem timestamp granule still changes the size in /// any realistic re-issue, and a truncate-and-rewrite changes the mtime. pub const FileSig = struct { mtime_ns: i96, size: u64, }; /// The stat pair recorded at the last successful load. pub const Signature = struct { cert: FileSig, key: FileSig, }; /// The watcher's whole decision, pure so a table can test it: reload exactly /// when either file's mtime or size differs from the loaded pair. pub fn changed(loaded: Signature, observed: Signature) bool { return fileChanged(loaded.cert, observed.cert) or fileChanged(loaded.key, observed.key); } fn fileChanged(loaded: FileSig, observed: FileSig) bool { return loaded.mtime_ns != observed.mtime_ns or loaded.size != observed.size; } /// One published generation. Freed only when `retired` and `refs == 0`; both /// transitions happen under the store's mutex, so the last releaser (or the /// reload that retires an idle entry) frees it exactly once. pub const Entry = struct { ctx: tls_server.ServerContext, refs: u32, retired: bool, }; /// See `CertStore.after_load_hook`. pub const ReloadHook = struct { ctx: *anyopaque, call: *const fn (ctx: *anyopaque, io: std.Io) void, }; pub const CertStore = struct { /// Which endpoint's certificate this store holds. It is the subject of /// every `certificate.reload` event, and a box serving both DoH and DoT /// runs two stores over two file pairs. pub const Kind = enum { doh, dot }; gpa: std.mem.Allocator, /// Owned. A settings apply replaces both paths, so a borrowed config slice /// would dangle the moment the row it came from went away. Read and /// written only under `reload_mutex`, which every apply and every reload /// holds for its whole read-build-publish sequence. cert_path: []u8, /// Owned; see `cert_path`. key_path: []u8, /// Passed through to every `ServerContext.init`; the same lifetime rule /// applies — a comptime-constant NULL-terminated array, never memory that /// can go away before the store. alpn: ?[*:null]const ?[*:0]const u8, /// Guards `current`, `loaded`, and every `Entry.refs`/`Entry.retired`. /// `lockUncancelable` throughout: `acquire`/`release` sit on the /// per-connection path with no error union for `error.Canceled`, and no /// critical section here holds I/O. mutex: std.Io.Mutex, /// Serializes whole reloads (read files, build context, publish) so an /// overlapping reload cannot publish an older read after a newer one: /// whichever reload reads the files last publishes last. Held across file /// I/O, which is fine — `acquire`/`release` never touch it, so connection /// latency is unchanged. Lock order: `reload_mutex` is always taken BEFORE /// `mutex` and never while holding it. reload_mutex: std.Io.Mutex, current: *Entry, loaded: Signature, /// Test seam: runs inside `reload` between a successful `load` and the /// publish, i.e. inside `reload_mutex`. Lets a test occupy the window /// where an unserialized reload could be overtaken. Must not call /// `reload`, `pollOnce` or `preparePathChange` synchronously — all four /// take `reload_mutex`, which is not reentrant. after_load_hook: ?ReloadHook, /// Set by the composition root right after `init`, with `diagnostics`. kind: Kind = .doh, /// Wired the same way and for the same reason as every other subsystem's: /// the store is fully usable without it, and `nxdns check` has none. diagnostics: ?*events.Store = null, reloads: std.atomic.Value(u64), reload_failures: std.atomic.Value(u64), /// Wall-clock second of the last successful load, including the one in /// `init`. last_reload_unix: std.atomic.Value(i64), pub const Stats = struct { reloads: u64, reload_failures: u64, last_reload_unix: i64, }; /// Performs the initial load. A failure here is the boot-time "bad cert" /// case: nothing is constructed and the caller exits with /// `humanMessage(err)` (milestone-10 ruling 11). pub fn init( gpa: std.mem.Allocator, io: std.Io, cert_path: []const u8, key_path: []const u8, alpn: ?[*:null]const ?[*:0]const u8, ) ReloadError!CertStore { const first = try load(gpa, io, cert_path, key_path, alpn); errdefer { first.entry.ctx.deinit(gpa); gpa.destroy(first.entry); } const owned_cert = try gpa.dupe(u8, cert_path); errdefer gpa.free(owned_cert); const owned_key = try gpa.dupe(u8, key_path); return .{ .gpa = gpa, .cert_path = owned_cert, .key_path = owned_key, .alpn = alpn, .mutex = .init, .reload_mutex = .init, .current = first.entry, .loaded = first.sig, .after_load_hook = null, .reloads = .init(0), .reload_failures = .init(0), .last_reload_unix = .init(std.Io.Clock.real.now(io).toSeconds()), }; } /// Every listener must have released its entries first: the current /// generation must be idle, and a retired generation with refs would have /// been freed by its last release already. pub fn deinit(self: *CertStore, io: std.Io) void { self.mutex.lockUncancelable(io); const current = self.current; std.debug.assert(current.refs == 0); self.mutex.unlock(io); self.destroyEntry(current); self.gpa.free(self.cert_path); self.gpa.free(self.key_path); self.* = undefined; } /// Pins the current generation for one connection. The returned entry /// stays valid until the matching `release`, across any number of reloads. pub fn acquire(self: *CertStore, io: std.Io) *Entry { self.mutex.lockUncancelable(io); defer self.mutex.unlock(io); self.current.refs += 1; return self.current; } pub fn release(self: *CertStore, io: std.Io, entry: *Entry) void { self.mutex.lockUncancelable(io); std.debug.assert(entry.refs > 0); entry.refs -= 1; const free_it = entry.retired and entry.refs == 0; self.mutex.unlock(io); if (free_it) self.destroyEntry(entry); } /// Builds a whole new generation from the files on disk, then swaps it in /// and retires the old one. On ANY failure the store is untouched: the old /// generation keeps serving and the loaded signature keeps its value, so /// the watcher retries on its next poll. /// /// `reload_mutex` covers the whole read-build-publish sequence: without /// it, a reload could read the files, stall in `ServerContext.init`, and /// publish that stale read over a generation another reload already /// published from newer bytes. pub fn reload(self: *CertStore, io: std.Io) ReloadError!void { self.reload_mutex.lockUncancelable(io); defer self.reload_mutex.unlock(io); return self.reloadLocked(io); } /// `reload`'s body, for callers that already hold `reload_mutex` — /// `pollOnce` does, because it must read `cert_path`/`key_path` under the /// same lock an apply replaces them under. `std.Io.Mutex` is not /// reentrant, so this exists rather than a recursive `reload` call. fn reloadLocked(self: *CertStore, io: std.Io) ReloadError!void { const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| { _ = self.reload_failures.fetchAdd(1, .monotonic); return err; }; if (self.after_load_hook) |hook| hook.call(hook.ctx, io); self.mutex.lockUncancelable(io); const old = self.current; self.current = next.entry; self.loaded = next.sig; old.retired = true; const free_old = old.refs == 0; self.mutex.unlock(io); if (free_old) self.destroyEntry(old); _ = self.reloads.fetchAdd(1, .monotonic); self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic); } /// A candidate certificate loaded from new paths, not yet published. /// Holding one means holding `reload_mutex`: exactly one of /// `publishPathChange` or `abortPathChange` must follow, and it releases /// the lock. pub const PreparedPaths = struct { cert_path: []u8, key_path: []u8, loaded: Loaded, /// Read at prepare so publish reads no clock: publish must touch /// nothing outside memory it already owns. loaded_at_unix: i64, }; /// Prepare half of a `doh_server`/`dot_server` cert-path change: takes /// `reload_mutex` and loads the certificate and key from the NEW paths. /// Nothing is published, so a failure leaves the store exactly as it was — /// the old certificate keeps serving and the caller writes no DB row. The /// lock is released on failure and held on success, which is what makes /// the whole apply serialized against `reload` and `pollOnce`. pub fn preparePathChange( self: *CertStore, io: std.Io, cert_path: []const u8, key_path: []const u8, ) ReloadError!PreparedPaths { self.reload_mutex.lockUncancelable(io); errdefer self.reload_mutex.unlock(io); const owned_cert = try self.gpa.dupe(u8, cert_path); errdefer self.gpa.free(owned_cert); const owned_key = try self.gpa.dupe(u8, key_path); errdefer self.gpa.free(owned_key); const next = load(self.gpa, io, cert_path, key_path, self.alpn) catch |err| { _ = self.reload_failures.fetchAdd(1, .monotonic); return err; }; return .{ .cert_path = owned_cert, .key_path = owned_key, .loaded = next, .loaded_at_unix = std.Io.Clock.real.now(io).toSeconds(), }; } /// Publish half: infallible and I/O-free. The paths and the generation /// they were loaded from are installed together — the generation `mutex` /// is taken only for that swap, inside `reload_mutex`, the same lock order /// `reload` uses. Releases `reload_mutex`. /// /// Connections that pinned the old generation finish on the old /// certificate; the old entry is freed once its last reader releases. pub fn publishPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void { const old_cert_path = self.cert_path; const old_key_path = self.key_path; self.mutex.lockUncancelable(io); const old = self.current; self.cert_path = prepared.cert_path; self.key_path = prepared.key_path; self.current = prepared.loaded.entry; self.loaded = prepared.loaded.sig; old.retired = true; const free_old = old.refs == 0; self.mutex.unlock(io); // The branch runs before the counter bump, never after: a `bool` still // live across an atomic read-modify-write is the zig 0.16.0 Debug // miscompile AGENTS.md documents. if (free_old) self.destroyEntry(old); self.gpa.free(old_cert_path); self.gpa.free(old_key_path); _ = self.reloads.fetchAdd(1, .monotonic); self.last_reload_unix.store(prepared.loaded_at_unix, .monotonic); self.reload_mutex.unlock(io); } /// Discards a prepared candidate — the commit that would have published it /// failed, or a sibling owner's prepare did. Releases `reload_mutex`. pub fn abortPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void { self.gpa.free(prepared.cert_path); self.gpa.free(prepared.key_path); self.destroyEntry(prepared.loaded.entry); self.reload_mutex.unlock(io); } /// Sleep first: `init` just loaded the files this poll would compare /// against. `.boot` so a suspended box still sees the interval elapse. pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void { const interval: std.Io.Clock.Duration = .{ .raw = .fromSeconds(poll_interval_s), .clock = .boot, }; while (true) { try interval.sleep(io); self.pollOnce(io, std.Io.Clock.real.now(io).toSeconds()); } } /// One watcher pass. A file that cannot be stat'ed only warns — a /// mid-rename window or a permission slip is not evidence the certificate /// changed, and the old one keeps serving either way. A failed reload /// warns and counts (`reload_failures`); the signature stays at the loaded /// pair, so every subsequent poll retries until the files parse. /// /// The whole pass runs under `reload_mutex`: the paths it stats are the /// ones an apply replaces, and a poll that read a path outside the lock /// could stat a freed slice or reload a pair that was never published /// together. pub fn pollOnce(self: *CertStore, io: std.Io, now_s: i64) void { self.reload_mutex.lockUncancelable(io); defer self.reload_mutex.unlock(io); const cert_sig = statSig(io, self.cert_path) catch |err| { log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path}); self.reportReload(io, now_s, "stat of the certificate failed", @errorName(err)); return; }; const key_sig = statSig(io, self.key_path) catch |err| { log.warn("stat {s} failed; keeping the loaded certificate", .{self.key_path}); self.reportReload(io, now_s, "stat of the private key failed", @errorName(err)); return; }; const observed: Signature = .{ .cert = cert_sig, .key = key_sig }; self.mutex.lockUncancelable(io); const loaded = self.loaded; self.mutex.unlock(io); if (!changed(loaded, observed)) { // A poll that stat'ed both files and found nothing to do is a // fully healthy pass, so it closes any episode a transient stat // failure opened. Without this, a file that never changes again // would leave that episode open forever. if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind)); return; } if (self.reloadLocked(io)) { log.info("certificate reloaded from {s}", .{self.cert_path}); if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind)); } else |err| { log.warn("certificate reload from {s} failed ({s}); the old certificate keeps serving", .{ self.cert_path, humanMessage(err), }); self.reportReload(io, now_s, "certificate reload failed", humanMessage(err)); } } /// A warning, never an error: a stat failure can be a rename window, and a /// failed reload leaves the loaded certificate serving. Nothing is down. fn reportReload(self: *CertStore, io: std.Io, now_s: i64, message: []const u8, reason: []const u8) void { const store = self.diagnostics orelse return; var buf: [events.Store.max_detail_len]u8 = undefined; const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ message, reason }) catch buf[0..]; store.report(io, now_s, .certificate_reload, @tagName(self.kind), @tagName(self.kind), .warning, detail); } pub fn snapshotStats(self: *const CertStore) Stats { return .{ .reloads = self.reloads.load(.monotonic), .reload_failures = self.reload_failures.load(.monotonic), .last_reload_unix = self.last_reload_unix.load(.monotonic), }; } fn destroyEntry(self: *CertStore, entry: *Entry) void { entry.ctx.deinit(self.gpa); self.gpa.destroy(entry); } }; const Loaded = struct { entry: *Entry, sig: Signature, }; /// Stat first, then read: a file replaced between the two makes the recorded /// signature stale, so the watcher's next poll sees the difference and loads /// again. The other order would record the new signature over the old bytes /// and miss the update. fn load( gpa: std.mem.Allocator, io: std.Io, cert_path: []const u8, key_path: []const u8, alpn: ?[*:null]const ?[*:0]const u8, ) ReloadError!Loaded { const cert_sig = statSig(io, cert_path) catch return error.CertUnreadable; const key_sig = statSig(io, key_path) catch return error.KeyUnreadable; const cert_pem = readPem(gpa, io, cert_path) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, error.TooLarge => error.CertTooLarge, error.Unreadable => error.CertUnreadable, }; defer gpa.free(cert_pem); const key_pem = readKeyPem(gpa, io, key_path) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, error.TooLarge => error.KeyTooLarge, error.Unreadable => error.KeyUnreadable, }; defer { std.crypto.secureZero(u8, key_pem); gpa.free(key_pem); } const entry = try gpa.create(Entry); errdefer gpa.destroy(entry); entry.* = .{ .ctx = try tls_server.ServerContext.init(gpa, cert_pem, key_pem, alpn), .refs = 0, .retired = false, }; return .{ .entry = entry, .sig = .{ .cert = cert_sig, .key = key_sig }, }; } /// Mbed TLS wants PEM with a terminating zero byte counted in the length, so /// the file lands in a sentinel-terminated allocation. The limit admits /// exactly `max_pem_bytes` and rejects the first byte beyond it. /// /// The certificate only. A private key goes through `readKeyPem`, which does /// not leave copies behind. fn readPem( gpa: std.mem.Allocator, io: std.Io, path: []const u8, ) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 { return std.Io.Dir.cwd().readFileAllocOptions( io, path, gpa, .limited(max_pem_bytes + 1), .of(u8), 0, ) catch |err| switch (err) { error.OutOfMemory => error.OutOfMemory, error.StreamTooLong => error.TooLarge, else => error.Unreadable, }; } /// `readPem` for the private key, with the same cap and the same /// sentinel-terminated result. The difference is that no copy of the key /// survives this function: `readFileAllocOptions` grows its buffer as it reads, /// and every intermediate copy it abandons stays legible in freed pages that no /// wipe at the call site can reach. One fixed staging buffer never grows, and it /// is wiped before it goes back to the allocator. The caller wipes the returned /// slice the same way before freeing it (the idiom is auth.zig's `secureZero` /// defers). fn readKeyPem( gpa: std.mem.Allocator, io: std.Io, path: []const u8, ) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 { const staging = try gpa.alloc(u8, max_pem_bytes + 1); defer { std.crypto.secureZero(u8, staging); gpa.free(staging); } const bytes = std.Io.Dir.cwd().readFile(io, path, staging) catch return error.Unreadable; // A read that filled the staging buffer is ambiguous — `readFile` cannot // say whether more followed — and one byte past `max_pem_bytes` is over the // cap either way. if (bytes.len == staging.len) return error.TooLarge; const key = try gpa.allocSentinel(u8, bytes.len, 0); @memcpy(key, bytes); return key; } fn statSig(io: std.Io, path: []const u8) !FileSig { const st = try std.Io.Dir.cwd().statFile(io, path, .{}); return .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size }; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const events_fixture = @import("../storage/events_fixture.zig"); const fixtures = @import("test_fixtures"); const testing = std.testing; test "the stat-compare decision table" { const a: FileSig = .{ .mtime_ns = 1_000, .size = 100 }; const a_later: FileSig = .{ .mtime_ns = 2_000, .size = 100 }; const a_grown: FileSig = .{ .mtime_ns = 1_000, .size = 101 }; const b: FileSig = .{ .mtime_ns = 5_000, .size = 500 }; const b_later: FileSig = .{ .mtime_ns = 6_000, .size = 500 }; const b_grown: FileSig = .{ .mtime_ns = 5_000, .size = 501 }; const cases = [_]struct { loaded: Signature, observed: Signature, want: bool, }{ .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b }, .want = false }, .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_later, .key = b }, .want = true }, .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_grown, .key = b }, .want = true }, .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b_later }, .want = true }, .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b_grown }, .want = true }, .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_later, .key = b_grown }, .want = true }, // A same-second rewrite registers through the size alone. .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = .{ .mtime_ns = 1_000, .size = 99 }, .key = b }, .want = true, }, // The pair swapped between the two files is a change, not a wash. .{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = b, .key = a }, .want = true }, }; for (cases) |case| { try testing.expectEqual(case.want, changed(case.loaded, case.observed)); } } test "every reload error carries a human message" { inline for (comptime @typeInfo(ReloadError).error_set.?) |e| { try testing.expect(humanMessage(@field(anyerror, e.name)).len > 0); } } /// Fixture PEMs written into a tmp directory, addressed by cwd-relative paths /// the same way `app.zig` hands config paths to the store. Must not move after /// `init`: the path slices point into the buffers below. const TestEnv = struct { threaded: std.Io.Threaded, tmp: testing.TmpDir, cert_path_buf: [128]u8, key_path_buf: [128]u8, cert_path: []const u8, key_path: []const u8, fn init(env: *TestEnv) !void { env.threaded = .init(testing.allocator, .{}); errdefer env.threaded.deinit(); env.tmp = testing.tmpDir(.{}); errdefer env.tmp.cleanup(); const env_io = env.threaded.io(); try env.tmp.dir.writeFile(env_io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); try env.tmp.dir.writeFile(env_io, .{ .sub_path = "key.pem", .data = fixtures.key_pem }); env.cert_path = try std.fmt.bufPrint(&env.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{env.tmp.sub_path}); env.key_path = try std.fmt.bufPrint(&env.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{env.tmp.sub_path}); } fn deinit(env: *TestEnv) void { env.tmp.cleanup(); env.threaded.deinit(); } fn io(env: *TestEnv) std.Io { return env.threaded.io(); } }; test "loaded PEM bytes are NUL-terminated and intact" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const pem = try readPem(testing.allocator, env.io(), env.cert_path); defer testing.allocator.free(pem); try testing.expectEqualStrings(fixtures.cert_pem, pem); try testing.expectEqual(@as(u8, 0), pem[pem.len]); } test "the size cap admits 64 KiB and rejects one byte more" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); const at_cap = try testing.allocator.alloc(u8, max_pem_bytes); defer testing.allocator.free(at_cap); @memset(at_cap, 'a'); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = at_cap }); const loaded = try readPem(testing.allocator, io, env.cert_path); testing.allocator.free(loaded); const over = try testing.allocator.alloc(u8, max_pem_bytes + 1); defer testing.allocator.free(over); @memset(over, 'a'); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = over }); try testing.expectError(error.TooLarge, readPem(testing.allocator, io, env.cert_path)); } test "the key PEM path keeps the shape and the cap of the certificate path" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); const key = try readKeyPem(testing.allocator, io, env.key_path); defer testing.allocator.free(key); try testing.expectEqualStrings(fixtures.key_pem, key); try testing.expectEqual(@as(u8, 0), key[key.len]); const at_cap = try testing.allocator.alloc(u8, max_pem_bytes); defer testing.allocator.free(at_cap); @memset(at_cap, 'a'); try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = at_cap }); testing.allocator.free(try readKeyPem(testing.allocator, io, env.key_path)); const over = try testing.allocator.alloc(u8, max_pem_bytes + 1); defer testing.allocator.free(over); @memset(over, 'a'); try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = over }); try testing.expectError(error.TooLarge, readKeyPem(testing.allocator, io, env.key_path)); try testing.expectError( error.Unreadable, readKeyPem(testing.allocator, io, "./nxdns-no-such-key-9b31.pem"), ); } test "init fails typed on a missing file and on garbage PEM" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); try testing.expectError( error.CertUnreadable, CertStore.init(testing.allocator, io, "./nxdns-no-such-cert-9b31.pem", env.key_path, null), ); try testing.expectError( error.KeyUnreadable, CertStore.init(testing.allocator, io, env.cert_path, "./nxdns-no-such-key-9b31.pem", null), ); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "not a certificate" }); try testing.expectError( error.CertParse, CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null), ); } test "init fails typed on an oversized certificate" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); const over = try testing.allocator.alloc(u8, max_pem_bytes + 1); defer testing.allocator.free(over); @memset(over, 'a'); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = over }); try testing.expectError( error.CertTooLarge, CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null), ); } test "acquire and release across a reload free the old entry after the last release" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const old = store.acquire(io); const old_again = store.acquire(io); try testing.expectEqual(old, old_again); try testing.expectEqual(@as(u32, 2), old.refs); try testing.expect(!old.retired); try store.reload(io); // The old generation is retired but pinned; the store publishes a new one. try testing.expect(old.retired); try testing.expectEqual(@as(u32, 2), old.refs); const fresh = store.acquire(io); try testing.expect(fresh != old); try testing.expect(!fresh.retired); // The first release leaves the old entry alive for the second holder; // the testing allocator proves the second release frees it exactly once. store.release(io, old); try testing.expectEqual(@as(u32, 1), old.refs); store.release(io, old); store.release(io, fresh); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); } test "a reload with no holders frees the old entry immediately" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); // No acquire in flight: the swap itself must free the old generation, or // the testing allocator reports the leak at the end of this test. try store.reload(io); try store.reload(io); try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads); } test "a failed reload publishes nothing" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const before = store.acquire(io); store.release(io, before); // Bad PEM bytes: the file reads fine and fails in the parser. try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "garbage" }); try testing.expectError(error.CertParse, store.reload(io)); var held = store.acquire(io); try testing.expectEqual(before, held); try testing.expect(!held.retired); store.release(io, held); // Bad path: the key file vanishes. try env.tmp.dir.deleteFile(io, "key.pem"); try testing.expectError(error.KeyUnreadable, store.reload(io)); held = store.acquire(io); try testing.expectEqual(before, held); store.release(io, held); const stats = store.snapshotStats(); try testing.expectEqual(@as(u64, 0), stats.reloads); try testing.expectEqual(@as(u64, 2), stats.reload_failures); } test "a mismatched key pair fails the reload and keeps the old pair" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const before = store.acquire(io); store.release(io, before); try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.mismatched_key_pem }); try testing.expectError(error.KeyMismatch, store.reload(io)); const held = store.acquire(io); try testing.expectEqual(before, held); store.release(io, held); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures); } test "pollOnce reloads on a changed stat pair and stays put on an unchanged one" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); store.pollOnce(io, 1000); try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); // Same certificate plus a trailing newline: the PEM still parses and the // size differs even when the rewrite lands within one timestamp granule. const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); defer testing.allocator.free(grown); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = grown }); const old = store.acquire(io); store.release(io, old); store.pollOnce(io, 1000); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); const fresh = store.acquire(io); try testing.expect(fresh != old); store.release(io, fresh); store.pollOnce(io, 1000); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); } test "pollOnce warns and keeps serving when a reload fails" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const before = store.acquire(io); store.release(io, before); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "still not a certificate" }); store.pollOnce(io, 1000); const stats = store.snapshotStats(); try testing.expectEqual(@as(u64, 0), stats.reloads); try testing.expectEqual(@as(u64, 1), stats.reload_failures); const held = store.acquire(io); try testing.expectEqual(before, held); store.release(io, held); } test "pollOnce does nothing when a file cannot be stat'ed" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); try env.tmp.dir.deleteFile(io, "cert.pem"); store.pollOnce(io, 1000); const stats = store.snapshotStats(); try testing.expectEqual(@as(u64, 0), stats.reloads); try testing.expectEqual(@as(u64, 0), stats.reload_failures); } test "init records the load time and reload advances it" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const at_init = store.snapshotStats(); try testing.expectEqual(@as(u64, 0), at_init.reloads); try testing.expectEqual(@as(u64, 0), at_init.reload_failures); try testing.expect(at_init.last_reload_unix > 0); try store.reload(io); const after = store.snapshotStats(); try testing.expectEqual(@as(u64, 1), after.reloads); try testing.expect(after.last_reload_unix >= at_init.last_reload_unix); } test "init accepts a comptime ALPN list" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); const alpn: [1:null]?[*:0]const u8 = .{"http/1.1"}; var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, &alpn); defer store.deinit(io); try store.reload(io); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); } test "the watch loop sleeps before polling and returns on cancel" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); var future = try io.concurrent(CertStore.watch, .{ &store, io }); // Cancelling immediately proves the loop starts by sleeping rather than // by reloading. try testing.expectError(error.Canceled, future.cancel(io)); try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); } // Pins the reload-ordering race via `after_load_hook`: the hook runs inside // reload A's load-to-publish window, where it (1) probes that `reload_mutex` // is held, the serialization this exists for (without it a second reload // could publish here and be overwritten by A's stale read), then (2) stages // newer cert bytes and starts an overlapping reload B. B can only read the // files after A publishes, so B both reads and publishes last and the newer // bytes win. This proves the lock spans the window and last-read-wins for // this schedule; it does not enumerate every interleaving. test "a reload overlapping another reload's window publishes last" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); defer testing.allocator.free(grown); const Window = struct { store: *CertStore, env: *TestEnv, grown: []const u8, overlapping: ?std.Io.Future(ReloadError!void) = null, lock_spans_window: bool = false, staged: bool = false, // Reload B re-enters this hook under `reload_mutex`, so its read of // `overlapping` is ordered after A's write and the early return makes // the window work run exactly once. fn call(ctx: *anyopaque, hook_io: std.Io) void { const w: *@This() = @ptrCast(@alignCast(ctx)); if (w.overlapping != null) return; if (w.store.reload_mutex.tryLock()) { w.store.reload_mutex.unlock(hook_io); } else { w.lock_spans_window = true; } w.env.tmp.dir.writeFile(hook_io, .{ .sub_path = "cert.pem", .data = w.grown }) catch return; w.staged = true; w.overlapping = hook_io.concurrent(CertStore.reload, .{ w.store, hook_io }) catch null; } }; var window: Window = .{ .store = &store, .env = &env, .grown = grown }; store.after_load_hook = .{ .ctx = &window, .call = Window.call }; try store.reload(io); try testing.expect(window.lock_spans_window); try testing.expect(window.staged); var overlapping = window.overlapping orelse return error.ConcurrentUnavailable; try overlapping.await(io); try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads); store.mutex.lockUncancelable(io); const final = store.loaded; store.mutex.unlock(io); try testing.expectEqual(@as(u64, grown.len), final.cert.size); } test "a failed reload opens an episode keyed by endpoint kind and a good one closes it" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); store.kind = .dot; store.diagnostics = &fx.store; try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "still not a certificate" }); store.pollOnce(io, 1000); try testing.expectEqualStrings("certificate.reload", try fx.text("SELECT code FROM operational_events")); try testing.expectEqualStrings("dot", try fx.text("SELECT subject_key FROM operational_events")); try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events")); // The same certificate plus a newline: it parses, and the size differs even // within one timestamp granule. const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); defer testing.allocator.free(grown); try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = grown }); store.pollOnce(io, 1100); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); try testing.expectEqual( @as(i64, 0), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); } test "a failed stat opens the same episode a failed reload would" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); store.diagnostics = &fx.store; try env.tmp.dir.deleteFile(io, "cert.pem"); store.pollOnce(io, 1000); try testing.expectEqualStrings("doh", try fx.text( "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL", )); try testing.expectEqual(@as(u64, 0), store.snapshotStats().reload_failures); } test "an unchanged poll closes the episode a transient stat failure opened" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); store.diagnostics = &fx.store; // What a stat failure in a rename window left open. The file it named is // back and unchanged, so no reload will ever close this episode. fx.store.report(io, 1000, .certificate_reload, "doh", "doh", .warning, "stat of the certificate failed: FileNotFound"); store.pollOnce(io, 1100); try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); try testing.expectEqual( @as(i64, 0), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); } // --------------------------------------------------------------------------- // path apply (milestone-34 S3.4) // --------------------------------------------------------------------------- test "a bad candidate is refused at prepare and the store keeps serving" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const before = store.acquire(io); store.release(io, before); const before_paths_cert = store.cert_path; try env.tmp.dir.writeFile(io, .{ .sub_path = "bad.pem", .data = "not a certificate" }); var bad_buf: [128]u8 = undefined; const bad_path = try std.fmt.bufPrint(&bad_buf, ".zig-cache/tmp/{s}/bad.pem", .{env.tmp.sub_path}); try testing.expectError( error.CertParse, store.preparePathChange(io, bad_path, env.key_path), ); // Nothing published: same generation, same paths, and `reload_mutex` was // released — a second prepare would deadlock otherwise. const after = store.acquire(io); store.release(io, after); try testing.expectEqual(before, after); try testing.expectEqual(before_paths_cert.ptr, store.cert_path.ptr); try testing.expectEqualStrings(env.cert_path, store.cert_path); try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures); // A missing candidate path is refused the same way. try testing.expectError( error.CertUnreadable, store.preparePathChange(io, "./nxdns-no-such-cert-4a11.pem", env.key_path), ); } test "a published path change installs the new pair and retires the old generation" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); // A second, byte-different copy of the same valid pair under new names. const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); defer testing.allocator.free(grown); try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown }); try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem }); var cert_buf: [128]u8 = undefined; var key_buf: [128]u8 = undefined; const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path}); const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path}); // A connection pinned to the old generation finishes on it. const pinned = store.acquire(io); const prepared = try store.preparePathChange(io, next_cert, next_key); store.publishPathChange(io, prepared); try testing.expectEqualStrings(next_cert, store.cert_path); try testing.expectEqualStrings(next_key, store.key_path); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); try testing.expect(pinned.retired); const serving = store.acquire(io); try testing.expect(serving != pinned); store.release(io, serving); store.release(io, pinned); // The watcher now measures the new pair, so an untouched pair polls clean // and a rewritten one reloads. store.pollOnce(io, 1_000); try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads); try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = fixtures.cert_pem }); store.pollOnce(io, 1_100); try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads); } test "an aborted path change frees the candidate and leaves the store untouched" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const before = store.acquire(io); store.release(io, before); // The commit this candidate was built for failed; the testing allocator // proves the abort frees everything the prepare took. const prepared = try store.preparePathChange(io, env.cert_path, env.key_path); store.abortPathChange(io, prepared); const after = store.acquire(io); store.release(io, after); try testing.expectEqual(before, after); try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads); // `reload_mutex` came back, so the store still reloads. try store.reload(io); } test "a reload racing a path apply is serialized behind it" { var env: TestEnv = undefined; try env.init(); defer env.deinit(); const io = env.io(); var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null); defer store.deinit(io); const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" }); defer testing.allocator.free(grown); try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown }); try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem }); var cert_buf: [128]u8 = undefined; var key_buf: [128]u8 = undefined; const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path}); const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path}); // Prepare holds `reload_mutex` across the whole apply. const prepared = try store.preparePathChange(io, next_cert, next_key); try testing.expect(!store.reload_mutex.tryLock()); // The concurrent reload cannot start, so it cannot publish the OLD paths // over the new generation. var racing = try io.concurrent(CertStore.reload, .{ &store, io }); store.publishPathChange(io, prepared); try racing.await(io); // Two publications, and the last word is the apply's pair: the racing // reload reread the paths the apply installed. try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads); try testing.expectEqualStrings(next_cert, store.cert_path); store.mutex.lockUncancelable(io); const final = store.loaded; store.mutex.unlock(io); try testing.expectEqual(@as(u64, grown.len), final.cert.size); }