milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user