milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+192 -15
View File
@@ -62,6 +62,7 @@ const handlers_live = @import("handlers/live.zig");
const handlers_lookup = @import("handlers/lookup.zig");
const handlers_pause = @import("handlers/pause.zig");
const handlers_queries = @import("handlers/queries.zig");
const handlers_settings = @import("handlers/settings.zig");
const handlers_stats = @import("handlers/stats.zig");
const handlers_upstream_health = @import("handlers/upstream_health.zig");
const handlers_version = @import("handlers/version.zig");
@@ -147,7 +148,9 @@ const Conn = struct {
extra_header: ?[]const u8,
body: ?[]const u8,
) !void {
var buf: [2048]u8 = undefined;
// Room for an over-budget cookie header (ruling 7 of milestone 16) and
// still under the server's 8 KiB maximum request head.
var buf: [6144]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try w.print("{s} {s} HTTP/1.1\r\nhost: t\r\n", .{ method, target });
if (extra_header) |line| try w.print("{s}\r\n", .{line});
@@ -814,6 +817,19 @@ test "the certs reload payload with both endpoints disabled parses strictly" {
const test_password = "correct horse battery staple";
/// Hashes on an `Io` of its own, before the environment exists, so nothing
/// mutates a `WebState` the server tasks are already reading.
fn hashTestPassword(gpa: Allocator, buf: []u8) ![]const u8 {
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
defer hash_threaded.deinit();
return std.crypto.pwhash.argon2.strHash(test_password, .{
.allocator = gpa,
.params = .owasp_2id,
.mode = .argon2id,
.encoding = .phc,
}, buf, hash_threaded.io());
}
fn authMatrix(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
@@ -871,21 +887,8 @@ test "W10 auth on: password-hashed environment enforces the session matrix" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
// Hashed before the environment exists, so nothing mutates a `WebState`
// the server tasks are reading.
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
var hash_buf: [256]u8 = undefined;
const hash = std.crypto.pwhash.argon2.strHash(test_password, .{
.allocator = gpa,
.params = .owasp_2id,
.mode = .argon2id,
.encoding = .phc,
}, &hash_buf, hash_threaded.io()) catch |err| {
hash_threaded.deinit();
return err;
};
hash_threaded.deinit();
const hash = try hashTestPassword(gpa, &hash_buf);
var env = try Env.create(gpa, .{ .password_hash = hash });
defer env.destroy();
@@ -923,6 +926,77 @@ test "W10 auth off: an empty hash leaves every route open" {
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// oversized cookie headers (ruling 7 of milestone 16)
// ---------------------------------------------------------------------------
/// Pads `list` past `http_util.max_cookie_len` with foreign cookies, the way a
/// reverse proxy on a shared domain does.
fn padCookies(gpa: Allocator, list: *std.ArrayList(u8)) !void {
var index: usize = 0;
while (list.items.len <= http_util.max_cookie_len * 2) : (index += 1) {
try list.print(gpa, "ad_id_{d}=0123456789abcdef; ", .{index});
}
}
fn oversizedCookie(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
var session_pair: [256]u8 = undefined;
@memcpy(session_pair[0..pair_end], set_cookie[0..pair_end]);
// The session pair buried in the middle of an over-budget header. Before
// ruling 7 the whole header read as absent and this 401'd.
var with_session: std.ArrayList(u8) = .empty;
defer with_session.deinit(env.gpa);
try with_session.appendSlice(env.gpa, "cookie: ");
try padCookies(env.gpa, &with_session);
try with_session.appendSlice(env.gpa, session_pair[0..pair_end]);
try with_session.appendSlice(env.gpa, "; ");
try padCookies(env.gpa, &with_session);
try testing.expect(with_session.items.len > http_util.max_cookie_len);
try conn.request("GET", "/api/groups", with_session.items, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// The same size of header carrying no session pair stays unauthenticated:
// the degradation keeps the session, it does not open the door.
var without_session: std.ArrayList(u8) = .empty;
defer without_session.deinit(env.gpa);
try without_session.appendSlice(env.gpa, "cookie: ");
try padCookies(env.gpa, &without_session);
try testing.expect(without_session.items.len > http_util.max_cookie_len);
try conn.request("GET", "/api/groups", without_session.items, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 401), response.status);
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
}
test "W10 a 2 KiB cookie header keeps the session and still refuses without one" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var hash_buf: [256]u8 = undefined;
const hash = try hashTestPassword(gpa, &hash_buf);
var env = try Env.create(gpa, .{ .password_hash = hash });
defer env.destroy();
try bounded(env.io(), default_budget, oversizedCookie, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// rate limiting (ruling 19)
// ---------------------------------------------------------------------------
@@ -1023,6 +1097,58 @@ test "W10 SSE: retry preamble, query frame, per-address cap and heartbeat" {
try bounded(env.io(), sse_budget, sseStream, .{ env.io(), env });
}
/// Opens a live stream and reads the preamble, so the subscriber task is in
/// its wait loop by the time this returns.
fn openLiveStream(io: std.Io, env: *Env, conn: *Conn, seen: *std.ArrayList(u8)) anyerror!void {
try conn.connect(io, env.addr);
// Stays open on success — the caller closes it. Only a failure here leaves
// a socket for this to reclaim.
errdefer conn.close(io);
try conn.request("GET", "/api/queries/live", null, null);
const head = try conn.receiveHead();
try testing.expectEqual(@as(u16, 200), head.status);
try conn.readChunkedUntil(seen, env.gpa, "retry: 3000");
}
test "W10 shutdown with a live stream open does not wait out a heartbeat" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
// The teardown frees the environment's own `Io`, so the clock that times it
// has to be somebody else's.
var clock_threaded: std.Io.Threaded = .init(gpa, .{});
defer clock_threaded.deinit();
const clock_io = clock_threaded.io();
var env = try Env.create(gpa, .{});
var conn: Conn = undefined;
var seen: std.ArrayList(u8) = .empty;
defer seen.deinit(gpa);
// Not a `defer`: the teardown is what this test measures, so it runs below
// rather than after the assertion.
bounded(env.io(), default_budget, openLiveStream, .{ env.io(), env, &conn, &seen }) catch |err| {
env.destroy();
return err;
};
// Closed before the teardown because the teardown frees the `Io` this
// socket belongs to. It does not weaken the test: the subscriber task is
// parked on a hub event, not on this socket, so closing the client end
// does not wake it — only `Hub.close` does (ruling 11 of milestone 16).
conn.close(env.io());
const started = std.Io.Clock.awake.now(clock_io);
env.destroy();
const elapsed = started.durationTo(std.Io.Clock.awake.now(clock_io));
// Before ruling 11 the drain waited out the full 15 s heartbeat interval.
try testing.expect(elapsed.toMilliseconds() < 5_000);
}
// ---------------------------------------------------------------------------
// pagination walk (ruling 11)
// ---------------------------------------------------------------------------
@@ -1399,6 +1525,57 @@ test "W10 settings written through the API read back changed" {
try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// password hashing outside config_lock (ruling 18 of milestone 16)
// ---------------------------------------------------------------------------
fn putNewPassword(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16 * 1024]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
fn getWhileHashParked(io: std.Io, env: *Env) anyerror!void {
var put = try io.concurrent(putNewPassword, .{ io, env });
errdefer {
handlers_settings.hash_stall_control.release(io);
put.await(io) catch {};
}
handlers_settings.hash_stall_control.waitParked(io);
// The whole point of the ruling: this read completes while the hash is
// still held. Before the fix it blocked on `config_lock` until the hash
// finished, and the seam would deadlock the test rather than answer.
var body_buf: [16 * 1024]u8 = undefined;
var reader: Conn = undefined;
try reader.connect(io, env.addr);
defer reader.close(io);
try reader.request("GET", "/api/settings", null, null);
const response = try reader.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
handlers_settings.hash_stall_control.release(io);
try put.await(io);
}
test "W10 a settings read completes while a password hash is still running" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
handlers_settings.hash_stall_control.arm();
try bounded(env.io(), default_budget, getWhileHashParked, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// static assets: /, SPA fallback, ETag → 304 (ruling 24)
// ---------------------------------------------------------------------------