//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the //! `settings` table (ruling 16). //! //! Every key applies live except the two that create or destroy a socket — //! `bind` (a listener's address, port or existence) and `web_lifecycle` //! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute. //! `restart_required` in the response is that pair of key groups and nothing //! else. `apply.zig` holds the table that decides which owner a key belongs to //! and drives the prepare → commit → publish → retire the write contract asks //! for; a section added to the model fails to compile until its keys have //! owners there. //! //! `web.password` is write-only and `web.password_hash` is neither readable nor //! directly writable. A PUT carrying `web.password` hashes it with the import //! path's argon2id parameters and stores the hash alone (PLAN §19: the plain //! password is never stored, never logged, never echoed). Changing the hash //! ends every session, because the old cookies were minted under the old //! password. //! //! A PUT is partial: a section left out, or a field left out of a section, keeps //! what is stored. The merged configuration is validated whole — the same check //! the next start runs — before a single row is written, so a settings PUT //! cannot leave a configuration the server would refuse to boot from. const builtin = @import("builtin"); const std = @import("std"); const Allocator = std.mem.Allocator; const apply = @import("apply.zig"); const auth = @import("../auth.zig"); const db = @import("../../storage/db.zig"); const http_util = @import("../http_util.zig"); const model = @import("../../config/model.zig"); const mutations = @import("mutations.zig"); const server = @import("../server.zig"); const settings_repo = @import("../../storage/repositories/settings_repo.zig"); const Failure = mutations.Failure; const Request = http_util.Request; const HandlerError = http_util.HandlerError; const log = std.log.scoped(.web_api); /// Holds any PHC-encoded argon2id string comfortably (import.zig's number). /// Equal to the live holder's capacity by construction, so a hash written here /// always fits the copy `applyLogin` takes. const hash_buf_len = auth.LiveHash.max_len; const isHidden = apply.isHidden; const isWriteOnly = apply.isWriteOnly; const isScalarSection = apply.isScalarSection; /// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the /// two operations milestone 35 executes. The apply table is the authority. pub const restart_required_keys = apply.restart_required_keys; // --------------------------------------------------------------------------- // the patch a PUT carries // --------------------------------------------------------------------------- /// `Section` with every field optional, so an absent field means "leave it". /// Generated rather than written out: a hand-copied mirror of `model.Config` /// would drift the first time a setting is added. fn Partial(comptime Section: type, comptime section_name: []const u8) type { const info = @typeInfo(Section).@"struct"; var names: [info.fields.len][:0]const u8 = undefined; var types: [info.fields.len]type = undefined; var attrs: [info.fields.len]std.builtin.Type.StructField.Attributes = undefined; var count: usize = 0; for (info.fields) |field| { if (isHidden(section_name, field.name)) continue; const Field = ?FieldType(field.type); const default: Field = null; names[count] = field.name; types[count] = Field; attrs[count] = .{ .default_value_ptr = @ptrCast(&default) }; count += 1; } const final_names = names[0..count].*; const final_types = types[0..count].*; const final_attrs = attrs[0..count].*; return @Struct(.auto, null, &final_names, &final_types, &final_attrs); } /// Enums arrive as the words the database stores, so they are parsed from text /// rather than by tag name (`logging.level` is `error`, whose tag cannot be). /// /// An optional model field collapses to its child, because `Partial` wraps /// every field in one optional of its own and that optional already carries the /// only meaning a PUT has for absence — "leave it". A double optional would be /// two ways to say the same thing, and `std.json` cannot parse the outer one. fn FieldType(comptime T: type) type { return switch (@typeInfo(T)) { .@"enum" => []const u8, .optional => |info| FieldType(info.child), else => T, }; } /// The whole PUT body: every section optional, every field optional. pub const Patch = blk: { const config_fields = @typeInfo(model.Config).@"struct".fields; var names: [config_fields.len][:0]const u8 = undefined; var types: [config_fields.len]type = undefined; var attrs: [config_fields.len]std.builtin.Type.StructField.Attributes = undefined; var count: usize = 0; for (config_fields) |section_field| { if (!isScalarSection(section_field.type)) continue; const Section = ?Partial(section_field.type, section_field.name); const default: Section = null; names[count] = section_field.name; types[count] = Section; attrs[count] = .{ .default_value_ptr = @ptrCast(&default) }; count += 1; } const final_names = names[0..count].*; const final_types = types[0..count].*; const final_attrs = attrs[0..count].*; break :blk @Struct(.auto, null, &final_names, &final_types, &final_attrs); }; /// Applies `patch` onto `cfg`. A word an enum does not know is the one failure /// this can report, and it names the key. fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool { inline for (@typeInfo(Patch).@"struct".fields) |section_field| { if (@field(patch, section_field.name)) |section| { inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| { if (@field(section, field.name)) |value| { const Target = @TypeOf(@field(@field(cfg, section_field.name), field.name)); if (@typeInfo(Target) == .@"enum") { const decoded = Target.fromDb(value) orelse { bad_key.* = section_field.name ++ "." ++ field.name; return false; }; @field(@field(cfg, section_field.name), field.name) = decoded; } else { @field(@field(cfg, section_field.name), field.name) = value; } } } } } return true; } /// Whether the patch carries a new password. fn newPassword(patch: Patch) ?[]const u8 { const web = patch.web orelse return null; const password = web.password orelse return null; if (password.len == 0) return null; return password; } // --------------------------------------------------------------------------- // the read shape // --------------------------------------------------------------------------- const BlockingView = struct { response: []const u8, ttl: u32 }; const EdnsView = struct { ecs_mode: []const u8 }; const LoggingView = struct { level: []const u8, retention_days: u16, query_log_buffer_max: u32, query_log_flush_interval_s: u16, hide_domains: bool, hide_client_ips: bool, output: []const u8, file_path: []const u8, max_size_mb: u32, max_files: u8, }; const WebView = struct { enabled: bool, bind: []const u8, port: u16, session_ttl_hours: u16, api_rate_limit_per_min: u32, api_localhost_exempt: bool, sse_max_connections_per_ip: u16, trusted_proxies: []const u8, /// Derived, not stored: the hash itself is never serialized, and the UI /// still has to know whether a password is set. auth_enabled: bool, }; pub const View = struct { upstream: model.Upstream, dns: model.Dns, blocking: BlockingView, cache: model.Cache, web: WebView, doh_server: model.TlsEndpoint, dot_server: model.TlsEndpoint, edns: EdnsView, logging: LoggingView, disk: model.Disk, blocklist_update: model.BlocklistUpdate, }; pub fn view(cfg: model.Config) View { return .{ .upstream = cfg.upstream, .dns = cfg.dns, .blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl }, .cache = cfg.cache, .web = .{ .enabled = cfg.web.enabled, .bind = cfg.web.bind, .port = cfg.web.port, .session_ttl_hours = cfg.web.session_ttl_hours, .api_rate_limit_per_min = cfg.web.api_rate_limit_per_min, .api_localhost_exempt = cfg.web.api_localhost_exempt, .sse_max_connections_per_ip = cfg.web.sse_max_connections_per_ip, .trusted_proxies = cfg.web.trusted_proxies, .auth_enabled = auth.authEnabled(cfg.web), }, .doh_server = cfg.doh_server, .dot_server = cfg.dot_server, .edns = .{ .ecs_mode = cfg.edns.ecs_mode.toDb() }, .logging = .{ .level = cfg.logging.level.toDb(), .retention_days = cfg.logging.retention_days, .query_log_buffer_max = cfg.logging.query_log_buffer_max, .query_log_flush_interval_s = cfg.logging.query_log_flush_interval_s, .hide_domains = cfg.logging.hide_domains, .hide_client_ips = cfg.logging.hide_client_ips, .output = cfg.logging.output.toDb(), .file_path = cfg.logging.file_path, .max_size_mb = cfg.logging.max_size_mb, .max_files = cfg.logging.max_files, }, .disk = cfg.disk, .blocklist_update = cfg.blocklist_update, }; } // --------------------------------------------------------------------------- // decisions // --------------------------------------------------------------------------- /// Reads, merges, validates, writes, and — when the password changed — ends /// every session. Returns the configuration as it now stands. fn applyPut( state: *server.WebState, io: std.Io, arena: Allocator, patch: Patch, ) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; // Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this // handler does, and its input is the parsed patch alone — nothing under the // lock. Hashing inside the lock stalled every settings read and every other // mutation for its duration. The login path already hashes unlocked // (auth.zig), and `LiveHash`'s generation check closes the install race. const password = newPassword(patch); var new_hash: []const u8 = ""; if (password) |plain| { if (plain.len > auth.max_password_len) { return .{ .fail = .{ .invalid = "web.password is too long" } }; } const buf = try arena.alloc(u8, hash_buf_len); new_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } }, else => return .{ .fail = .{ .internal = error.Unexpected } }, }; } state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); const stored = mutations.loadConfig(arena, database) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return .{ .fail = .{ .internal = err } }, }; var cfg = stored; var bad_key: []const u8 = ""; if (!merge(&cfg, patch, &bad_key)) { return .{ .fail = .{ .invalid = try std.fmt.allocPrint( arena, "{s}: not one of the values this setting accepts", .{bad_key}, ) } }; } // The password never becomes a row: the hash made above is what the merged // configuration — and therefore the settings table — carries. const previous_hash = cfg.web.password_hash orelse ""; if (password != null) cfg.web.password_hash = new_hash; cfg.web.password = null; if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } }; // The gpa copy the live holder will own, made before the write so a // committed transaction can never be followed by a failed revocation. const merged_hash = cfg.web.password_hash orelse ""; const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash); const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null; errdefer if (replacement) |hash| state.gpa.free(hash); // Prepare: one candidate per owner whose value actually moved, built from // the merged configuration. Nothing is published and no row is written // until every one of them succeeded. const ops = apply.changedOperations(stored, cfg); var plan: apply.Plan = .init(state, arena, cfg, ops); if (try plan.prepare(io)) |failure| { plan.abandon(io); if (replacement) |hash| state.gpa.free(hash); return .{ .fail = failure }; } writeSettings(arena, database, cfg) catch |err| { plan.abandon(io); if (replacement) |hash| state.gpa.free(hash); return .{ .fail = .{ .internal = err } }; }; plan.publish(io); plan.retire(io); // After the commit, never before: a validation failure or a write that // rolled back changed nothing, so it owes nobody a restart. Only the two // operations milestone 35 executes raise it; everything else is already // live by the time this line runs. var restart_owed = false; var it = ops.iterator(); while (it.next()) |operation| { if (operation.needsRestart()) restart_owed = true; } if (restart_owed) state.restart_pending.store(true, .monotonic); if (replacement) |hash| { // Ruling 17, both halves: the running server must verify against the // new hash at once — a restart-free credential change — and the // cookies in flight were minted under the old password. One // LiveHash-ordered operation: a login with the new password cannot // mint between the hash swap and the revocation and then lose its // fresh cookie to it. state.live_hash.installAndRevoke(io, state.gpa, state.sessions, hash); } return .{ .config = cfg }; } /// Writes every key of `cfg` in one transaction. Rewriting the unchanged rows /// costs a few dozen upserts and buys the guarantee that the table is exactly /// what `model.toSettings` says the merged configuration is — no key can be /// missed and none can be left behind. fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error!void { var pairs: std.ArrayList(model.SettingPair) = .empty; try model.toSettings(cfg, arena, &pairs); var tx = try db.Tx.begin(database); errdefer tx.rollback(); try write_fault.check(); for (pairs.items) |pair| { try settings_repo.putSetting(database, pair.key, pair.value); } // `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the // reconcile engine owns that row, because only it can tell "the file said // nothing" from "the file said empty"). A PUT has no such ambiguity — the // merged configuration is the whole truth — so this handler writes the row // itself rather than losing the password change. try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse ""); try tx.commit(); } /// Fails one `writeSettings` after its transaction has begun, so a test can /// prove the `errdefer` above rolls that transaction back rather than leaving /// the shared connection inside it. Test builds only, and it reduces to nothing /// everywhere else — the rotation seam's shape (logging.zig). const write_fault = if (builtin.is_test) struct { var armed: bool = false; fn check() db.Error!void { if (!armed) return; armed = false; return error.Internal; } } else struct { fn check() db.Error!void {} }; fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 { return mutations.firstProblem(arena, cfg); } /// argon2id with the import path's parameters (OWASP t=2, m=19 MiB, p=1), so a /// password set through the API and one set through a config import produce the /// same kind of hash. fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 { hash_stall.park(io); return std.crypto.pwhash.argon2.strHash(password, .{ .allocator = gpa, .params = .owasp_2id, .mode = .argon2id, .encoding = .phc, }, buf, io) catch |err| switch (err) { error.OutOfMemory => error.OutOfMemory, error.Canceled => error.Canceled, else => { // Never the password, never the hash: only what went wrong. log.warn("hashing the new web password failed: {s}", .{@errorName(err)}); return error.Unexpected; }, }; } /// Holds a hash still so a test can prove another request runs beside it. The /// hash finishing on its own would prove nothing: before ruling 18 a settings /// GET also completed, it merely waited out the hash first. The storage exists /// in a test build only, and `park` reduces to nothing everywhere else — the /// rotation seam's shape (logging.zig). const hash_stall = if (builtin.is_test) struct { var armed: bool = false; var parked: std.Io.Event = .unset; var release: std.Io.Event = .unset; fn park(io: std.Io) void { if (!armed) return; parked.set(io); release.waitUncancelable(io); } } else struct { fn park(io: std.Io) void { _ = io; } }; /// The seam's controls, for the test that proves a settings read runs beside a /// hash in flight. Present in a test build only. pub const hash_stall_control = if (builtin.is_test) struct { pub fn arm() void { hash_stall.parked = .unset; hash_stall.release = .unset; hash_stall.armed = true; } /// Returns once a hash is parked on the seam. pub fn waitParked(io: std.Io) void { hash_stall.parked.waitUncancelable(io); } /// Lets the parked hash finish and disarms the seam for the next test. pub fn release(io: std.Io) void { hash_stall.armed = false; hash_stall.release.set(io); } } else struct {}; // --------------------------------------------------------------------------- // routes // --------------------------------------------------------------------------- pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const database = mutations.requireConfigDb(state) catch return mutations.respondFailure(request, mutations.no_config_db, "reading the settings"); // Under the same lock the mutation handlers hold: a PUT rewrites every // settings row in one transaction on this shared connection, and SQLite's // own mutex serializes statements, not transactions — an unlocked read // could see half a PUT. Released before responding, like the mutations. state.config_lock.lockUncancelable(io); const loaded = mutations.loadConfig(request.arena, database); state.config_lock.unlock(io); const cfg = loaded catch |err| return mutations.respondFailure(request, .{ .internal = err }, "reading the settings"); return respondSettings(request, .ok, cfg); } pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(Patch, request) catch |err| return mutations.respondBadBody(request, err); return switch (try applyPut(state, io, request.arena, parsed.value)) { .fail => |failure| mutations.respondFailure(request, failure, "writing the settings"), .config => |cfg| respondSettings(request, .ok, cfg), }; } /// `restart_required` is field-level metadata about this form — which keys need /// a restart to take effect. Whether one is *owed* right now is process state /// and lives on `GET /api/config/status`, which is also the one home of the /// configuration authority: two copies of the same fact drift. fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void { return http_util.respondJson(request, status, .{ .settings = view(cfg), .restart_required = restart_required_keys, }, &.{}); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; const auth_handlers = @import("auth.zig"); test "the restart-required list is a strict subset of the settings keys" { // `model.toSettings` is the key list this form writes. The restart list is // now the small part of it that milestone 35 owns, so it must be shorter // than the key list and every entry must still be a real key. var pairs: std.ArrayList(model.SettingPair) = .empty; defer { model.freeSettings(testing.allocator, pairs.items); pairs.deinit(testing.allocator); } try model.toSettings(.{}, testing.allocator, &pairs); try testing.expect(restart_required_keys.len < pairs.items.len); for (restart_required_keys) |key| { var is_a_setting = false; for (pairs.items) |pair| { if (std.mem.eql(u8, pair.key, key)) is_a_setting = true; } try testing.expect(is_a_setting); } var found_port = false; var found_ttl = false; for (restart_required_keys) |key| { if (std.mem.eql(u8, key, "dns.port")) found_port = true; if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true; } try testing.expect(found_port); // A live key must not appear: the UI renders no restart affordance for it. try testing.expect(!found_ttl); } test "the read shape spells every enum the way the database does" { const rendered = view(.{ .logging = .{ .level = .err, .output = .file }, .blocking = .{ .response = .nxdomain }, .edns = .{ .ecs_mode = .forward }, }); try testing.expectEqualStrings("error", rendered.logging.level); try testing.expectEqualStrings("file", rendered.logging.output); try testing.expectEqualStrings("nxdomain", rendered.blocking.response); try testing.expectEqualStrings("forward", rendered.edns.ecs_mode); try testing.expect(!rendered.web.auth_enabled); const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } }); try testing.expect(with_password.web.auth_enabled); } test "the patch type has no password_hash field and every field is optional" { const WebPatch = @typeInfo(@FieldType(Patch, "web")).optional.child; comptime var has_password = false; inline for (@typeInfo(WebPatch).@"struct".fields) |field| { comptime std.debug.assert(@typeInfo(field.type) == .optional); comptime std.debug.assert(!std.mem.eql(u8, field.name, "password_hash")); if (comptime std.mem.eql(u8, field.name, "password")) has_password = true; } try testing.expect(has_password); } fn seeded(bench: *mutations.Bench) !void { try bench.exec( \\INSERT INTO upstreams (url, priority, enabled) VALUES ('https://dns.example/dns-query', 100, 1); \\INSERT INTO settings (key, value) VALUES ('dns.port', '53'), ('logging.level', 'info'); ); } test "a partial put changes the keys it names and keeps the rest" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var patch: Patch = .{}; patch.dns = .{ .port = 5353 }; patch.logging = .{ .level = "debug" }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expectEqual(@as(u16, 5353), outcome.config.dns.port); try testing.expectEqual(model.LogLevel.debug, outcome.config.logging.level); // Untouched keys keep their stored value, not the model default. try testing.expectEqual(@as(u32, 1000), outcome.config.dns.rate_limit); const stored = try mutations.loadConfig(bench.arena(), &bench.database); try testing.expectEqual(@as(u16, 5353), stored.dns.port); try testing.expectEqual(model.LogLevel.debug, stored.logging.level); } test "a put that would not validate writes nothing" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var patch: Patch = .{}; patch.dns = .{ .port = 0 }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expect(outcome.fail == .invalid); const stored = try mutations.loadConfig(bench.arena(), &bench.database); try testing.expectEqual(@as(u16, 53), stored.dns.port); } test "a write that fails after the transaction begins rolls it back" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var patch: Patch = .{}; patch.dns = .{ .port = 5353 }; write_fault.armed = true; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expect(outcome.fail == .internal); // BEGIN IMMEDIATE inside an open transaction is an error, so a second // `begin` succeeding is what proves the errdefer ran. var tx = try db.Tx.begin(&bench.database); tx.rollback(); const stored = try mutations.loadConfig(bench.arena(), &bench.database); try testing.expectEqual(@as(u16, 53), stored.dns.port); } test "an enum value the model does not know names the key it came from" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var patch: Patch = .{}; patch.logging = .{ .level = "verbose" }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expect(std.mem.startsWith(u8, outcome.fail.invalid, "logging.level:")); } test "a new password is stored as a hash and ends every session" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var sessions: auth.Sessions = .init(24); bench.state.sessions = &sessions; const cookie = sessions.createWithToken(bench.io(), @splat(7), 1_000); try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001)); var patch: Patch = .{}; patch.web = .{ .password = "correct horse battery staple" }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$")); try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001)); // The plain password is nowhere in the table, and the hash is. try testing.expectEqual( @as(i64, 0), try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"), ); const stored = try mutations.loadConfig(bench.arena(), &bench.database); try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$")); try testing.expectEqual( auth.Outcome.ok, try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"), ); } test "changing the password revokes the old one without a restart" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var sessions: auth.Sessions = .init(24); bench.state.sessions = &sessions; var first: Patch = .{}; first.web = .{ .password = "old password" }; try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), first) == .config); const old_login = auth_handlers.applyLogin(&bench.state, bench.io(), "old password"); try testing.expect(sessions.validate(bench.io(), &old_login.cookie)); var second: Patch = .{}; second.web = .{ .password = "new password" }; try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), second) == .config); // The session minted under the old password is dead... try testing.expect(!sessions.validate(bench.io(), &old_login.cookie)); // ...the old password no longer mints one... try testing.expect(auth_handlers.applyLogin(&bench.state, bench.io(), "old password").fail == .invalid); // ...and the new one works immediately, no restart in between. const new_login = auth_handlers.applyLogin(&bench.state, bench.io(), "new password"); try testing.expect(sessions.validate(bench.io(), &new_login.cookie)); } test "a put that does not carry a password leaves the sessions alone" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var sessions: auth.Sessions = .init(24); bench.state.sessions = &sessions; const cookie = sessions.createWithToken(bench.io(), @splat(9), 1_000); var patch: Patch = .{}; patch.cache = .{ .size = 5000 }; _ = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001)); } test "an empty password is not a password change" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var patch: Patch = .{}; patch.web = .{ .password = "" }; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expectEqualStrings("", outcome.config.web.password_hash orelse ""); } test "a write that fails at the SQL layer owes no restart" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var patch: Patch = .{}; patch.dns = .{ .port = 5353 }; // The seam fails `writeSettings` after its transaction has begun, which is // the ordering this pins: the flag is stored only once the commit lands, so // a rolled-back write leaves the operator nothing to restart for. write_fault.armed = true; const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch); try testing.expect(outcome.fail == .internal); try testing.expect(!bench.state.restart_pending.load(.monotonic)); // The same patch, written for real, does raise it. try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config); try testing.expect(bench.state.restart_pending.load(.monotonic)); } test "a rejected patch raises no restart flag" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var invalid: Patch = .{}; invalid.dns = .{ .port = 0 }; try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), invalid)).fail == .invalid); try testing.expect(!bench.state.restart_pending.load(.monotonic)); var unknown_enum: Patch = .{}; unknown_enum.logging = .{ .level = "verbose" }; try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), unknown_enum)).fail == .invalid); try testing.expect(!bench.state.restart_pending.load(.monotonic)); } test "only a bind or web-lifecycle change raises the flag" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); // A live key, however many of them: nothing to restart for. var live: Patch = .{}; live.blocking = .{ .ttl = 30 }; live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 }; try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config); try testing.expect(!bench.state.restart_pending.load(.monotonic)); // `web.enabled` is the second of the two milestone-35 operations, and it // executes nothing: the row moves and the flag rises. var lifecycle: Patch = .{}; lifecycle.web = .{ .enabled = false }; try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config); try testing.expect(bench.state.restart_pending.load(.monotonic)); try testing.expectEqual( @as(i64, 1), try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"), ); } test "a patch that changes nothing applies nothing" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); // The stored port is 53, and the admin form submits every field it read. // Rewriting a value to itself is not a change and must not raise the flag. var same: Patch = .{}; same.dns = .{ .port = 53 }; try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config); try testing.expect(!bench.state.restart_pending.load(.monotonic)); } test "a password-only put leaves the restart flag alone" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seeded(&bench); var sessions: auth.Sessions = .init(24); bench.state.sessions = &sessions; var patch: Patch = .{}; patch.web = .{ .password = "correct horse battery staple" }; try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config); try testing.expect(!bench.state.restart_pending.load(.monotonic)); } test "reading the settings with no database is unavailable" { var state: server.WebState = .{ .gpa = testing.allocator }; var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const outcome = try applyPut(&state, undefined, arena_state.allocator(), .{}); try testing.expect(outcome.fail == .unavailable); }