//! 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 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"));
}
}