src/client/hosts.zig
Ref: Size: 24.4 KiB History
//! The wall: an ordered list of DAEMONS, one per line of
//! `$XDG_STATE_HOME/mux/hosts`. Tiles are whatever those daemons have live, so
//! nothing here names a session or can resurrect one. Strict on load, because
//! a host line is authored intent, and ONE grammar for argv, the file and the
//! picker alike.
//!
//! Every write is an unlocked read-modify-write over an atomic rename: no
//! reader tears, but two concurrent writers lose one update. Nothing fsyncs,
//! so no crash-durability claim is made.
const std = @import("std");
const xdg = @import("xdg");
/// What a host line names. `client.Target.fromSpec` dials it.
pub const Spec = union(enum) {
sock: []const u8,
host: []const u8,
quic: []const u8,
};
pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling };
/// The bytes a HOST spelling may not hold. The word is ONE argv element and the
/// wall grammar is whitespace-separated, so a space here is a second host and
/// the rest is punctuation no resolver answers. `[user@]host` needs none of it.
const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~";
pub fn hasBadSpelling(word: []const u8) bool {
for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true;
return false;
}
/// The two prefixes that ARE the grammar. Every writer and every reader —
/// argv, the file, the picker, a tile's label — spells them through these,
/// so changing a literal (or its length) moves all of them at once instead
/// of leaving one site matching a prefix nothing writes any more.
pub const sock_prefix = "--sock ";
pub const quic_prefix = "quic://";
pub fn parse(line: []const u8) ParseError!Spec {
for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte;
if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession;
if (std.mem.startsWith(u8, line, sock_prefix)) {
const p = line[sock_prefix.len..];
return if (p.len == 0) error.EmptySpec else .{ .sock = p };
}
if (std.mem.startsWith(u8, line, quic_prefix)) {
const h = line[quic_prefix.len..];
return if (h.len == 0) error.EmptySpec else .{ .quic = h };
}
if (line.len == 0) return error.EmptySpec;
if (hasBadSpelling(line)) return error.BadSpelling;
return .{ .host = line };
}
pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error;
/// Every mouth's rule: a host starting with a dash is a mistyped flag.
pub fn flagLike(target: []const u8) bool {
return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, sock_prefix);
}
/// Both `--sock` dialects reach one spelling: one parser, one line format.
pub fn spellingFromArgv(
alloc: std.mem.Allocator,
args: []const [:0]const u8,
i: usize,
) ArgvError!struct { spelling: []u8, consumed: usize } {
if (std.mem.eql(u8, args[i], "--sock")) {
// A trailing `--sock` names no path: a usage mistake, reported as
// one rather than read off the end of argv.
if (i + 1 >= args.len) return error.MissingSockPath;
return .{ .spelling = try std.fmt.allocPrint(alloc, sock_prefix ++ "{s}", .{args[i + 1]}), .consumed = 2 };
}
// A wall takes hosts, and no host starts with a dash. Left to fall through,
// `mux hosts add -A box` becomes a host named `-A` that fails to resolve far
// from the typo — and a wall has no per-host agent flag to have meant.
if (flagLike(args[i])) return error.FlagLikeTarget;
return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
}
/// Whether the grammar refused a line — the user's spelling to fix — as
/// opposed to the file not being readable at all, which is not.
pub fn isParse(err: anyerror) bool {
inline for (@typeInfo(ParseError).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;
}
return false;
}
pub fn reason(err: anyerror) []const u8 {
return switch (err) {
error.HasSession => "names a session after '#': a host line names a daemon; the layout names sessions",
error.EmptySpec => "empty host",
error.BadByte => "control byte in host",
error.BadSpelling => "punctuation in host: a host line names a machine, not a command",
error.MissingSockPath => "names no path",
else => @errorName(err),
};
}
pub const Hosts = struct {
lines: std.ArrayList([]u8) = .empty,
pub fn deinit(self: *Hosts, alloc: std.mem.Allocator) void {
for (self.lines.items) |l| alloc.free(l);
self.lines.deinit(alloc);
}
pub fn has(self: *const Hosts, spelling: []const u8) bool {
for (self.lines.items) |l| if (std.mem.eql(u8, l, spelling)) return true;
return false;
}
/// False when it was already listed; the file is a set in list order.
pub fn add(self: *Hosts, alloc: std.mem.Allocator, spelling: []const u8) !bool {
_ = try parse(spelling);
if (self.has(spelling)) return false;
try self.lines.append(alloc, try alloc.dupe(u8, spelling));
return true;
}
};
pub fn load(alloc: std.mem.Allocator, path: []const u8) !Hosts {
var h: Hosts = .{};
errdefer h.deinit(alloc);
const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1 << 20) catch |e| switch (e) {
error.FileNotFound => return h,
else => return e,
};
defer alloc.free(bytes);
var it = std.mem.splitScalar(u8, bytes, '\n');
while (it.next()) |line| {
if (line.len == 0) continue;
_ = try h.add(alloc, line);
}
return h;
}
pub fn save(h: *const Hosts, path: []const u8) !void {
return saveLines(h.lines.items, path);
}
pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
var h = try load(alloc, path);
defer h.deinit(alloc);
if (!try h.add(alloc, spelling)) return false;
try save(&h, path);
return true;
}
/// One spelling; `forgetMany` owns the rules.
pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
var gone = [_]bool{false};
try forgetMany(alloc, path, &.{spelling}, &gone);
return gone[0];
}
/// Out of the file, marking `gone[i]` for each spelling that matched.
pub fn forgetMany(
alloc: std.mem.Allocator,
path: []const u8,
spellings: []const []const u8,
gone: []bool,
) !void {
// `gone` is the caller's, one slot per spelling, and is only ever set.
std.debug.assert(gone.len == spellings.len);
// Verbatim, not through `load`: the one command whose job is removing a line
// has to reach a hand-edited one the grammar refuses. ONE read-modify-write,
// so an IO error on the third of four leaves none applied; EVERY copy,
// because `load` folds duplicates and a first-match `rm` exits 0 with the
// host still polled.
var lines = try loadLines(alloc, path);
defer freeLines(alloc, &lines);
var removed = false;
var i: usize = 0;
while (i < lines.items.len) {
var hit = false;
for (spellings, gone) |s, *g| {
if (!std.mem.eql(u8, lines.items[i], s)) continue;
g.* = true;
hit = true;
}
if (!hit) {
i += 1;
continue;
}
alloc.free(lines.orderedRemove(i));
removed = true;
}
// A save that changed nothing is still a write another writer can lose
// an update to (see the header), so a `rm` that matched nothing does not
// make one.
if (removed) try saveLines(lines.items, path);
}
pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
return xdg.statePath(alloc, "hosts");
}
pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts");
}
/// Beside the hosts file. What the layout file HOLDS is `wall_layout`'s.
pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 {
return xdg.statePath(alloc, "layout");
}
pub fn layoutPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "layout");
}
/// One atomic-write idiom in this module: join then `saveBytes`.
pub fn saveLines(lines: []const []const u8, path: []const u8) !void {
const ga = std.heap.page_allocator;
var joined: std.ArrayList(u8) = .empty;
defer joined.deinit(ga);
for (lines) |t| {
try joined.appendSlice(ga, t);
try joined.append(ga, '\n');
}
try saveBytes(path, joined.items);
}
/// Every line of the file, verbatim, with NO grammar applied. `load` refuses a
/// line that no longer parses, which leaves a hand-edited one unrepairable by
/// the command whose job is removing a line — so removal reads with this.
/// NOT for `record` or `add`: growing an unread file re-saves the garbage.
pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) {
var lines: std.ArrayList([]u8) = .empty;
errdefer freeLines(alloc, &lines);
const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) {
error.FileNotFound => return lines,
else => return err,
};
defer alloc.free(data);
var it = std.mem.tokenizeScalar(u8, data, '\n');
while (it.next()) |line| try lines.append(alloc, try alloc.dupe(u8, line));
return lines;
}
pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
for (lines.items) |l| alloc.free(l);
lines.deinit(alloc);
}
/// One atomic writer for every state file mux keeps: the hosts file here
/// and the layout file in `wall_layout` and `layoutfile`, so there is one
/// temp+rename idiom and not three.
pub fn saveBytes(path: []const u8, bytes: []const u8) !void {
var write_buf: [4096]u8 = undefined;
var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
defer af.deinit();
try af.file_writer.interface.writeAll(bytes);
try af.finish();
}
/// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated
/// here at usage altitude rather than downstream as a host that will not dial.
pub const Argv = struct {
alloc: std.mem.Allocator,
list: std.ArrayList([]const u8) = .empty,
/// A hook answers yes or no, so one that refused for a REASON leaves
/// the word and the why for the caller's message.
err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null,
pub fn deinit(self: *Argv) void {
for (self.list.items) |t| self.alloc.free(t);
self.list.deinit(self.alloc);
}
pub fn positional(self: *Argv, word: []const u8) bool {
return self.take(word);
}
pub fn extra(self: *Argv, rest: []const [:0]const u8) usize {
const n = spellingFromArgv(self.alloc, rest, 0) catch |e| {
if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e);
return 0;
};
defer self.alloc.free(n.spelling);
return if (self.take(n.spelling)) n.consumed else 0;
}
fn take(self: *Argv, spelling: []const u8) bool {
// Appended before it is judged, so a refusal's `word` points into a
// copy this list owns: `extra` frees the joined `--sock PATH`
// spelling on return, and a message naming it must not outlive that.
const copy = self.alloc.dupe(u8, spelling) catch return self.refuse("", error.OutOfMemory);
self.list.append(self.alloc, copy) catch {
self.alloc.free(copy);
return self.refuse("", error.OutOfMemory);
};
_ = parse(copy) catch |e| return self.refuse(copy, e);
return true;
}
fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool {
self.err = .{ .word = word, .err = e };
return false;
}
};
test "hosts.parse: three spellings classify; a '#' is refused by name" {
try std.testing.expectEqualStrings("/tmp/a.sock", (try parse("--sock /tmp/a.sock")).sock);
try std.testing.expectEqualStrings("box", (try parse("box")).host);
try std.testing.expectEqualStrings("10.0.0.2:4433", (try parse("quic://10.0.0.2:4433")).quic);
try std.testing.expectError(error.HasSession, parse("box#build"));
try std.testing.expectError(error.HasSession, parse("--sock /tmp/a.sock#0"));
try std.testing.expectError(error.EmptySpec, parse(""));
try std.testing.expectError(error.EmptySpec, parse("--sock "));
try std.testing.expectError(error.BadByte, parse("bo\x01x"));
// The sentence has to say what a host line IS, not merely that this one
// is wrong: "names a session" alone leaves the user with no next move.
try std.testing.expect(std.mem.indexOf(u8, reason(error.HasSession), "daemon") != null);
try std.testing.expect(std.mem.indexOf(u8, reason(error.HasSession), "layout") != null);
}
test "hosts.parse: a HOST spelling is one word — the file's grammar is one host per line" {
// The word becomes one argv element of `handoff.recipeFor`'s ssh line,
// and the wall grammar splits on whitespace: a space here is a second
// tile nobody asked for, and the rest are punctuation that names no
// machine any resolver will answer.
for ([_][]const u8{
"box; touch /tmp/pwned",
"box&sleep 9",
"box|tee /tmp/x",
"box`id`",
"box$(id)",
"box$HOME",
"box>out",
"box<in",
"box 'two words'",
"box*glob",
"box\\esc",
}) |bad| try std.testing.expectError(error.BadSpelling, parse(bad));
// A tab is a word separator too, and the control-byte rule reaches it
// first — the refusal is what matters, not which rule spoke.
try std.testing.expectError(error.BadByte, parse("box\ttab"));
// What an ssh destination actually is, and still is: user@host, an
// alias, an IPv4 or IPv6 literal, a `+` or `%` in a jump alias.
for ([_][]const u8{
"box",
"user@box.example.com",
"10.0.0.2",
"2001:db8::1",
"user-1_x@build-box.local",
"gate+inner",
"eth0%1",
}) |ok| try std.testing.expectEqualStrings(ok, (try parse(ok)).host);
// Only the arm that reaches ssh. A socket path is `connect(2)`'s
// business and a quic spelling is `quic.resolveHost`'s; neither is ever
// a word in an argv, and a path with a space is legal.
try std.testing.expectEqualStrings("/tmp/my sock", (try parse("--sock /tmp/my sock")).sock);
try std.testing.expect(std.mem.indexOf(u8, reason(error.BadSpelling), "not a command") != null);
}
test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled line loads once" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/mux/hosts", .{tmp.path()});
defer alloc.free(path);
var h = try load(alloc, path); // absent file = empty
defer h.deinit(alloc);
try std.testing.expectEqual(@as(usize, 0), h.lines.items.len);
try std.testing.expect(try h.add(alloc, "--sock /tmp/a.sock"));
try std.testing.expect(try h.add(alloc, "box"));
try std.testing.expect(!try h.add(alloc, "box"));
try std.testing.expectError(error.HasSession, h.add(alloc, "box#x"));
try save(&h, path);
var back = try load(alloc, path);
defer back.deinit(alloc);
try std.testing.expectEqual(@as(usize, 2), back.lines.items.len);
try std.testing.expectEqualStrings("--sock /tmp/a.sock", back.lines.items[0]);
try std.testing.expectEqualStrings("box", back.lines.items[1]);
try std.testing.expect(try record(alloc, path, "quic://h:1"));
try std.testing.expect(!try record(alloc, path, "quic://h:1"));
try std.testing.expect(try forget(alloc, path, "quic://h:1"));
try std.testing.expect(!try forget(alloc, path, "quic://h:1"));
// A hand-edited file that lists a daemon twice loads as one host. The
// wall would otherwise poll it twice and show every session of it
// twice, and `add` would then report a line it did not write.
try saveBytes(path, "box\n--sock /tmp/a.sock\nbox\n");
var doubled = try load(alloc, path);
defer doubled.deinit(alloc);
try std.testing.expectEqual(@as(usize, 2), doubled.lines.items.len);
try std.testing.expectEqualStrings("box", doubled.lines.items[0]);
try std.testing.expectEqualStrings("--sock /tmp/a.sock", doubled.lines.items[1]);
}
test "hosts.load is strict: a session line in the file is an error, not a skipped line" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
defer alloc.free(path);
try saveBytes(path, "box\nbox#old\n");
try std.testing.expectError(error.HasSession, load(alloc, path));
}
test "hosts.forget removes a line strict load refuses; record still will not grow that file" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
defer alloc.free(path);
try saveBytes(path, "box\nbox#old\n");
// Growing a file whose content is not understood re-saves the garbage
// as if it had been read, so record is refused while the bad line sits.
try std.testing.expectError(error.HasSession, record(alloc, path, "other"));
try std.testing.expect(try forget(alloc, path, "box#old"));
var back = try load(alloc, path);
defer back.deinit(alloc);
try std.testing.expectEqual(@as(usize, 1), back.lines.items.len);
try std.testing.expectEqualStrings("box", back.lines.items[0]);
try std.testing.expect(try record(alloc, path, "other"));
}
test "hosts.forget removes EVERY copy, so a rm cannot report success and change nothing" {
// `load` folds duplicate lines into one wall entry, so a hand-edited
// file with `box` twice is ONE host. A forget that stopped at the first
// match exited 0, printed nothing, and left `mux` still polling box.
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
defer alloc.free(path);
try saveBytes(path, "box\nkeep\nbox\n");
try std.testing.expect(try forget(alloc, path, "box"));
var lines = try loadLines(alloc, path);
defer freeLines(alloc, &lines);
try std.testing.expectEqual(@as(usize, 1), lines.items.len);
try std.testing.expectEqualStrings("keep", lines.items[0]);
try std.testing.expect(!try forget(alloc, path, "box"));
}
test "hosts.forgetMany: several names leave in ONE read-modify-write, and the survivors keep file order" {
// One load, one save, so an IO error on the third of four leaves none
// applied and every name's verdict comes from the same pass. The removed `a`
// sits BETWEEN the two survivors, so an unordered remove would swap the tail
// into its slot and re-order the wall.
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
defer alloc.free(path);
try saveBytes(path, "a\nb\nkeep\na\nlast\n");
var gone = [_]bool{ false, false, false };
try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone);
try std.testing.expectEqualSlices(bool, &.{ true, false, true }, &gone);
var lines = try loadLines(alloc, path);
defer freeLines(alloc, &lines);
try std.testing.expectEqual(@as(usize, 2), lines.items.len);
try std.testing.expectEqualStrings("keep", lines.items[0]);
try std.testing.expectEqualStrings("last", lines.items[1]);
}
test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" {
const alloc = std.testing.allocator;
const a = try statePathFrom(alloc, "/x", "/h");
defer alloc.free(a);
try std.testing.expectEqualStrings("/x/mux/hosts", a);
const b = try statePathFrom(alloc, null, "/h");
defer alloc.free(b);
try std.testing.expectEqualStrings("/h/.local/state/mux/hosts", b);
}
const TmpDir = @import("testtmp").TmpDir;
test "hosts.Argv: a taken word is validated, a refusal names it, a flag leaves no record" {
var a: Argv = .{ .alloc = std.testing.allocator };
defer a.deinit();
// `-A` is not a host this refuses but one it never saw: no record, so
// cliflags names it the unknown flag it is. Asserted FIRST, on an
// untouched collector, so a stale `err` cannot answer for it.
try std.testing.expectEqual(@as(usize, 0), a.extra(&[_][:0]const u8{ "-A", "box" }));
try std.testing.expect(a.err == null);
try std.testing.expectEqual(@as(usize, 0), a.list.items.len);
try std.testing.expect(a.positional("box"));
try std.testing.expectEqual(@as(usize, 2), a.extra(&[_][:0]const u8{ "--sock", "/tmp/a.sock" }));
try std.testing.expect(a.err == null);
try std.testing.expectEqual(@as(usize, 2), a.list.items.len);
try std.testing.expect(!a.positional("box#x"));
try std.testing.expect(a.err.?.err == error.HasSession);
try std.testing.expectEqualStrings("box#x", a.err.?.word);
}
test "spellingFromArgv: both --sock dialects reach the same spelling" {
const alloc = std.testing.allocator;
const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock", "--sock /tmp/x.sock", "box", "quic://h:4433" };
// Two arguments joined, and one argument passed through: same string,
// which is the point — the file only ever holds this one.
const joined = try spellingFromArgv(alloc, &argv, 0);
defer alloc.free(joined.spelling);
try std.testing.expectEqualStrings("--sock /tmp/x.sock", joined.spelling);
try std.testing.expectEqual(@as(usize, 2), joined.consumed);
const whole = try spellingFromArgv(alloc, &argv, 2);
defer alloc.free(whole.spelling);
try std.testing.expectEqualStrings("--sock /tmp/x.sock", whole.spelling);
try std.testing.expectEqual(@as(usize, 1), whole.consumed);
try std.testing.expectEqualStrings("/tmp/x.sock", (try parse(whole.spelling)).sock);
// Host and quic spellings are already whole; nothing is consumed after.
const host = try spellingFromArgv(alloc, &argv, 3);
defer alloc.free(host.spelling);
try std.testing.expectEqualStrings("box", host.spelling);
try std.testing.expectEqual(@as(usize, 1), host.consumed);
const q = try spellingFromArgv(alloc, &argv, 4);
defer alloc.free(q.spelling);
try std.testing.expectEqualStrings("quic://h:4433", q.spelling);
try std.testing.expectEqual(@as(usize, 1), q.consumed);
}
test "spellingFromArgv: a trailing --sock is a usage error, not a read off the end" {
const argv = [_][:0]const u8{ "box", "--sock" };
try std.testing.expectError(error.MissingSockPath, spellingFromArgv(std.testing.allocator, &argv, 1));
}
test "spellingFromArgv: a flag is not a host" {
// `mux hosts add -A box` used to make a host named `-A`, which then
// failed to resolve somewhere far from the typo. A wall has no per-host
// agent flag at all — `-A` belongs to a single attach — so every
// flag-shaped argument here is the same mistake.
const argv = [_][:0]const u8{ "-A", "box", "--sock /tmp/x", "quic://h:1" };
try std.testing.expectError(error.FlagLikeTarget, spellingFromArgv(std.testing.allocator, &argv, 0));
// The one spelling that legitimately starts with a dash still passes.
const whole = try spellingFromArgv(std.testing.allocator, &argv, 2);
defer std.testing.allocator.free(whole.spelling);
try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling);
}
test "saveLines: a hosts file bigger than any stack buffer still round-trips" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/bighosts", .{tmp.path()});
defer alloc.free(path);
// 200 lines of ~100 bytes each = ~20 KiB, well past an 8 KiB stack buffer.
var lines: std.ArrayList([]const u8) = .empty;
defer {
for (lines.items) |l| alloc.free(l);
lines.deinit(alloc);
}
for (0..200) |i| {
const line = try std.fmt.allocPrint(alloc, "host-{d:0>3}.example.com-{d:0>3}", .{ i, i });
try lines.append(alloc, line);
}
try saveLines(lines.items, path);
var back = try loadLines(alloc, path);
defer freeLines(alloc, &back);
try std.testing.expectEqual(@as(usize, 200), back.items.len);
try std.testing.expectEqualStrings("host-199.example.com-199", back.items[199]);
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md).
test {
std.testing.refAllDeclsRecursive(@This());
}