milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
CI / test (push) Successful in 1m46s
CI / test-aarch64 (push) Successful in 5m30s
CI / frontend (push) Successful in 46s
CI / cross (push) Successful in 8m12s
CI / docker (push) Successful in 3m46s

This commit is contained in:
2026-08-07 20:39:27 +02:00
parent 6f67940995
commit 6c507992e4
59 changed files with 1020 additions and 382 deletions
+2 -2
View File
@@ -116,13 +116,13 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
// Ruling 18: a refused login is a 401, not the 400 an invalid value
// would earn elsewhere. Only the address and the outcome are logged.
if (failure == .invalid) {
log.warn("web login refused for {f}", .{request.peer});
log.warn("web login refused for {f}", .{request.client_addr});
return http_util.respondError(request, .unauthorized, "invalid password");
}
return mutations.respondFailure(request, failure, "verifying the web password");
},
.cookie => |cookie| {
log.info("web login accepted for {f}", .{request.peer});
log.info("web login accepted for {f}", .{request.client_addr});
var buf: [cookie_buf_len]u8 = undefined;
const header = http_util.formatSetCookie(
&buf,
+14
View File
@@ -259,6 +259,20 @@ fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expecte
///
/// A state with no `reload_fn` has nothing to reload — that is the shape of a
/// web layer under test, and of one whose composition root wired no manager.
///
/// Locking contract, and it is the reason every one of the fourteen call sites
/// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call
/// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of
/// the state it publishes from the database itself, under the manager's own
/// writer lock. It must never accept rows the caller read. Rows read under
/// `config_lock` and passed across its release are already stale, so a
/// signature change that adds a row parameter here silently breaks the
/// correctness of all fourteen sites — every one of them would have to move
/// the call back inside the lock.
///
/// `swapLocalTables` below is the pre-read shape and is exactly the contrast:
/// it takes the rows, so `local.zig`'s `publish` calls it while it still holds
/// `config_lock`, and the ordering rationale lives on that function.
pub fn reload(state: *server.WebState, io: std.Io) ?Failure {
const reload_fn = state.reload_fn orelse return null;
reload_fn(state, io) catch |err| {
+5 -5
View File
@@ -15,6 +15,7 @@ const Allocator = std.mem.Allocator;
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const logger = @import("../../storage/logger.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const server = @import("../server.zig");
@@ -23,11 +24,10 @@ const log = std.log.scoped(.web_queries);
pub const default_limit: u32 = 100;
pub const max_limit: u32 = queries_repo.max_limit;
/// A domain filter longer than the longest legal domain name matches nothing.
pub const max_domain_len = 253;
/// Long enough for an IPv6 address with a zone identifier.
pub const max_client_len = 64;
/// The filter widths are the stored widths: a filter wider than the column it
/// compares against could only match a row the query log cannot hold.
pub const max_domain_len = logger.max_domain_len;
pub const max_client_len = logger.max_client_len;
/// Where the two string filters are copied to. The parsed filter borrows them,
/// so it must not outlive the buffers — in the handler both live in the same
+46 -14
View File
@@ -332,10 +332,10 @@ pub fn applyPut(
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
if (writeSettings(arena, database, cfg)) |err| {
writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash);
return .{ .fail = .{ .internal = err } };
}
};
if (replacement) |hash| {
// Ruling 17, both halves: the running server must verify against the
@@ -354,26 +354,36 @@ pub fn applyPut(
/// 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 {
fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error!void {
var pairs: std.ArrayList(model.SettingPair) = .empty;
model.toSettings(cfg, arena, &pairs) catch return error.OutOfMemory;
try model.toSettings(cfg, arena, &pairs);
var tx = db.Tx.begin(database) catch |err| return err;
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
try write_fault.check();
for (pairs.items) |pair| {
settings_repo.putSetting(database, pair.key, pair.value) catch |err| {
tx.rollback();
return err;
};
try settings_repo.putSetting(database, pair.key, pair.value);
}
tx.commit() catch |err| {
tx.rollback();
return err;
};
return null;
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);
}
@@ -581,6 +591,28 @@ test "a put that would not validate writes nothing" {
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);
+4 -1
View File
@@ -349,7 +349,10 @@ pub fn respondBytes(
) HandlerError!void {
var headers: [8]http.Header = undefined;
headers[0] = .{ .name = "content-type", .value = content_type };
if (extra_headers.len + 1 > headers.len) return error.OutOfMemory;
// Every call site passes a comptime-known list, the longest of them three
// headers, so overflowing this array is a programmer error and never a
// condition a request can produce.
std.debug.assert(extra_headers.len + 1 <= headers.len);
@memcpy(headers[1 .. 1 + extra_headers.len], extra_headers);
return request.http.respond(body, .{
.status = status,
+10 -2
View File
@@ -728,8 +728,16 @@ test "every HELP line has a TYPE line and a sample, and every sample a name" {
}
try testing.expectEqual(helps, types);
try testing.expectEqual(helps, samples);
// `nxdns_up` plus every DNS counter: the families a bare state still has.
try testing.expectEqual(1 + dns_stat_fields.len + 7, samples);
// The families a bare state still has: `nxdns_up`, every DNS counter, the
// query log writer's, and the diagnostic log sink's. Derived rather than
// counted, so a new counter in any of those structs extends the exposition
// and this assertion together.
try testing.expectEqual(
1 + dns_stat_fields.len +
@typeInfo(LoggerCounters).@"struct".fields.len +
@typeInfo(logging.Stats).@"struct".fields.len,
samples,
);
// The reflective walk names the counters, so a renamed `Handler.Stats`
// field silently renames a scraped series. Pin the ones an operator alerts
// on by name.
+3
View File
@@ -156,6 +156,9 @@ pub const WebState = struct {
querylog_db: ?*db.Db = null,
version: []const u8 = "",
/// The `--web-dev` asset directory, read by the dev-mode fallback. Empty
/// whenever that fallback is not wired.
dev_dir: []const u8 = "",
/// Unix seconds at process start, for uptime.
started_unix: i64 = 0,
+13
View File
@@ -434,6 +434,19 @@ test "the embedded dist has an index and consistent gzip siblings" {
// The gzip member header: build-time compression, not an accident.
try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);
// A sibling is served under its base file's identity, so equal
// content is the only thing that makes the substitution honest.
var input: std.Io.Reader = .fixed(file.bytes);
const window = try testing.allocator.alloc(u8, std.compress.flate.max_window_len);
defer testing.allocator.free(window);
var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
const plain = try decompress.reader.allocRemaining(
testing.allocator,
.limited(max_disk_asset_bytes),
);
defer testing.allocator.free(plain);
try testing.expectEqualSlices(u8, base.bytes, plain);
}
}
}