a73x

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());
}