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
373 lines
13 KiB
Zig
373 lines
13 KiB
Zig
//! `/api/rules` — the per-group allow and block rules.
|
|
//!
|
|
//! A rule names its group by row id, not by name: the API identifies every
|
|
//! resource by id, and a `group_id` no group holds is then the foreign-key
|
|
//! violation it is (409) rather than a lookup that quietly writes nothing.
|
|
//!
|
|
//! `kind` and `action` travel as the words the database stores (`exact` /
|
|
//! `wildcard`, `allow` / `block`), so one vocabulary describes a rule in the
|
|
//! config file, in the database and on the wire.
|
|
//!
|
|
//! Rules take effect live: the write is followed by the reload seam, and the
|
|
//! next query is matched against the new snapshot (ruling 12).
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const http_util = @import("../http_util.zig");
|
|
const model = @import("../../config/model.zig");
|
|
const mutations = @import("mutations.zig");
|
|
const rules_repo = @import("../../storage/repositories/rules_repo.zig");
|
|
const server = @import("../server.zig");
|
|
|
|
const Failure = mutations.Failure;
|
|
const Request = http_util.Request;
|
|
const HandlerError = http_util.HandlerError;
|
|
|
|
const group_conflict = "that group does not exist";
|
|
|
|
const Body = struct {
|
|
group_id: i64,
|
|
pattern: []const u8,
|
|
kind: []const u8,
|
|
action: []const u8,
|
|
};
|
|
|
|
const Created = union(enum) { id: i64, fail: Failure };
|
|
|
|
/// A body's `kind` and `action` decoded, or the 400 that says which word was
|
|
/// not understood.
|
|
fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } {
|
|
const kind = model.RuleKind.fromDb(body.kind) orelse
|
|
return .{ .fail = .{ .invalid = "kind must be 'exact', 'wildcard' or 'regex'" } };
|
|
const action = model.RuleAction.fromDb(body.action) orelse
|
|
return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } };
|
|
return .{ .input = .{
|
|
.group_id = body.group_id,
|
|
.pattern = body.pattern,
|
|
.kind = kind,
|
|
.action = action,
|
|
} };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// decisions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn applyCreate(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
item: rules_repo.RuleInput,
|
|
) error{OutOfMemory}!Created {
|
|
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
|
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
|
|
return .{ .fail = .{ .invalid = problem } };
|
|
}
|
|
|
|
state.config_lock.lockUncancelable(io);
|
|
const inserted = rules_repo.insertRuleRow(database, item, mutations.nowSeconds(io));
|
|
state.config_lock.unlock(io);
|
|
|
|
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, group_conflict) };
|
|
if (mutations.reload(state, io)) |failure| return .{ .fail = failure };
|
|
return .{ .id = id };
|
|
}
|
|
|
|
fn applyUpdate(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
id: i64,
|
|
item: rules_repo.RuleInput,
|
|
) error{OutOfMemory}!?Failure {
|
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
|
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
|
|
return .{ .invalid = problem };
|
|
}
|
|
|
|
state.config_lock.lockUncancelable(io);
|
|
const written = rules_repo.updateRule(database, id, item);
|
|
state.config_lock.unlock(io);
|
|
|
|
written catch |err| return mutations.dbFailure(err, group_conflict);
|
|
return mutations.reload(state, io);
|
|
}
|
|
|
|
fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
|
|
|
state.config_lock.lockUncancelable(io);
|
|
const written = rules_repo.deleteRule(database, id);
|
|
state.config_lock.unlock(io);
|
|
|
|
written catch |err| return mutations.dbFailure(err, group_conflict);
|
|
return mutations.reload(state, io);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// routes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The wire shape of a rule: the row with its enums spelled the way the
|
|
/// database spells them.
|
|
const RuleView = struct {
|
|
id: i64,
|
|
group_id: i64,
|
|
group: []const u8,
|
|
pattern: []const u8,
|
|
kind: []const u8,
|
|
action: []const u8,
|
|
created_at: i64,
|
|
|
|
fn from(row: rules_repo.RuleRow) RuleView {
|
|
return .{
|
|
.id = row.id,
|
|
.group_id = row.group_id,
|
|
.group = row.group,
|
|
.pattern = row.pattern,
|
|
.kind = row.kind.toDb(),
|
|
.action = row.action.toDb(),
|
|
.created_at = row.created_at,
|
|
};
|
|
}
|
|
};
|
|
|
|
const resource = mutations.Resource(.{
|
|
.Row = rules_repo.RuleRow,
|
|
.list = rules_repo.listRuleRows,
|
|
.get = rules_repo.getRule,
|
|
.remove = applyDelete,
|
|
.label = "a rule",
|
|
.plural = "rules",
|
|
.envelope = "rules",
|
|
.view = RuleView.from,
|
|
});
|
|
|
|
pub const list = resource.list;
|
|
pub const get = resource.get;
|
|
pub const remove = resource.remove;
|
|
|
|
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
|
const parsed = http_util.parseBody(Body, request) catch |err|
|
|
return mutations.respondBadBody(request, err);
|
|
const item = switch (toInput(parsed.value)) {
|
|
.fail => |failure| return mutations.respondFailure(request, failure, "creating a rule"),
|
|
.input => |value| value,
|
|
};
|
|
|
|
return switch (try applyCreate(state, io, request.arena, item)) {
|
|
.fail => |failure| mutations.respondFailure(request, failure, "creating a rule"),
|
|
.id => |id| http_util.respondJson(request, .created, .{
|
|
.id = id,
|
|
.group_id = item.group_id,
|
|
.pattern = item.pattern,
|
|
.kind = item.kind.toDb(),
|
|
.action = item.action.toDb(),
|
|
}, &.{}),
|
|
};
|
|
}
|
|
|
|
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
|
const parsed = http_util.parseBody(Body, request) catch |err|
|
|
return mutations.respondBadBody(request, err);
|
|
const item = switch (toInput(parsed.value)) {
|
|
.fail => |failure| return mutations.respondFailure(request, failure, "updating a rule"),
|
|
.input => |value| value,
|
|
};
|
|
const id = request.id.?;
|
|
|
|
if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
|
|
return mutations.respondFailure(request, failure, "updating a rule");
|
|
}
|
|
return http_util.respondJson(request, .ok, .{
|
|
.id = id,
|
|
.group_id = item.group_id,
|
|
.pattern = item.pattern,
|
|
.kind = item.kind.toDb(),
|
|
.action = item.action.toDb(),
|
|
}, &.{});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
const block_ads: rules_repo.RuleInput = .{
|
|
.group_id = 1,
|
|
.pattern = "ads.example",
|
|
.kind = .exact,
|
|
.action = .block,
|
|
};
|
|
|
|
test "a created rule is stored with the clock's created_at and reloads" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads);
|
|
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
|
|
|
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
|
|
try testing.expectEqualStrings("ads.example", row.pattern);
|
|
try testing.expectEqual(model.RuleAction.block, row.action);
|
|
try testing.expectEqualStrings("default", row.group);
|
|
try testing.expect(row.created_at > 0);
|
|
}
|
|
|
|
test "a pattern the validator refuses never reaches the database" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
const starred = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
|
.group_id = 1,
|
|
.pattern = "ads.*.example",
|
|
.kind = .exact,
|
|
.action = .block,
|
|
});
|
|
try testing.expect(starred.fail == .invalid);
|
|
|
|
const starless = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
|
.group_id = 1,
|
|
.pattern = "ads.example",
|
|
.kind = .wildcard,
|
|
.action = .allow,
|
|
});
|
|
try testing.expect(starless.fail == .invalid);
|
|
|
|
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules"));
|
|
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
|
}
|
|
|
|
test "a group id no group holds is a conflict" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
|
.group_id = 404,
|
|
.pattern = "ads.example",
|
|
.kind = .exact,
|
|
.action = .block,
|
|
});
|
|
try testing.expectEqualStrings(group_conflict, created.fail.conflict);
|
|
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
|
}
|
|
|
|
test "an edited rule keeps its created_at" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads);
|
|
const before = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?.created_at;
|
|
|
|
const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
|
|
.group_id = 1,
|
|
.pattern = "*.ads.example",
|
|
.kind = .wildcard,
|
|
.action = .allow,
|
|
});
|
|
try testing.expectEqual(@as(?Failure, null), failure);
|
|
|
|
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
|
|
try testing.expectEqualStrings("*.ads.example", row.pattern);
|
|
try testing.expectEqual(model.RuleKind.wildcard, row.kind);
|
|
try testing.expectEqual(before, row.created_at);
|
|
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
|
}
|
|
|
|
test "an id no rule holds is a 404 on both update and delete" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(
|
|
Failure.not_found,
|
|
(try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, block_ads)).?,
|
|
);
|
|
try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?);
|
|
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
|
}
|
|
|
|
test "a deleted rule is gone and the change is announced" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads);
|
|
try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id));
|
|
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules"));
|
|
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
|
}
|
|
|
|
test "an unknown kind or action is a 400 before anything is written" {
|
|
try testing.expect(toInput(.{
|
|
.group_id = 1,
|
|
.pattern = "ads.example",
|
|
.kind = "glob",
|
|
.action = "block",
|
|
}).fail == .invalid);
|
|
|
|
try testing.expect(toInput(.{
|
|
.group_id = 1,
|
|
.pattern = "ads.example",
|
|
.kind = "exact",
|
|
.action = "drop",
|
|
}).fail == .invalid);
|
|
|
|
const good = toInput(.{
|
|
.group_id = 1,
|
|
.pattern = "ads.example",
|
|
.kind = "wildcard",
|
|
.action = "allow",
|
|
});
|
|
try testing.expectEqual(model.RuleKind.wildcard, good.input.kind);
|
|
try testing.expectEqual(model.RuleAction.allow, good.input.action);
|
|
|
|
const third = toInput(.{
|
|
.group_id = 1,
|
|
.pattern = "^ad[0-9]+-",
|
|
.kind = "regex",
|
|
.action = "block",
|
|
});
|
|
try testing.expectEqual(model.RuleKind.regex, third.input.kind);
|
|
}
|
|
|
|
test "a regex rule is stored, and a pattern the engine refuses is a 400 that names it" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
|
.group_id = 1,
|
|
.pattern = "^ad[0-9]+-",
|
|
.kind = .regex,
|
|
.action = .block,
|
|
});
|
|
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
|
|
try testing.expectEqual(model.RuleKind.regex, row.kind);
|
|
// Stored verbatim: a regex is not a name, so nothing lowercases or
|
|
// dot-strips it on the way to the table.
|
|
try testing.expectEqualStrings("^ad[0-9]+-", row.pattern);
|
|
|
|
const unclosed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
|
.group_id = 1,
|
|
.pattern = "(",
|
|
.kind = .regex,
|
|
.action = .block,
|
|
});
|
|
try testing.expectEqualStrings(
|
|
"rules[0].pattern: '(' is not a valid regex pattern",
|
|
unclosed.fail.invalid,
|
|
);
|
|
|
|
// The refused pattern reached no table, and the good one is still the only
|
|
// row: a 400 costs no write and no reload.
|
|
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM rules"));
|
|
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
|
}
|