Skip to content
zit Artifact [aa6df09891]
Login

Artifact [aa6df09891]

Artifact aa6df09891f993334dee3d3f5d600e7adea59bf5f9790b2e526715e094955fda:


//! End-to-end proof that operations crossing a horizon fail closed
//! (draft-fossil-horizon sections 4.4, 5.5, 5.7 and 5.8). Every verb that can
//! name a check-in or a path is driven at a target below the horizon of a
//! content clone and of a structural clone, with the origin reachable and
//! with it gone, and judged against a dense clone running the same command:
//! the horizon clone either gives the dense answer (fetching what it needs
//! from the origin) or refuses, names the horizon, and leaves its store
//! byte-identical. A crash, a partial write, a diagnostic that calls elision
//! damage, or a different answer that does not say why is a defect.
//!
//! Built into `zig build e2e` through src/e2e.zig, so it runs the same real
//! binary with the same pinned environment.

const std = @import("std");
const build_options = @import("build_options");
const cli = @import("zit").cli;

const net = std.Io.net;
const zit_exe: []const u8 = build_options.zit_exe;

/// Every commit and control artifact is stamped from this clock, so a tag or
/// amendment minted on a horizon clone hashes the same as the dense clone's.
const epoch = "1767225600";

const RunResult = std.process.RunResult;

/// `zit <argv...>` in `dir` with the pinned environment. Output is owned by
/// `arena`, which the caller frees wholesale.
fn zitIn(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, argv: []const []const u8) !RunResult {
    return zitInEnv(arena, io, dir, argv, null);
}

/// `zitIn` with one more variable set, such as an evidence seam.
fn zitInEnv(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, argv: []const []const u8, extra: ?[2][]const u8) !RunResult {
    var env = std.process.Environ.Map.init(arena);
    if (extra) |kv| try env.put(kv[0], kv[1]);
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
    try env.put("PATH", "/usr/bin:/bin");
    try env.put("USER", "e2e-user");
    try env.put("SOURCE_DATE_EPOCH", epoch);
    const cwd_abs = try std.process.currentPathAlloc(io, arena);
    const abs_exe = try std.fs.path.resolve(arena, &.{ cwd_abs, zit_exe });
    var full: std.ArrayList([]const u8) = .empty;
    try full.append(arena, abs_exe);
    try full.appendSlice(arena, argv);
    return std.process.run(arena, io, .{
        .argv = full.items,
        .cwd = .{ .dir = dir },
        .environ_map = &env,
    });
}

fn exitCode(term: std.process.Child.Term) ?u8 {
    return switch (term) {
        .exited => |c| c,
        else => null,
    };
}

fn expectOk(res: RunResult) !void {
    if (exitCode(res.term) == 0) return;
    std.debug.print("expected exit 0, got {any}\n--- stdout ---\n{s}\n--- stderr ---\n{s}\n", .{ res.term, res.stdout, res.stderr });
    return error.TestUnexpectedExitCode;
}

fn contains(haystack: []const u8, needle: []const u8) bool {
    return std.mem.indexOf(u8, haystack, needle) != null;
}

fn writeFile(io: std.Io, dir: std.Io.Dir, name: []const u8, content: []const u8) !void {
    var f = try dir.createFile(io, name, .{});
    defer f.close(io);
    try f.writePositionalAll(io, content, 0);
}

/// Commits the checkout in `dir` and returns the new check-in's hash, read
/// from the `check-in HASH on BRANCH` line.
fn commit(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, extra: []const []const u8) ![]const u8 {
    var argv: std.ArrayList([]const u8) = .empty;
    try argv.append(arena, "commit");
    try argv.appendSlice(arena, extra);
    const res = try zitIn(arena, io, dir, argv.items);
    try expectOk(res);
    const prefix = "check-in ";
    const at = std.mem.indexOf(u8, res.stdout, prefix) orelse return error.NoCheckinLine;
    const hash = res.stdout[at + prefix.len ..][0..64];
    for (hash) |ch| if (!std.ascii.isHex(ch)) return error.NoCheckinLine;
    return hash;
}

fn sha3Hex(arena: std.mem.Allocator, content: []const u8) ![]const u8 {
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
    return std.fmt.allocPrint(arena, "{x}", .{digest});
}

fn freshLoopbackPort(io: std.Io) !u16 {
    const addr = try net.IpAddress.parseIp4("127.0.0.1", 0);
    var server = try addr.listen(io, .{});
    const port = server.socket.address.getPort();
    server.deinit(io);
    if (port == 0) return error.NoLoopbackPort;
    return port;
}

fn spawnServe(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, port: u16) !std.process.Child {
    var env = std.process.Environ.Map.init(arena);
    try env.put("TZ", "UTC");
    try env.put("HOME", "/zit-e2e-isolated-home-does-not-exist");
    try env.put("PATH", "/usr/bin:/bin");
    const cwd_abs = try std.process.currentPathAlloc(io, arena);
    const abs_exe = try std.fs.path.resolve(arena, &.{ cwd_abs, zit_exe });
    const port_str = try std.fmt.allocPrint(arena, "{d}", .{port});
    var child = try std.process.spawn(io, .{
        .argv = &.{ abs_exe, "serve", "--port", port_str },
        .cwd = .{ .dir = dir },
        .environ_map = &env,
        .stdout = .ignore,
        .stderr = .ignore,
    });
    errdefer child.kill(io);
    try waitForListen(io, port);
    return child;
}

fn waitForListen(io: std.Io, port: u16) !void {
    const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.BadTestAddress;
    const deadline_ns: i96 = 15_000_000_000;
    const start = std.Io.Clock.now(.awake, io).nanoseconds;
    while (true) {
        if (addr.connect(io, .{ .mode = .stream })) |stream| {
            stream.close(io);
            return;
        } else |_| {}
        if (std.Io.Clock.now(.awake, io).nanoseconds - start > deadline_ns) return error.ServerNeverBound;
    }
}

/// The origin's history. C4 is the horizon; everything named here except C4
/// and C5 lies below it, and `old` is a branch that forked below it:
///
///     C1 -- C2 (rel1) -- C4 (horizon) -- C5 (trunk tip)
///             \
///              C3 (branch old)
const History = struct {
    c1: []const u8,
    c2: []const u8,
    c3: []const u8,
    c4: []const u8,
    c5: []const u8,
    /// The blob of a.txt at C2, which only check-ins below the horizon name.
    a2: []const u8,

    fn build(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !History {
        try expectOk(try zitIn(arena, io, dir, &.{"init"}));
        try writeFile(io, dir, "a.txt", "a1\n");
        try writeFile(io, dir, "b.txt", "b1\n");
        try expectOk(try zitIn(arena, io, dir, &.{ "add", "a.txt", "b.txt" }));
        var h: History = undefined;
        h.c1 = try commit(arena, io, dir, &.{ "-m", "c1" });
        try writeFile(io, dir, "a.txt", "a2\n");
        h.c2 = try commit(arena, io, dir, &.{ "-m", "c2" });
        try expectOk(try zitIn(arena, io, dir, &.{ "tag", "add", "rel1", h.c2 }));
        try writeFile(io, dir, "b.txt", "b3\n");
        h.c3 = try commit(arena, io, dir, &.{ "-m", "c3 on old", "--branch", "old" });
        try expectOk(try zitIn(arena, io, dir, &.{ "update", "trunk" }));
        try writeFile(io, dir, "a.txt", "a4\n");
        h.c4 = try commit(arena, io, dir, &.{ "-m", "c4" });
        try writeFile(io, dir, "a.txt", "a5\n");
        h.c5 = try commit(arena, io, dir, &.{ "-m", "c5" });
        h.a2 = try sha3Hex(arena, "a2\n");
        return h;
    }

    /// `@C1`..`@C5` and `@A2` in an argv, replaced by the real names.
    fn expand(self: History, arena: std.mem.Allocator, argv: []const []const u8) ![]const []const u8 {
        const out = try arena.alloc([]const u8, argv.len);
        for (argv, out) |tok, *dst| {
            dst.* = if (std.mem.eql(u8, tok, "@C1")) self.c1 else if (std.mem.eql(u8, tok, "@C2")) self.c2 else if (std.mem.eql(u8, tok, "@C3")) self.c3 else if (std.mem.eql(u8, tok, "@C4")) self.c4 else if (std.mem.eql(u8, tok, "@C5")) self.c5 else if (std.mem.eql(u8, tok, "@A2")) self.a2 else tok;
        }
        return out;
    }
};

const CloneKind = enum { dense, content, structural };
const OriginState = enum { up, down };

/// Clones `url` into `dir_name` under `root` as `kind`, horizon at `c`.
fn cloneAs(arena: std.mem.Allocator, io: std.Io, root: std.Io.Dir, url: []const u8, dir_name: []const u8, kind: CloneKind, c: []const u8, rule: []const u8) !void {
    const horizon = try std.fmt.allocPrint(arena, "--horizon={s}", .{c});
    const rule_flag = try std.fmt.allocPrint(arena, "--horizon-rule={s}", .{rule});
    const res = switch (kind) {
        .dense => try zitIn(arena, io, root, &.{ "clone", url, dir_name }),
        .content => try zitIn(arena, io, root, &.{ "clone", url, dir_name, horizon, rule_flag }),
        .structural => try zitIn(arena, io, root, &.{ "clone", url, dir_name, horizon, rule_flag, "--structural" }),
    };
    try expectOk(res);
    if (kind == .structural and contains(res.stderr, "keeping the content horizon")) return error.OriginDidNotServeStructural;
}

/// The store and every piece of store-adjacent horizon state: what a
/// refusal must leave exactly as it found it.
fn storeDigest(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ![32]u8 {
    var h = std.crypto.hash.sha2.Sha256.init(.{});
    for ([_][]const u8{ ".zit", ".zit-cache/store-cone", ".zit-cache/horizon-seed", ".zit-cache/horizon-cursor" }) |name| {
        h.update(name);
        const bytes = dir.readFileAlloc(io, name, arena, .limited(64 << 20)) catch |e| switch (e) {
            error.FileNotFound => {
                h.update("\x00absent");
                continue;
            },
            else => return e,
        };
        h.update(bytes);
    }
    var out: [32]u8 = undefined;
    h.final(&out);
    return out;
}

/// What the judge compares: `same` cases must give the dense clone's stdout
/// or name the horizon; `local` cases report on the store itself (counts of
/// what it holds), so only their refusals are judged.
const Expect = enum { same, local };

const Case = struct {
    verb: cli.Verb,
    argv: []const []const u8,
    expect: Expect = .same,
};

/// Targets below the horizon in every form a verb accepts: a check-in hash,
/// a tag on a check-in below (`rel1`), a branch whose tip is below (`old`),
/// a file at a check-in below, and a raw blob only old check-ins name.
const cases = [_]Case{
    .{ .verb = .diff, .argv = &.{ "diff", "--from", "@C2" } },
    .{ .verb = .diff, .argv = &.{ "diff", "--from", "@C2", "--to", "@C5" } },
    .{ .verb = .diff, .argv = &.{ "diff", "--from", "@C2", "--to", "@C5", "a.txt" } },
    .{ .verb = .diff, .argv = &.{ "diff", "--checkin", "@C2" } },
    .{ .verb = .diff, .argv = &.{ "diff", "--branch", "old" } },
    .{ .verb = .log, .argv = &.{ "log", "@C2" } },
    .{ .verb = .log, .argv = &.{ "log", "rel1" } },
    .{ .verb = .log, .argv = &.{ "log", "old" } },
    .{ .verb = .log, .argv = &.{ "log", "@C5", "a.txt" } },
    .{ .verb = .open, .argv = &.{ "open", "@C2" } },
    .{ .verb = .update, .argv = &.{ "update", "@C2" } },
    .{ .verb = .update, .argv = &.{ "update", "rel1" } },
    .{ .verb = .update, .argv = &.{ "update", "old" } },
    .{ .verb = .merge, .argv = &.{ "merge", "old" } },
    .{ .verb = .merge, .argv = &.{ "merge", "@C3" } },
    .{ .verb = .merge, .argv = &.{ "merge", "--probe", "old" } },
    .{ .verb = .merge, .argv = &.{ "merge", "--cherrypick", "@C2" } },
    .{ .verb = .merge, .argv = &.{ "merge", "--backout", "@C2" } },
    .{ .verb = .merge, .argv = &.{ "merge", "--baseline", "@C1", "old" } },
    .{ .verb = .merge_tree, .argv = &.{ "merge-tree", "old", "--into", "trunk", "-F", "json" } },
    .{ .verb = .merge_base, .argv = &.{ "merge-base", "trunk", "old" } },
    .{ .verb = .merge_base, .argv = &.{ "merge-base", "@C5", "@C3" } },
    .{ .verb = .revert, .argv = &.{ "revert", "-r", "@C2", "a.txt" } },
    .{ .verb = .timeline, .argv = &.{ "timeline", "-b", "old" } },
    .{ .verb = .timeline, .argv = &.{ "timeline", "-p", "a.txt" } },
    .{ .verb = .branch, .argv = &.{ "branch", "new", "nb", "@C2" } },
    .{ .verb = .branch, .argv = &.{ "branch", "new", "nb", "rel1" } },
    .{ .verb = .branch, .argv = &.{ "branch", "info", "old" } },
    .{ .verb = .branch, .argv = &.{ "branch", "close", "old" } },
    .{ .verb = .tag, .argv = &.{ "tag", "list", "@C2" } },
    .{ .verb = .tag, .argv = &.{ "tag", "add", "t2", "@C2" } },
    .{ .verb = .tag, .argv = &.{ "tag", "cancel", "rel1", "@C2" } },
    .{ .verb = .amend, .argv = &.{ "amend", "@C2", "--comment", "changed" } },
    .{ .verb = .amend, .argv = &.{ "amend", "old", "--comment", "changed" } },
    .{ .verb = .info, .argv = &.{ "info", "@C2" } },
    .{ .verb = .info, .argv = &.{ "info", "rel1" } },
    .{ .verb = .info, .argv = &.{ "info", "old" } },
    .{ .verb = .ls, .argv = &.{ "ls", "@C2" } },
    .{ .verb = .ls, .argv = &.{ "ls", "rel1" } },
    .{ .verb = .ls, .argv = &.{ "ls", "old" } },
    .{ .verb = .ls, .argv = &.{ "ls", "@C2", "--long" } },
    .{ .verb = .cat, .argv = &.{ "cat", "a.txt", "@C2" } },
    .{ .verb = .cat, .argv = &.{ "cat", "a.txt", "rel1" } },
    .{ .verb = .cat, .argv = &.{ "cat", "b.txt", "old" } },
    .{ .verb = .artifact, .argv = &.{ "artifact", "@C2" } },
    .{ .verb = .artifact, .argv = &.{ "artifact", "@A2" } },
    .{ .verb = .annotate, .argv = &.{ "annotate", "a.txt" } },
    .{ .verb = .annotate, .argv = &.{ "annotate", "a.txt", "@C2" } },
    .{ .verb = .annotate, .argv = &.{ "annotate", "a.txt", "--full" } },
    .{ .verb = .grep, .argv = &.{ "grep", "a2", "a.txt" } },
    .{ .verb = .grep, .argv = &.{ "grep", "-S", "a2" } },
    .{ .verb = .bisect, .argv = &.{ "bisect", "good", "@C2" } },
    .{ .verb = .bisect, .argv = &.{ "bisect", "bad", "@C2" } },
    .{ .verb = .tarball, .argv = &.{ "tarball", "@C2", "out.tgz" } },
    .{ .verb = .tarball, .argv = &.{ "tarball", "@C2", "--list" } },
    .{ .verb = .zip, .argv = &.{ "zip", "@C2", "out.zip" } },
    .{ .verb = .zip, .argv = &.{ "zip", "rel1", "--list" } },
    .{ .verb = .purge, .argv = &.{ "purge", "checkins", "--dry-run", "old" }, .expect = .local },
    .{ .verb = .purge, .argv = &.{ "purge", "checkins", "--force", "old" }, .expect = .local },
    .{ .verb = .shun, .argv = &.{ "shun", "@A2" } },
    .{ .verb = .landing, .argv = &.{ "landing", "open", "--branch", "old", "-m", "land" } },
    .{ .verb = .export_git, .argv = &.{ "export-git", ".", "git-out" } },
    .{ .verb = .attach, .argv = &.{ "attach", "add", "@C2", "a.txt" } },
};

/// The verbs the table does not drive, each with the reason it cannot name a
/// check-in or a path in history, or the test that drives it instead.
const skipped = [_]struct { verb: cli.Verb, why: []const u8 }{
    .{ .verb = .ui, .why = "opens a browser through quarry; takes no check-in" },
    .{ .verb = .init, .why = "creates a repository; takes no check-in" },
    .{ .verb = .add, .why = "working-tree paths only" },
    .{ .verb = .rm, .why = "working-tree paths only" },
    .{ .verb = .mv, .why = "working-tree paths only" },
    .{ .verb = .status, .why = "the checkout's own state; takes no check-in" },
    .{ .verb = .stat, .why = "the store's own census; takes no check-in" },
    .{ .verb = .extras, .why = "working-tree paths only" },
    .{ .verb = .clean, .why = "working-tree paths only" },
    .{ .verb = .addremove, .why = "working-tree paths only" },
    .{ .verb = .commit, .why = "driven by the commit-and-push test" },
    .{ .verb = .undo, .why = "replays the last checkout change; takes no check-in" },
    .{ .verb = .redo, .why = "replays the last checkout change; takes no check-in" },
    .{ .verb = .uv, .why = "unversioned files, outside history" },
    .{ .verb = .leaves, .why = "takes no check-in; leaves are exact over K by the draft" },
    .{ .verb = .serve, .why = "driven by the not-a-complete-origin test" },
    .{ .verb = .clone, .why = "driven by the clone-refusal test" },
    .{ .verb = .pull, .why = "takes a URL, not a check-in; driven by the commit-and-push test" },
    .{ .verb = .push, .why = "takes a URL, not a check-in; driven by the commit-and-push test" },
    .{ .verb = .sync, .why = "takes a URL, not a check-in" },
    .{ .verb = .remote, .why = "takes a URL, not a check-in" },
    .{ .verb = .settings, .why = "settings only" },
    .{ .verb = .pack, .why = "repacks the store; takes no check-in" },
    .{ .verb = .submanifest, .why = "settings only" },
    .{ .verb = .checkpoint, .why = "a cache over the store; takes no check-in" },
    .{ .verb = .cone, .why = "path members of the cone, not history" },
    .{ .verb = .worktrees, .why = "lists working trees; takes no check-in" },
    .{ .verb = .import_git, .why = "creates a repository from Git" },
    .{ .verb = .import_fossil, .why = "creates a repository from fossil" },
    .{ .verb = .git, .why = "mirror bookkeeping; export-git is driven instead" },
    .{ .verb = .stash, .why = "working-tree changes only" },
    .{ .verb = .precedent, .why = "remembered conflict resolutions; takes no check-in" },
    .{ .verb = .verify, .why = "takes no check-in; its horizon report is asserted by the commit-and-push test" },
    .{ .verb = .frames, .why = "takes a cursor, not a check-in" },
    .{ .verb = .crosslink, .why = "takes no check-in" },
    .{ .verb = .wiki, .why = "wiki artifacts are held under both cuts; takes no check-in" },
    .{ .verb = .ticket, .why = "ticket artifacts are held under both cuts; takes no check-in" },
    .{ .verb = .forum, .why = "forum artifacts are held under both cuts; takes no check-in" },
    .{ .verb = .technote, .why = "technote artifacts are held under both cuts; takes no check-in" },
    .{ .verb = .trust, .why = "local consent only" },
    .{ .verb = .config, .why = "configuration areas only" },
    .{ .verb = .users, .why = "the user table only" },
    .{ .verb = .version, .why = "takes no argument" },
    .{ .verb = .help, .why = "takes no argument" },
    .{ .verb = .unknown, .why = "not a verb" },
};

test "horizon crossing: every verb is driven below the horizon or skipped with a reason" {
    for (std.enums.values(cli.Verb)) |v| {
        var driven = false;
        for (cases) |c| driven = driven or c.verb == v;
        var skip = false;
        for (skipped) |s| skip = skip or s.verb == v;
        if (driven == skip) {
            std.debug.print("verb {s}: driven={} skipped={}\n", .{ @tagName(v), driven, skip });
            return error.VerbNotAccountedFor;
        }
    }
}

const Outcome = struct {
    code: ?u8,
    stdout: []const u8,
    stderr: []const u8,
    store_changed: bool,
};

/// Every byte a diagnostic carries is text: a slice into freed or stack
/// memory shows up here as control bytes.
fn printable(s: []const u8) bool {
    if (!std.unicode.utf8ValidateSlice(s)) return false;
    for (s) |b| {
        if (b < 0x20 and b != '\n' and b != '\t' and b != '\r') return false;
        if (b == 0x7f) return false;
    }
    return true;
}

/// Null when `got` is a correct fail-closed outcome against `dense`, else the
/// defect, named.
fn judge(c: Case, state: OriginState, dense: Outcome, got: Outcome) ?[]const u8 {
    const code = got.code orelse return "crashed (killed by a signal)";
    if (!printable(got.stdout) or !printable(got.stderr)) return "printed bytes that are not text";
    if (contains(got.stderr, "corrupt") or contains(got.stderr, "ANOTHER repository"))
        return "described elision as damage";
    const names_horizon = contains(got.stderr, "horizon") or contains(got.stdout, "horizon");
    if (code == 0) {
        if (dense.code != 0) return "succeeded where the dense clone fails";
        if (state == .down and got.store_changed and !dense.store_changed)
            return "wrote the store although the origin was unreachable";
        if (c.expect == .same and !std.mem.eql(u8, got.stdout, dense.stdout) and !names_horizon)
            return "answered differently from the dense clone without naming the horizon";
        return null;
    }
    if (dense.code == 0) {
        if (got.store_changed) return "refused but changed the store";
        if (!names_horizon) return "refused without naming the horizon";
    }
    return null;
}

test "horizon crossing: every verb below the horizon refuses cleanly or gives the dense answer" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    const h = try History.build(arena, io, origin);
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});
    // Nothing listens here: the saved remote of an "unreachable" run.
    const dead_url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{try freshLoopbackPort(io)});

    var defects: std.ArrayList(u8) = .empty;
    for (cases, 0..) |c, i| {
        const argv = try h.expand(arena, c.argv);
        const dense = try runCase(arena, io, tmp.dir, url, null, h, .dense, i, argv);
        for ([_]CloneKind{ .content, .structural }) |kind| {
            for ([_]OriginState{ .up, .down }) |state| {
                const got = try runCase(arena, io, tmp.dir, url, if (state == .down) dead_url else null, h, kind, i, argv);
                if (judge(c, state, dense, got)) |why| {
                    try defects.print(arena, "{s} [{s}, origin {s}]: {s}\n  exit {any}, stderr: {s}\n", .{
                        try std.mem.join(arena, " ", c.argv), @tagName(kind), @tagName(state), why, got.code, std.mem.trim(u8, got.stderr, "\n"),
                    });
                }
            }
        }
    }
    if (defects.items.len != 0) {
        std.debug.print("crossing defects:\n{s}", .{defects.items});
        return error.CrossingNotFailClosed;
    }
}

fn runCase(
    arena: std.mem.Allocator,
    io: std.Io,
    root: std.Io.Dir,
    url: []const u8,
    dead_url: ?[]const u8,
    h: History,
    kind: CloneKind,
    index: usize,
    argv: []const []const u8,
) !Outcome {
    const name = try std.fmt.allocPrint(arena, "case{d}-{s}-{s}", .{ index, @tagName(kind), if (dead_url == null) "up" else "down" });
    try cloneAs(arena, io, root, url, name, kind, h.c4, "descendants");
    defer root.deleteTree(io, name) catch {};
    var dir = try root.openDir(io, name, .{});
    defer dir.close(io);
    if (dead_url) |d| try expectOk(try zitIn(arena, io, dir, &.{ "remote", d }));
    const before = try storeDigest(arena, io, dir);
    const res = try zitIn(arena, io, dir, argv);
    const after = try storeDigest(arena, io, dir);
    return .{
        .code = exitCode(res.term),
        .stdout = res.stdout,
        .stderr = res.stderr,
        .store_changed = !std.mem.eql(u8, &before, &after),
    };
}

test "horizon crossing: commits and merges pushed from horizon clones leave the origin dense and whole" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    const h = try History.build(arena, io, origin);
    // A second branch forked below the horizon, for the content clone to
    // merge while the leaves clone merges `old`.
    try expectOk(try zitIn(arena, io, origin, &.{ "update", h.c2 }));
    try writeFile(io, origin, "c.txt", "c on old2\n");
    try expectOk(try zitIn(arena, io, origin, &.{ "add", "c.txt" }));
    const old2 = try commit(arena, io, origin, &.{ "-m", "on old2", "--branch", "old2" });
    try expectOk(try zitIn(arena, io, origin, &.{ "update", "trunk" }));
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});

    // Every clone is taken while `old` and `old2` are open leaves, so the
    // leaves rule holds both back to their fork.
    try cloneAs(arena, io, tmp.dir, url, "content", .content, h.c4, "descendants");
    try cloneAs(arena, io, tmp.dir, url, "structural", .structural, h.c4, "descendants");
    try cloneAs(arena, io, tmp.dir, url, "leaves", .structural, h.c4, "leaves");

    // An ordinary commit from each cut, pushed in turn; each clone first
    // brings in the other's work so the history stays one line.
    var content = try tmp.dir.openDir(io, "content", .{});
    defer content.close(io);
    var structural = try tmp.dir.openDir(io, "structural", .{});
    defer structural.close(io);
    try writeFile(io, content, "a.txt", "from the content clone\n");
    const from_content = try commit(arena, io, content, &.{ "-m", "from content" });
    try expectOk(try zitIn(arena, io, content, &.{"push"}));
    try expectOk(try zitIn(arena, io, structural, &.{"update"}));
    try writeFile(io, structural, "a.txt", "from the structural clone\n");
    const from_structural = try commit(arena, io, structural, &.{ "-m", "from structural" });
    try expectOk(try zitIn(arena, io, structural, &.{"push"}));

    // A merge whose other parent and base both lie below the horizon, from
    // the content cut: the base's content is fetched (a deepen to the base),
    // then the merge runs locally over real artifacts.
    try expectOk(try zitIn(arena, io, content, &.{"update"}));
    try expectOk(try zitIn(arena, io, content, &.{ "merge", "old2" }));
    const content_merge = try commit(arena, io, content, &.{ "-m", "merge old2 from content" });
    try expectOk(try zitIn(arena, io, content, &.{"push"}));

    // The same from a structural clone under the leaves rule, which holds the
    // open leaf `old` back to its fork, so the base is in K.
    var leaves = try tmp.dir.openDir(io, "leaves", .{});
    defer leaves.close(io);
    try expectOk(try zitIn(arena, io, leaves, &.{"update"}));
    try writeFile(io, leaves, "a.txt", "from the leaves clone\n");
    const from_leaves = try commit(arena, io, leaves, &.{ "-m", "from leaves" });
    try expectOk(try zitIn(arena, io, leaves, &.{ "merge", "old" }));
    const leaves_merge = try commit(arena, io, leaves, &.{ "-m", "merge old from leaves" });
    try expectOk(try zitIn(arena, io, leaves, &.{"push"}));

    // The origin verifies clean and holds every pushed check-in with the
    // parents its author gave it.
    const verify = try zitIn(arena, io, origin, &.{"verify"});
    try expectOk(verify);
    try std.testing.expect(contains(verify.stdout, " 0 faults"));
    for ([_][]const u8{ from_content, from_structural, from_leaves }) |ci| {
        try expectOk(try zitIn(arena, io, origin, &.{ "info", ci }));
    }
    for ([_][2][]const u8{ .{ content_merge, old2 }, .{ leaves_merge, h.c3 } }) |pair| {
        const raw = try zitIn(arena, io, origin, &.{ "artifact", pair[0], "--raw" });
        try expectOk(raw);
        try std.testing.expect(contains(raw.stdout, pair[1]));
    }

    // A fresh dense clone of the origin is whole and derives the same history.
    try cloneAs(arena, io, tmp.dir, url, "fresh", .dense, "", "");
    var fresh = try tmp.dir.openDir(io, "fresh", .{});
    defer fresh.close(io);
    const fresh_verify = try zitIn(arena, io, fresh, &.{"verify"});
    try expectOk(fresh_verify);
    try std.testing.expect(contains(fresh_verify.stdout, " 0 faults"));
    try std.testing.expect(!contains(fresh_verify.stdout, "elided"));
    for ([_][]const []const u8{
        &.{"timeline"},
        &.{ "leaves", "--all" },
        &.{ "ls", content_merge },
        &.{ "cat", "a.txt", leaves_merge },
        &.{ "annotate", "a.txt", content_merge },
    }) |argv| {
        const want = try zitIn(arena, io, origin, argv);
        const got = try zitIn(arena, io, fresh, argv);
        try expectOk(want);
        try expectOk(got);
        // Which check-in each working tree has open is not history.
        const want_history = try std.mem.replaceOwned(u8, arena, want.stdout, " *CURRENT*", "");
        const got_history = try std.mem.replaceOwned(u8, arena, got.stdout, " *CURRENT*", "");
        try std.testing.expectEqualStrings(want_history, got_history);
    }
}

test "horizon crossing: a horizon clone refuses to serve a clone it cannot cover" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    const h = try History.build(arena, io, origin);
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});

    for ([_]CloneKind{ .content, .structural }) |kind| {
        const name = @tagName(kind);
        try cloneAs(arena, io, tmp.dir, url, name, kind, h.c4, "descendants");
        var dir = try tmp.dir.openDir(io, name, .{});
        defer dir.close(io);
        const sport = try freshLoopbackPort(io);
        var sub = try spawnServe(arena, io, dir, sport);
        defer sub.kill(io);
        const surl = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{sport});
        const below = try std.fmt.allocPrint(arena, "--horizon={s}", .{h.c2});
        for ([_][]const []const u8{
            &.{ "clone", surl, "dense-from-horizon" },
            &.{ "clone", surl, "deeper-from-horizon", below },
        }) |argv| {
            const res = try zitIn(arena, io, tmp.dir, argv);
            try std.testing.expectEqual(@as(?u8, 1), exitCode(res.term));
            try std.testing.expect(contains(res.stderr, "the peer refused"));
            tmp.dir.deleteTree(io, argv[2]) catch {};
        }
    }
}

test "horizon crossing: a clone whose horizon cannot be honoured is refused and leaves nothing behind" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    const h = try History.build(arena, io, origin);
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});

    const blob = try std.fmt.allocPrint(arena, "--horizon={s}", .{h.a2});
    const short = try std.fmt.allocPrint(arena, "--horizon={s}", .{h.c4[0..12]});
    const unknown = "--horizon=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
    const good = try std.fmt.allocPrint(arena, "--horizon={s}", .{h.c4});
    const refused = [_][]const []const u8{
        &.{blob},
        &.{ blob, "--structural" },
        &.{short},
        &.{unknown},
        &.{ unknown, "--structural" },
        &.{"--structural"},
        &.{"--horizon-rule=leaves"},
        &.{ good, "--horizon-rule=bogus" },
    };
    try tmp.dir.createDir(io, "work", .default_dir);
    var work = try tmp.dir.openDir(io, "work", .{});
    defer work.close(io);
    for (refused) |flags| {
        var argv: std.ArrayList([]const u8) = .empty;
        try argv.appendSlice(arena, &.{ "clone", url, "target" });
        try argv.appendSlice(arena, flags);
        const res = try zitIn(arena, io, work, argv.items);
        const code = exitCode(res.term);
        const label = try std.mem.join(arena, " ", flags);
        if (code == null or code.? == 0 or !contains(res.stderr, "horizon")) {
            std.debug.print("clone {s}: exit {any}, stderr: {s}\n", .{ label, res.term, res.stderr });
            return error.HorizonNotRefused;
        }
        var it = work.iterate();
        if (try it.next(io)) |entry| {
            std.debug.print("clone {s}: refused, but left '{s}' behind\n", .{ label, entry.name });
            return error.RefusedCloneLeftDebris;
        }
    }
}

/// Lands `body` (sealed with its Z card here) and the blobs it names straight
/// into the store in `dir`, as a sync ingest would: the shapes a zit commit
/// never writes (a second root, a delta manifest) without a second tool.
/// Returns the manifest's name.
fn putRaw(arena: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, body: []const u8, blobs: []const []const u8) ![]const u8 {
    const zit = @import("zit");
    var seal_buf: [32]u8 = undefined;
    const sealed = try std.fmt.allocPrint(arena, "{s}Z {s}\n", .{ body, zit.artifact.selfChecksum(body, &seal_buf) });
    var file = try dir.openFile(io, ".zit", .{ .mode = .read_write });
    defer file.close(io);
    const st = try file.stat(io);
    var fs = zit.storage.FileStorage.init(io, file, st.size);
    var log = try zit.logstore.LogStore.open(arena, fs.storageFile(), 0);
    defer log.deinit(arena);
    for (blobs) |b| {
        var digest: [32]u8 = undefined;
        std.crypto.hash.sha3.Sha3_256.hash(b, &digest, .{});
        _ = try log.put(&digest, b);
    }
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(sealed, &digest, .{});
    _ = try log.put(&digest, sealed);
    try log.commit();
    return std.fmt.allocPrint(arena, "{x}", .{digest});
}

test "horizon deepen: with no common ancestor of C and the target known, the deepen is refused and C never moves" {
    // Horizon draft section 5.8 -- a deepen moves C to a common ancestor of
    // C and the target. A second root shares no history with C: the clone
    // finds no ancestor, the origin answers `-`, and moving C to the target
    // itself would trade K(C) for an unrelated history. The deepen must be
    // refused naming the remedy, with the store and its horizon untouched.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    const h = try History.build(arena, io, origin);
    const other = "a second root's only file\n";
    const root2 = try putRaw(arena, io, origin, try std.fmt.allocPrint(arena, "C second\\sroot\nD 2026-01-01T00:00:00\nF other.txt {s}\nU u\n", .{try sha3Hex(arena, other)}), &.{other});
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});

    try cloneAs(arena, io, tmp.dir, url, "content", .content, h.c4, "descendants");
    var dir = try tmp.dir.openDir(io, "content", .{});
    defer dir.close(io);
    const before = try storeDigest(arena, io, dir);
    const res = try zitIn(arena, io, dir, &.{ "update", root2 });
    if (exitCode(res.term) == 0 or !contains(res.stderr, "no common ancestor") or !contains(res.stderr, "re-clone with an earlier --horizon")) {
        std.debug.print("update to an unrelated root: exit {any}\n--- stdout ---\n{s}\n--- stderr ---\n{s}\n", .{ res.term, res.stdout, res.stderr });
        return error.DeepenNotRefused;
    }
    const after = try storeDigest(arena, io, dir);
    try std.testing.expectEqualSlices(u8, &before, &after);
}

test "horizon merge: a merge-base query that would need more than two secondaries is refused, and the store is untouched" {
    // Horizon draft section 6.2.4 -- the query names the merged-in check-in
    // over the check-out and every pending plain merge, and a card carries
    // the check-out and one pending merge. With two merges pending, a third
    // whose base may lie below C cannot be asked: asking without them names
    // a base Fossil's merge would not choose. Refused naming the limit.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    const h = try History.build(arena, io, origin);
    // Two more branches forked at c2, below the horizon.
    for ([_][2][]const u8{ .{ "o2", "c.txt" }, .{ "o3", "d.txt" } }) |b| {
        try expectOk(try zitIn(arena, io, origin, &.{ "update", h.c2 }));
        try writeFile(io, origin, b[1], b[0]);
        try expectOk(try zitIn(arena, io, origin, &.{ "add", b[1] }));
        _ = try commit(arena, io, origin, &.{ "-m", b[0], "--branch", b[0] });
    }
    try expectOk(try zitIn(arena, io, origin, &.{ "update", "trunk" }));
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});

    // The leaves rule holds every open leaf back to its fork, so each branch
    // has a name here and only c1 lies below.
    try cloneAs(arena, io, tmp.dir, url, "structural", .structural, h.c4, "leaves");
    var dir = try tmp.dir.openDir(io, "structural", .{});
    defer dir.close(io);
    try expectOk(try zitIn(arena, io, dir, &.{ "merge", "old" }));
    try expectOk(try zitIn(arena, io, dir, &.{ "merge", "o2" }));
    const before = try storeDigest(arena, io, dir);
    const res = try zitIn(arena, io, dir, &.{ "merge", "o3" });
    if (exitCode(res.term) == 0 or !contains(res.stderr, "at most 1 pending merge, not the 2 pending here")) {
        std.debug.print("third stacked merge: exit {any}\n--- stdout ---\n{s}\n--- stderr ---\n{s}\n", .{ res.term, res.stdout, res.stderr });
        return error.StackedMergeNotRefused;
    }
    const after = try storeDigest(arena, io, dir);
    try std.testing.expectEqualSlices(u8, &before, &after);
}

test "horizon boundary: the parent of a baseline held below C is labelled, so a structural clone asks for nothing it cannot have" {
    // Horizon draft section 5.3 -- c1 -> c2 -> c3 (C) -> c4, with c3 and c4
    // delta manifests over the baseline c2. K = {c3, c4}; c2 is held below C
    // because the deltas cannot be read without it, and its P card names c1.
    // A boundary is a parent, not in K, of any manifest the clone holds, so
    // the origin labels c1 as well as c2: unlabelled, the clone takes c1 for
    // a late arrival and asks for what the origin never sends.
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.createDir(io, "origin", .default_dir);
    var origin = try tmp.dir.openDir(io, "origin", .{});
    defer origin.close(io);
    try expectOk(try zitIn(arena, io, origin, &.{"init"}));
    try writeFile(io, origin, "a.txt", "a1\n");
    try expectOk(try zitIn(arena, io, origin, &.{ "add", "a.txt" }));
    const c1 = try commit(arena, io, origin, &.{ "-m", "c1" });
    try writeFile(io, origin, "a.txt", "a2\n");
    const c2 = try commit(arena, io, origin, &.{ "-m", "c2" });
    const c3 = try putRaw(arena, io, origin, try std.fmt.allocPrint(arena, "B {s}\nC c3\nD 2026-01-02T00:00:00\nP {s}\nU u\n", .{ c2, c2 }), &.{});
    const a4 = "a4\n";
    const c4 = try putRaw(arena, io, origin, try std.fmt.allocPrint(arena, "B {s}\nC c4\nD 2026-01-03T00:00:00\nF a.txt {s}\nP {s}\nU u\n", .{ c2, try sha3Hex(arena, a4), c3 }), &.{a4});
    const port = try freshLoopbackPort(io);
    var server = try spawnServe(arena, io, origin, port);
    defer server.kill(io);
    const url = try std.fmt.allocPrint(arena, "http://127.0.0.1:{d}/", .{port});

    try cloneAs(arena, io, tmp.dir, url, "dense", .dense, c3, "descendants");
    try cloneAs(arena, io, tmp.dir, url, "structural", .structural, c3, "descendants");
    var dense = try tmp.dir.openDir(io, "dense", .{});
    defer dense.close(io);
    var dir = try tmp.dir.openDir(io, "structural", .{});
    defer dir.close(io);

    // Holdings: the manifests of K and the baseline they reach, and the seed.
    for ([_][]const u8{ c2, c3, c4 }) |m| try expectOk(try zitIn(arena, io, dir, &.{ "artifact", m }));
    try std.testing.expect(exitCode((try zitIn(arena, io, dir, &.{ "artifact", c1 })).term) != 0);
    _ = try dir.statFile(io, ".zit-cache/horizon-seed", .{});
    try std.testing.expectEqualStrings(a4, (try zitIn(arena, io, dir, &.{ "cat", "a.txt", c4 })).stdout);
    try std.testing.expectEqualStrings("a2\n", (try zitIn(arena, io, dir, &.{ "cat", "a.txt", c3 })).stdout);

    // Nothing outstanding: a pull has nothing to ask for -- one round, where
    // an unlabelled c1 costs a second to request what the origin withholds --
    // and verify counts c1 as elided, not missing.
    const pull = try zitInEnv(arena, io, dir, &.{"pull"}, .{ "ZIT_SYNC_STATS", "1" });
    try expectOk(pull);
    if (!contains(pull.stdout, "already in sync") or !contains(pull.stderr, "sync-stats: 1 rounds")) {
        std.debug.print("pull after the clone:\n--- stdout ---\n{s}\n--- stderr ---\n{s}\n", .{ pull.stdout, pull.stderr });
        return error.PullNotInSync;
    }
    const verify = try zitIn(arena, io, dir, &.{"verify"});
    try expectOk(verify);
    try std.testing.expect(contains(verify.stdout, " 0 faults"));
    try std.testing.expect(contains(verify.stdout, "; 1 artifact deliberately elided"));

    // Derived state is the dense store's: c4 the only leaf, c3 and c4 on
    // trunk with its symbolic tag.
    const leaves = try zitIn(arena, io, dir, &.{"leaves"});
    try expectOk(leaves);
    try std.testing.expectEqualStrings((try zitIn(arena, io, dense, &.{"leaves"})).stdout, leaves.stdout);
    try std.testing.expect(contains(leaves.stdout, c4[0..10]));
    try std.testing.expect(!contains(leaves.stdout, c2[0..10]));
    for ([_][]const u8{ c3, c4 }) |m| {
        const got = try zitIn(arena, io, dir, &.{ "info", m });
        try expectOk(got);
        try std.testing.expectEqualStrings((try zitIn(arena, io, dense, &.{ "info", m })).stdout, got.stdout);
        try std.testing.expect(contains(got.stdout, "trunk"));
    }
}