//! `GET /api/queries` — the query log, newest first (ruling 11) — and //! `GET /api/queries/{id}`, one row of it fully explained. //! //! Keyset pagination rather than an offset: the table is append-only and the //! UI reads the head of it, so `id < before` is one index seek no matter how //! deep the client has scrolled, and rows arriving between two pages cannot //! shift the window and duplicate a row. //! //! Filter parsing is separated from fetching, because parsing is where the //! input validation of PLAN §19 lives and it is worth testing on its own. Every //! value is length-capped here and bound as a SQL parameter by the repository; //! nothing this file reads is ever concatenated into a statement. const std = @import("std"); const Allocator = std.mem.Allocator; const coverage = @import("../coverage.zig"); const db = @import("../../storage/db.zig"); const http_util = @import("../http_util.zig"); const logger = @import("../../storage/logger.zig"); const provenance_view = @import("../provenance_view.zig"); const queries_repo = @import("../../storage/repositories/queries_repo.zig"); const server = @import("../server.zig"); const log = std.log.scoped(.web_queries); pub const default_limit: u32 = 100; pub const max_limit: u32 = queries_repo.max_limit; /// 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 /// stack frame. pub const Buffers = struct { domain: [max_domain_len]u8 = undefined, client: [max_client_len]u8 = undefined, }; pub const Page = struct { queries: []const queries_repo.QueryRow, /// The cursor for the next page, or null when this page is the last one. next_before: ?i64, /// Whether the log still covers the window the filter asked for. A client /// that reads rows without reading this cannot tell an empty window from a /// pruned one. coverage: coverage.Coverage, }; pub const FilterError = error{ BadLimit, BadBefore, BadDomain, BadClient, BadBlocked, BadSince, BadUntil, }; /// Ruling 11's query string. An absent parameter drops the filter; a malformed /// one is a 400 rather than a filter silently left off, which would answer a /// question the client did not ask. pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!queries_repo.QueryFilter { var filter: queries_repo.QueryFilter = .{}; if (http_util.queryInt(u32, query, "limit") catch return error.BadLimit) |limit| { if (limit == 0 or limit > max_limit) return error.BadLimit; filter.limit = limit; } if (http_util.queryInt(i64, query, "before") catch return error.BadBefore) |before| { // Row ids are positive, so a non-positive cursor is a client bug, not // an empty page. if (before <= 0) return error.BadBefore; filter.before = before; } if (http_util.queryValue(query, "domain", &buffers.domain) catch return error.BadDomain) |domain| { if (domain.len != 0) filter.domain_substring = domain; } if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |client| { if (client.len != 0) filter.client = client; } filter.blocked = http_util.queryBool(query, "blocked") catch return error.BadBlocked; filter.since = http_util.queryInt(i64, query, "since") catch return error.BadSince; filter.until = http_util.queryInt(i64, query, "until") catch return error.BadUntil; return filter; } pub fn message(err: FilterError) []const u8 { return switch (err) { error.BadLimit => "limit must be between 1 and 1000", error.BadBefore => "before must be a positive row id", error.BadDomain => "domain is not a valid filter", error.BadClient => "client is not a valid filter", error.BadBlocked => "blocked must be true or false", error.BadSince => "since must be a unix timestamp in seconds", error.BadUntil => "until must be a unix timestamp in seconds", }; } /// A full page carries a cursor and a short one does not: a client stops when /// `next_before` is null, without a count query telling it how many rows exist. pub fn page( database: *db.Db, arena: Allocator, filter: queries_repo.QueryFilter, ) db.Error!Page { const rows = try queries_repo.selectQueries(database, arena, filter); const full = rows.items.len == @min(filter.limit, max_limit); return .{ .queries = rows.items, .next_before = if (full and rows.items.len != 0) rows.items[rows.items.len - 1].id else null, .coverage = try coverage.read(database, filter.since), }; } pub fn list( state: *server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { var buffers: Buffers = .{}; const filter = parseFilter(request.query, &buffers) catch |err| { return http_util.respondError(request, .bad_request, message(err)); }; const database = state.querylog_db orelse return http_util.respondError(request, .service_unavailable, "query log unavailable"); const result = readPage(state, io, database, request.arena, filter) catch |err| { // The one thing this handler logs: a database fault is a property of // the box, not of the request, and the client is told nothing about it. log.warn("query log read failed: {s}", .{@errorName(err)}); return http_util.respondError(request, .internal_server_error, "internal error"); }; return http_util.respondJson(request, .ok, result, &.{}); } /// `GET /api/queries/{id}` — one query, fully explained. /// /// The row is the whole answer: every field is a fact recorded when the query /// was answered, so nothing here is joined against current configuration. A /// group or blocklist renamed since keeps the name it had. pub fn detail( state: *server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { const database = state.querylog_db orelse return http_util.respondError(request, .service_unavailable, "query log unavailable"); const row = detailRow(state, io, database, request.arena, request.id.?) catch |err| { log.warn("query log read failed: {s}", .{@errorName(err)}); return http_util.respondError(request, .internal_server_error, "internal error"); }; // An id retention has pruned and one that never existed are the same // answer, and the API does not pretend to tell them apart. const found = row orelse return http_util.respondError(request, .not_found, "not found"); return http_util.respondJson(request, .ok, provenance_view.fromDetail(found), &.{}); } /// The rows and the coverage watermark come from one database state, so a /// prune between them cannot tag pre-prune rows with a post-prune /// `available_since`. fn readPage( state: *server.WebState, io: std.Io, database: *db.Db, arena: Allocator, filter: queries_repo.QueryFilter, ) db.Error!Page { var scope = try server.QuerylogRead.open(state, io, database); errdefer scope.abort(); const result = try page(database, arena, filter); try scope.commit(); return result; } /// One row, read under the shared lock. There is nothing to keep consistent /// with a second statement here; the lock is what keeps this read out of /// another response's open transaction. fn detailRow( state: *server.WebState, io: std.Io, database: *db.Db, arena: Allocator, id: i64, ) db.Error!?queries_repo.QueryDetail { var scope = try server.QuerylogRead.open(state, io, database); errdefer scope.abort(); const row = try queries_repo.detailById(database, arena, id); try scope.commit(); return row; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const provenance = @import("../../storage/provenance.zig"); const querylog_schema = @import("../../storage/querylog_schema.zig"); const testing = std.testing; test "an empty query string is the default page" { var buffers: Buffers = .{}; const filter = try parseFilter("", &buffers); try testing.expectEqual(default_limit, filter.limit); try testing.expectEqual(@as(?i64, null), filter.before); try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring); try testing.expectEqual(@as(?bool, null), filter.blocked); } test "every filter reaches the repository untouched" { var buffers: Buffers = .{}; const filter = try parseFilter( "limit=250&before=900&domain=ads.example&client=192.0.2.10&blocked=true&since=100&until=200", &buffers, ); try testing.expectEqual(@as(u32, 250), filter.limit); try testing.expectEqual(@as(?i64, 900), filter.before); try testing.expectEqualStrings("ads.example", filter.domain_substring.?); try testing.expectEqualStrings("192.0.2.10", filter.client.?); try testing.expectEqual(@as(?bool, true), filter.blocked); try testing.expectEqual(@as(?i64, 100), filter.since); try testing.expectEqual(@as(?i64, 200), filter.until); } test "an empty string filter is no filter at all" { var buffers: Buffers = .{}; const filter = try parseFilter("domain=&client=", &buffers); try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring); try testing.expectEqual(@as(?[]const u8, null), filter.client); } test "each malformed parameter names itself in a 400" { var buffers: Buffers = .{}; try testing.expectError(error.BadLimit, parseFilter("limit=0", &buffers)); try testing.expectError(error.BadLimit, parseFilter("limit=1001", &buffers)); try testing.expectError(error.BadLimit, parseFilter("limit=ten", &buffers)); try testing.expectError(error.BadBefore, parseFilter("before=0", &buffers)); try testing.expectError(error.BadBefore, parseFilter("before=-4", &buffers)); try testing.expectError(error.BadBlocked, parseFilter("blocked=maybe", &buffers)); try testing.expectError(error.BadSince, parseFilter("since=yesterday", &buffers)); try testing.expectError(error.BadUntil, parseFilter("until=", &buffers)); try testing.expectError(error.BadDomain, parseFilter("domain=%zz", &buffers)); var long: [max_domain_len + 8]u8 = @splat('a'); var text: std.ArrayList(u8) = .empty; defer text.deinit(testing.allocator); try text.appendSlice(testing.allocator, "domain="); try text.appendSlice(testing.allocator, &long); try testing.expectError(error.BadDomain, parseFilter(text.items, &buffers)); } test "the limit cap is the repository's" { var buffers: Buffers = .{}; try testing.expectEqual(max_limit, (try parseFilter("limit=1000", &buffers)).limit); try testing.expectEqual(@as(u32, 1000), queries_repo.max_limit); } fn openLog() !db.Db { var database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer database.close(); try db.applyPragmas(&database, .{}); try database.exec(querylog_schema.ddl); return database; } /// Rows the resolver could actually have written. Ruling 20 ties the three /// route facts together: a block never consulted the cache, so its `cache_hit` /// is NULL rather than false; a cache hit has no upstream to name; and only an /// upstream answer carries one. A fixture that broke those ties would let a /// serializer regression pass here and fail on real rows. fn seed(database: *db.Db, count: usize) !void { var writer = try queries_repo.BatchWriter.init(testing.allocator, database); defer writer.deinit(); var rows: [16]queries_repo.Row = undefined; for (rows[0..count], 0..) |*row, i| { const blocked = i % 2 == 0; const from_cache = i % 4 == 1; row.* = .{ .timestamp = 1_700_000_000 + @as(i64, @intCast(i)), .domain = if (blocked) "ads.example" else "safe.example", .client_ip = "192.0.2.10", .qtype = 1, .qclass = 1, .rcode = 0, .blocked = blocked, .response_time_us = 500, .cache_hit = if (blocked) null else from_cache, .upstream = if (blocked or from_cache) null else "9.9.9.9", .group_id = 1, .group_name = "default", .policy_action = if (blocked) .block else .allow, .policy_reason = if (blocked) .blocklist_domain else .no_match, .matched = if (blocked) "ads.example" else null, .source_id = null, .source_name = null, .cname_target = null, .safe_search_target = null, .route_kind = if (blocked) .blocked else if (from_cache) .cache else .upstream, .forward_zone = null, }; } try writer.writeBatch(rows[0..count]); } test "a full page carries a cursor and the last page does not" { var database = try openLog(); defer database.close(); try seed(&database, 5); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const first = try page(&database, arena.allocator(), .{ .limit = 2 }); try testing.expectEqual(@as(usize, 2), first.queries.len); try testing.expectEqual(first.queries[1].id, first.next_before.?); // Newest first. try testing.expect(first.queries[0].id > first.queries[1].id); const second = try page(&database, arena.allocator(), .{ .limit = 2, .before = first.next_before }); try testing.expect(second.queries[0].id < first.queries[1].id); const third = try page(&database, arena.allocator(), .{ .limit = 2, .before = second.next_before }); try testing.expectEqual(@as(usize, 1), third.queries.len); try testing.expectEqual(@as(?i64, null), third.next_before); } test "an empty result is a page with no cursor" { var database = try openLog(); defer database.close(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const empty = try page(&database, arena.allocator(), .{}); try testing.expectEqual(@as(usize, 0), empty.queries.len); try testing.expectEqual(@as(?i64, null), empty.next_before); } test "the parsed filters narrow the rows the page returns" { var database = try openLog(); defer database.close(); try seed(&database, 6); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var buffers: Buffers = .{}; const blocked = try page( &database, arena.allocator(), try parseFilter("blocked=true", &buffers), ); try testing.expectEqual(@as(usize, 3), blocked.queries.len); for (blocked.queries) |row| try testing.expect(row.blocked); const by_domain = try page( &database, arena.allocator(), try parseFilter("domain=safe", &buffers), ); try testing.expectEqual(@as(usize, 3), by_domain.queries.len); for (by_domain.queries) |row| try testing.expectEqualStrings("safe.example", row.domain); const nobody = try page( &database, arena.allocator(), try parseFilter("client=198.51.100.1", &buffers), ); try testing.expectEqual(@as(usize, 0), nobody.queries.len); } test "the seeded rows carry only the route shapes ruling 20 allows" { var database = try openLog(); defer database.close(); try seed(&database, 4); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var seen: std.EnumSet(provenance.RouteKind) = .initEmpty(); for ((try page(&database, arena.allocator(), .{})).queries) |row| { seen.insert(row.route_kind); switch (row.route_kind) { .blocked => { try testing.expectEqual(@as(?bool, null), row.cache_hit); try testing.expectEqualStrings("", row.upstream); }, .cache => { try testing.expectEqual(@as(?bool, true), row.cache_hit); try testing.expectEqualStrings("", row.upstream); }, .upstream => { try testing.expectEqual(@as(?bool, false), row.cache_hit); try testing.expectEqualStrings("9.9.9.9", row.upstream); }, .local, .forward_zone, .rejected => return error.UnseededRouteKind, } } // All three, so the serializer tests below read every shape the fixture claims. try testing.expectEqual(@as(usize, 3), seen.count()); } test "the page serializes as the envelope ruling 11 defines" { var database = try openLog(); defer database.close(); try seed(&database, 1); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const result = try page(&database, arena.allocator(), .{ .limit = 100 }); var allocating: std.Io.Writer.Allocating = .init(testing.allocator); defer allocating.deinit(); try std.json.Stringify.value(result, .{}, &allocating.writer); const text = allocating.written(); try testing.expect(std.mem.startsWith(u8, text, "{\"queries\":[")); for ([_][]const u8{ "\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":", "\"qtype\":", "\"qclass\":", "\"rcode\":", "\"blocked\":", "\"cache_hit\":", "\"upstream\":", "\"response_time_us\":", "\"policy_action\":", "\"policy_reason\":", "\"route_kind\":", }) |field| { try testing.expect(std.mem.containsAtLeast(u8, text, 1, field)); } // W1's ruling: a NULL column reads as "", and "" stays "" on the wire. try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\"")); // The column the provenance columns replaced. try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "block_reason")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"coverage\":{\"complete\":")); } test "the coverage of a page answers the window the filter asked for" { var database = try openLog(); defer database.close(); try seed(&database, 1); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const watermark = try queries_repo.availableSince(&database); const unbounded = try page(&database, arena.allocator(), .{}); try testing.expectEqual(watermark, unbounded.coverage.available_since); try testing.expect(!unbounded.coverage.complete); const covered = try page(&database, arena.allocator(), .{ .since = watermark }); try testing.expect(covered.coverage.complete); const older = try page(&database, arena.allocator(), .{ .since = watermark - 1 }); try testing.expect(!older.coverage.complete); } test "a detail row carries every provenance field the row stored" { var database = try openLog(); defer database.close(); try seed(&database, 1); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const row = (try queries_repo.detailById(&database, arena, 1)).?; const view = provenance_view.fromDetail(row); try testing.expectEqual(@as(i64, 1), view.id); try testing.expectEqualStrings("ads.example", view.request.domain); try testing.expectEqualStrings("192.0.2.10", view.request.client); try testing.expectEqual(@as(u16, 1), view.request.qclass); try testing.expectEqualStrings("default", view.group.name); try testing.expectEqual(provenance.PolicyAction.block, view.policy.action); try testing.expectEqualStrings("ads.example", view.policy.matched); try testing.expectEqual(provenance.RouteKind.blocked, view.route.kind); try testing.expectEqualStrings("", view.route.upstream); try testing.expectEqual(@as(?i64, 500), view.response.duration_us); try testing.expectEqual( @as(?queries_repo.QueryDetail, null), try queries_repo.detailById(&database, arena, 99), ); }