Files
nxdns/src/web/router.zig
T
mokhtar cc23c97218
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
2026-08-22 23:31:37 +02:00

318 lines
13 KiB
Zig

//! Route matching and dispatch.
//!
//! The table is a flat array of literal patterns with at most one `{id}`
//! capture, matched segment by segment. A LAN admin API has a few dozen routes
//! and one request per user action, so a linear scan is the whole algorithm —
//! a trie would buy nothing and cost a build step.
//!
//! Dispatch is where the cross-cutting policies live, in the order a request
//! meets them: match, rate limit, authenticate, handle. Matching comes first
//! because both the limiter exemption (ruling 19: `/metrics` and `/api/health`
//! must never see a 429) and the auth exemption (ruling 18) are properties of
//! the matched route, not of the raw path.
const std = @import("std");
const http = std.http;
const http_util = @import("http_util.zig");
const routes_table = @import("routes.zig");
const server = @import("server.zig");
/// Every route the server serves. Ruling 23 reads this to prove the OpenAPI
/// document and the contract test cover the whole surface.
pub const routes: []const RouteInfo = routes_table.table;
pub const HandlerFn = *const fn (
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void;
/// Whether a route needs a session cookie when authentication is enabled.
/// Ruling 18 lists the open ones: health, version, metrics, the OpenAPI
/// document, login, and the static assets.
pub const Auth = enum { open, session };
/// Whether a route spends an API rate-limit token. Ruling 19 exempts the two
/// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt };
/// What a route does to the configuration, and therefore whether file
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
/// declarative state the managed file owns; `runtime_action` changes runtime
/// state the file never declares; `read` changes nothing.
pub const Policy = enum { read, config_write, runtime_action };
pub const RouteInfo = struct {
method: http.Method,
/// Segments separated by `/`, with at most one `{id}` capture, which must
/// be a positive integer row id.
pattern: []const u8,
auth: Auth,
/// No default: a new route states its class or does not compile.
policy: Policy,
handler: HandlerFn,
rate_limit: RateLimit = .counted,
};
pub const Match = union(enum) {
found: Found,
/// The path matches a route registered under a different method.
method_not_allowed,
not_found,
pub const Found = struct {
route: *const RouteInfo,
id: ?i64,
};
};
/// Matches `segments` (already decoded) against `table`.
pub fn match(
table: []const RouteInfo,
method: http.Method,
segments: []const []const u8,
) Match {
var path_exists = false;
for (table) |*route| {
const id = matchPattern(route.pattern, segments) orelse continue;
if (route.method != method) {
path_exists = true;
continue;
}
return .{ .found = .{ .route = route, .id = id } };
}
return if (path_exists) .method_not_allowed else .not_found;
}
/// Returns the `{id}` capture, or a null capture for a pattern without one.
/// The outer optional is "did the pattern match at all".
fn matchPattern(pattern: []const u8, segments: []const []const u8) ??i64 {
var id: ?i64 = null;
var index: usize = 0;
var rest = pattern;
while (rest.len != 0) {
const end = std.mem.findScalar(u8, rest, '/') orelse rest.len;
const part = rest[0..end];
rest = if (end == rest.len) rest[end..] else rest[end + 1 ..];
if (part.len == 0) continue;
if (index == segments.len) return null;
const segment = segments[index];
index += 1;
if (std.mem.eql(u8, part, "{id}")) {
id = std.fmt.parseInt(i64, segment, 10) catch return null;
// A row id is a positive integer; `-1` must 404, not reach SQL.
if (id.? <= 0) return null;
continue;
}
if (!std.mem.eql(u8, part, segment)) return null;
}
if (index != segments.len) return null;
return id;
}
/// Fills `buf` with the `Allow` header value for a path that matched under
/// other methods. The returned slice borrows `buf`.
fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
var writer: std.Io.Writer = .fixed(buf);
var first = true;
for (table) |*route| {
if (matchPattern(route.pattern, segments) == null) continue;
if (!first) writer.writeAll(", ") catch break;
writer.writeAll(@tagName(route.method)) catch break;
first = false;
}
return writer.buffered();
}
/// Runs one request to completion: match, limit, authenticate, handle.
///
/// Every exit responds. A `WriteFailed` on the way out is the client
/// disconnecting (ruling 28) and ends the connection.
pub fn dispatch(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const segments = request.path.segments();
const found = switch (match(state.routes, request.method, segments)) {
.found => |f| f,
.method_not_allowed => {
var buf: [64]u8 = undefined;
const allow = formatAllow(state.routes, segments, &buf);
return respondMethodNotAllowed(request, allow);
},
// Ruling 24: an unknown non-`/api` path is the SPA's, and the static
// handler answers it with index.html so client-side routing works. An
// unknown `/api` path is a real 404 and must stay JSON.
.not_found => {
if (state.fallback) |fallback| {
if (!std.mem.eql(u8, request.firstSegment(), "api")) {
return fallback(state, io, request);
}
}
return http_util.respondError(request, .not_found, "not found");
},
};
request.id = found.id;
if (found.route.rate_limit == .counted) {
const verdict = state.check_limit(state, io, request);
if (!verdict.allowed) return respondRateLimited(request, verdict.retry_after_s);
}
if (found.route.auth == .session and !state.check_auth(state, io, request)) {
return http_util.respondError(request, .unauthorized, "authentication required");
}
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
// rejecting before authenticating would tell an anonymous caller which
// routes exist. An unauthenticated request to a protected route answers
// 401 in both authority modes.
if (found.route.policy == .config_write) {
switch (state.authority) {
.database => {},
.managed_file => |path| return http_util.respondManagedByFile(request, path),
}
}
return found.route.handler(state, io, request);
}
fn respondMethodNotAllowed(request: *http_util.Request, allow: []const u8) http_util.HandlerError!void {
return http_util.respondBytes(
request,
.method_not_allowed,
"{\"error\":\"method not allowed\"}",
http_util.content_type_json,
&.{.{ .name = "allow", .value = allow }},
);
}
fn respondRateLimited(request: *http_util.Request, retry_after_seconds: u32) http_util.HandlerError!void {
var buf: [16]u8 = undefined;
const retry_after = std.fmt.bufPrint(&buf, "{d}", .{retry_after_seconds}) catch "60";
return http_util.respondBytes(
request,
.too_many_requests,
"{\"error\":\"rate limited\"}",
http_util.content_type_json,
&.{.{ .name = "retry-after", .value = retry_after }},
);
}
const testing = std.testing;
fn noopHandler(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = state;
_ = io;
_ = request;
}
const test_table = [_]RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
};
fn matchPath(method: http.Method, path: []const u8) Match {
var buf: [128]u8 = undefined;
@memcpy(buf[0..path.len], path);
const parsed = http_util.parsePath(buf[0..path.len]) catch return .not_found;
return match(&test_table, method, parsed.segments());
}
test "the matching table resolves every registered shape" {
const cases = [_]struct { method: http.Method, path: []const u8, id: ?i64 }{
.{ .method = .GET, .path = "/api/health", .id = null },
.{ .method = .GET, .path = "/api/groups", .id = null },
.{ .method = .POST, .path = "/api/groups", .id = null },
.{ .method = .GET, .path = "/api/groups/7", .id = 7 },
.{ .method = .PUT, .path = "/api/groups/7", .id = 7 },
.{ .method = .DELETE, .path = "/api/groups/12", .id = 12 },
.{ .method = .PUT, .path = "/api/groups/12/sources", .id = 12 },
};
for (cases) |case| {
const found = matchPath(case.method, case.path).found;
try testing.expectEqual(case.id, found.id);
try testing.expectEqual(case.method, found.route.method);
}
}
test "a trailing slash matches the same route" {
try testing.expectEqual(@as(?i64, 7), matchPath(.GET, "/api/groups/7/").found.id);
try testing.expectEqual(@as(?i64, null), matchPath(.GET, "/api/groups/").found.id);
}
test "an unregistered path is not found" {
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/nope")));
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api")));
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/7/sources/1")));
}
test "a non-numeric or non-positive id does not match the capture" {
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/abc")));
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/0")));
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/-1")));
}
test "a known path under an unknown method is 405, not 404" {
try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.DELETE, "/api/groups")));
try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.POST, "/api/groups/7")));
try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.PUT, "/api/health")));
}
test "the allow header lists every method the path accepts" {
var path_buf = "/api/groups".*;
const collection = try http_util.parsePath(&path_buf);
var buf: [64]u8 = undefined;
try testing.expectEqualStrings("GET, POST", formatAllow(&test_table, collection.segments(), &buf));
var item_buf = "/api/groups/7".*;
const item = try http_util.parsePath(&item_buf);
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
}
test "matching carries the class the table declares, per route and not per prefix" {
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
.{ .method = .GET, .path = "/api/groups", .policy = .read },
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
// Same prefix, different class: the column is per route.
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
};
for (cases) |case| {
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
}
}
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
var refresh: ?Policy = null;
var create: ?Policy = null;
for (routes) |route| {
if (route.method != .POST) continue;
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
}
try testing.expectEqual(Policy.runtime_action, refresh.?);
try testing.expectEqual(Policy.config_write, create.?);
}
test "the shipped route table is the one the router matches against" {
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
try testing.expectEqual(routes_table.table.len, routes.len);
}