//! Public-surface tests for the `Repository` facade.
//!
//! Every fixture builds a real on-disk `.zit` (a writable `LogStore` seeded with
//! artifacts assembled by `artifact.create`) plus, where a checkout matters, a
//! `.zit-checkout` record — then opens it through `Repository.open` exactly as
//! the CLI does. Expected artifact names are the artifact §7.2 hash-over-
//! uncompressed of the fixture's own bytes; expected derivations come from the
//! repo-state RFC (§5-9), never from Fossil.
const std = @import("std");
const artifact = @import("artifact.zig");
const repostate = @import("repostate.zig");
const logstore = @import("logstore.zig");
const storage = @import("storage.zig");
const checkout = @import("checkout.zig");
const Repository = @import("repository.zig").Repository;
const testing = std.testing;
const hex_digits = "0123456789abcdef";
/// A tmp-backed repository builder: seeds a writable `.zit` log, then closes it
/// so `Repository.open` reads a settled on-disk repository. The tmp dir persists
/// until `cleanup`, so `Repository` can reopen `.zit` for lazy content.
const Repo = struct {
tmp: std.testing.TmpDir,
fs: storage.FileStorage,
log: logstore.LogStore,
file: std.Io.File,
io: std.Io,
fn init(self: *Repo, io: std.Io) !void {
self.io = io;
self.tmp = testing.tmpDir(.{});
self.file = try self.tmp.dir.createFile(io, ".zit", .{ .read = true });
self.fs = storage.FileStorage.init(io, self.file, 0);
self.log = try logstore.LogStore.open(testing.allocator, self.fs.storageFile(), 0);
}
/// Commit the seeded frames and release the writable handle; the `.zit` file
/// stays on disk under `tmp` for `Repository.open` to read.
fn seal(self: *Repo) !void {
try self.log.commit();
self.log.deinit(testing.allocator);
self.file.close(self.io);
}
fn cleanup(self: *Repo) void {
self.tmp.cleanup();
}
fn config(self: *Repo, default_branch: []const u8) !void {
try self.log.putConfig(.{ .hash_policy = 0, .default_branch = default_branch, .project_id = "p" });
}
/// Serialize `m`, store it under its own sha3-256 digest, return its 64-hex
/// artifact name (caller frees).
fn put(self: *Repo, m: *const artifact.Manifest) ![]u8 {
const bytes = try artifact.create(m, testing.allocator);
defer testing.allocator.free(bytes);
return self.putRaw(bytes);
}
/// Store arbitrary `bytes` under their sha3-256 digest, return the 64-hex
/// name (caller frees). Used for both artifacts and plain file blobs.
fn putRaw(self: *Repo, bytes: []const u8) ![]u8 {
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
_ = try self.log.put(&digest, bytes);
return hexName(&digest);
}
};
fn hexName(raw: []const u8) ![]u8 {
const out = try testing.allocator.alloc(u8, raw.len * 2);
for (raw, 0..) |b, i| {
out[i * 2] = hex_digits[b >> 4];
out[i * 2 + 1] = hex_digits[b & 0x0f];
}
return out;
}
/// A skeleton manifest: a root carries the propagating `branch` tag (plus any
/// `extra_tags`); a child names one `parent`. Files are irrelevant to the
/// derivations under test, so there are none.
const Spec = struct {
ts: []const u8,
parent: ?[]const u8 = null,
branch_value: []const u8 = "trunk",
extra_tags: []const artifact.TCard = &.{},
};
fn manifestOf(spec: Spec, tag_buf: []artifact.TCard, parent_buf: []artifact.PCard) artifact.Manifest {
var n: usize = 0;
if (spec.parent == null) {
tag_buf[n] = .{ .sign = .propagate, .name = "branch", .target = null, .value = spec.branch_value };
n += 1;
}
for (spec.extra_tags) |t| {
tag_buf[n] = t;
n += 1;
}
var p_slice: []const artifact.PCard = &.{};
if (spec.parent) |parent| {
parent_buf[0] = .{ .parents = parent, .count = 1 };
p_slice = parent_buf[0..1];
}
return baseManifest(.manifest, spec.ts, p_slice, tag_buf[0..n]);
}
fn baseManifest(kind: artifact.ArtifactType, ts: []const u8, p: []const artifact.PCard, t: []const artifact.TCard) artifact.Manifest {
return .{
.arena_state = std.heap.ArenaAllocator.init(testing.allocator),
.kind = kind,
.a = null,
.b = null,
.c = if (kind == .manifest) .{ .comment = "c" } else null,
.d = .{ .timestamp = ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = null,
.r = null,
.u = .{ .login = "u" },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = p,
.q = &.{},
.t = t,
};
}
fn putCheckin(repo: *Repo, spec: Spec) ![]u8 {
var tag_buf: [8]artifact.TCard = undefined;
var parent_buf: [1]artifact.PCard = undefined;
var m = manifestOf(spec, &tag_buf, &parent_buf);
defer m.deinit();
return repo.put(&m);
}
fn putControl(repo: *Repo, ts: []const u8, tags: []const artifact.TCard) ![]u8 {
var m = baseManifest(.control, ts, &.{}, tags);
defer m.deinit();
return repo.put(&m);
}
/// One F card (§4.7.6): a present `hash` is a file at that blob id, a null hash
/// is a delete marker. Perm/old-name are irrelevant to the file-set walk.
fn fcard(name: []const u8, hash: ?[]const u8) artifact.FCard {
return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}
/// A file-bearing check-in: a baseline manifest carrying `files` (root unless a
/// `parent` is given), or a delta manifest when `baseline` names a B-card hash.
/// `files` MUST already be sorted by decoded filename (§8.1); the writer emits
/// them verbatim and the parser re-checks the order.
const FSpec = struct {
ts: []const u8,
parent: ?[]const u8 = null,
baseline: ?[]const u8 = null,
files: []const artifact.FCard = &.{},
};
fn putFiles(repo: *Repo, spec: FSpec) ![]u8 {
var tag_buf: [1]artifact.TCard = undefined;
var n: usize = 0;
if (spec.parent == null) {
tag_buf[n] = .{ .sign = .propagate, .name = "branch", .target = null, .value = "trunk" };
n += 1;
}
var parent_buf: [1]artifact.PCard = undefined;
var p_slice: []const artifact.PCard = &.{};
if (spec.parent) |parent| {
parent_buf[0] = .{ .parents = parent, .count = 1 };
p_slice = parent_buf[0..1];
}
var m = baseManifest(.manifest, spec.ts, p_slice, tag_buf[0..n]);
defer m.deinit();
m.f = spec.files;
if (spec.baseline) |bh| m.b = .{ .hash = bh };
return repo.put(&m);
}
/// Drain a `FileList` into `out` (caller-owned) so a test can assert the whole
/// effective set at once — names, order, and blob ids.
fn collectFiles(list: *const Repository.FileList, out: *std.ArrayList(artifact.FCard)) !void {
var it = list.iterator();
while (it.next()) |f| try out.append(testing.allocator, f);
}
/// Open `repo` through the facade exactly as the CLI does.
fn openRepo(out: *Repository, repo: *Repo, io: std.Io) !void {
try Repository.open(out, testing.allocator, io, repo.tmp.dir, .{});
}
test "repository §8.3: resolve a symbolic name to its check-in rid" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A root, then a child tagged with the symbolic name `sym-feature` (§8.3
// resolves a plain name through its active sym- tag, §5.1).
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const sym = [_]artifact.TCard{.{ .sign = .propagate, .name = "sym-feature", .target = null, .value = null }};
const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .extra_tags = &sym });
defer testing.allocator.free(child);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
// §8.3: "feature" resolves to the check-in carrying its active sym- tag.
const rid = try facade.resolve("feature", .checkin);
try testing.expectEqualStrings(child, facade.hashOf(rid));
// The full artifact id resolves to the same rid (§8.3 hash-prefix form).
try testing.expectEqual(rid, try facade.resolve(child, .checkin));
}
test "repository §8.2: resolve is recency-based — names a closed non-leaf that leaves omits" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A root that both bears a sym- name and is later closed AND superseded by a
// child: it is neither a leaf nor open, yet §8.2 resolution (max-mtime among
// sym-tagged) still names it — resolution never filters on leaf/closed. The
// sym tag is a singleton (§5.1 `+`), so it names this check-in alone — a
// propagating tag would flow to the child and resolve there instead.
const named = [_]artifact.TCard{.{ .sign = .add, .name = "sym-rel", .target = null, .value = null }};
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &named });
defer testing.allocator.free(root);
const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
const ctl = try putControl(&repo, "2020-01-03T00:00:00", &close);
defer testing.allocator.free(ctl);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const rid = try facade.resolve("rel", .checkin);
try testing.expectEqualStrings(root, facade.hashOf(rid));
// §8.2: the resolved check-in is a non-leaf (has a same-branch child) —
// exactly what leaf enumeration omits, proving resolve ≠ leaf listing.
try testing.expect(!facade.state().isLeaf(rid));
}
test "repository §8.3: an unresolvable name is NotFound; the closed ResolveError forwards RepoState.resolve" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
// §8.3: a name matching no id, tag, or keyword is NotFound.
try testing.expectError(error.NotFound, facade.resolve("zzzz-nope", .checkin));
// A hex-shaped prefix (≥4 digits) that names no stored id is also NotFound.
try testing.expectError(error.NotFound, facade.resolve("deadbeef", .checkin));
// The other arm, error.Ambiguous, is raised only by the §8.3 hash-prefix
// form when a ≥4-hex prefix matches two ids — impossible to force
// hermetically without colliding sha3-256 digests on 16 bits. `resolve`
// forwards `RepoState.resolve`'s closed `{ NotFound, Ambiguous }` verbatim
// (no added logic), so its Ambiguous arm is covered by repostate's own §8.3
// resolver tests; the CLI switches the same closed set exhaustively.
comptime {
const E = @typeInfo(@typeInfo(@TypeOf(Repository.resolve)).@"fn".return_type.?).error_union.error_set;
const names = @typeInfo(E).error_set.?;
std.debug.assert(names.len == 2); // exactly { NotFound, Ambiguous }
}
}
test "repository §7.1/§7.4: leaves lists open leaves; a closed leaf surfaces only under the all predicate" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Two independent roots on trunk; close one. Both are leaf-table members
// (§7.1), but only the open one passes the §7.4 open predicate.
const open_root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(open_root);
const closed_root = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00" });
defer testing.allocator.free(closed_root);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = closed_root, .value = null }};
const ctl = try putControl(&repo, "2020-01-03T00:00:00", &close);
defer testing.allocator.free(ctl);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const open_rid = facade.lookup(open_root).?;
const closed_rid = facade.lookup(closed_root).?;
// §7.1: both are leaf-table members.
try testing.expect(facade.state().isLeaf(open_rid));
try testing.expect(facade.state().isLeaf(closed_rid));
// §7.4: only the open leaf passes the open predicate.
try testing.expect(facade.state().isOpenLeaf(open_rid));
try testing.expect(!facade.state().isOpenLeaf(closed_rid));
// Enumerating "open only" yields exactly the open leaf; "all" yields both.
var open_only: usize = 0;
var all: usize = 0;
var it = facade.state().leaves();
while (it.next()) |rid| {
all += 1;
if (facade.state().isOpenLeaf(rid)) open_only += 1;
}
try testing.expectEqual(@as(usize, 2), all);
try testing.expectEqual(@as(usize, 1), open_only);
}
test "repository §7.5: currentCheckoutRid marks the checkout leaf; null when unborn/absent" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
try repo.seal();
// (a) No `.zit-checkout`: nothing to mark.
{
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expect(facade.currentCheckoutRid() == null);
}
// (b) A checkout naming the root: currentCheckoutRid resolves to its rid.
try checkout.write(io, repo.tmp.dir, .{ .version = root, .branch = "trunk" });
{
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const cur = facade.currentCheckoutRid() orelse return error.NothingMarked;
try testing.expectEqualStrings(root, facade.hashOf(cur));
}
// (c) A checkout naming a version this repo never interned: null (§7.5 — a
// version the view never saw marks nothing).
try checkout.write(io, repo.tmp.dir, .{ .version = "f" ** 64, .branch = "trunk" });
{
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expect(facade.currentCheckoutRid() == null);
}
}
test "repository §6.1: branchOf uses the config main-branch as fallback, else trunk" {
const io = testing.io;
// (a) No config frame, a tagless resolution falls back to "trunk".
{
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectEqualStrings("trunk", facade.mainBranch()); // §6.1 unset ⇒ trunk
try testing.expectEqualStrings("trunk", facade.branchOf(facade.lookup(root).?));
}
// (b) A config frame naming "mainline": branchOf threads the configured
// fallback (§6.1 MUST read the configured value).
{
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
try repo.config("mainline");
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00", .branch_value = "mainline" });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectEqualStrings("mainline", facade.mainBranch());
try testing.expectEqualStrings("mainline", facade.branchOf(facade.lookup(root).?));
}
}
test "repository §5.1/§5.2: tagsOf returns active tags on a resolved check-in, valued and valueless" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A root carrying the valued `branch` tag (§5.2 value = "trunk", added by
// manifestOf) plus a valueless singleton sym- tag.
const extra = [_]artifact.TCard{.{ .sign = .add, .name = "sym-v1", .target = null, .value = null }};
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &extra });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const rid = facade.lookup(root).?;
const active = try facade.state().tagsOf(testing.allocator, rid);
defer testing.allocator.free(active);
// §5.1/§5.2: the `branch` tag carries its value; the valueless sym tag
// carries none. Both are surfaced as active.
var saw_valued = false;
var saw_valueless = false;
for (active) |t| {
if (std.mem.eql(u8, t.name, "branch")) {
saw_valued = true;
try testing.expectEqualStrings("trunk", t.value.?);
}
if (std.mem.eql(u8, t.name, "sym-v1")) {
saw_valueless = true;
try testing.expect(t.value == null);
}
}
try testing.expect(saw_valued and saw_valueless);
}
test "repository: an unborn repository has no leaves — empty result, no error" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
try repo.seal(); // an empty log: header only, no check-ins
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
var count: usize = 0;
var it = facade.state().leaves();
while (it.next()) |_| count += 1;
try testing.expectEqual(@as(usize, 0), count);
}
test "repository open: not-a-repo → NotARepo; a .zit that cannot be read → CannotRead; a corrupt log → Corrupt" {
const io = testing.io;
// NotARepo: a directory with no `.zit`.
{
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var facade: Repository = undefined;
try testing.expectError(error.NotARepo, Repository.open(&facade, testing.allocator, io, tmp.dir, .{}));
}
// CannotRead: `.zit` exists but is a directory, so the open cannot read it.
{
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDir(io, ".zit", .default_dir);
var facade: Repository = undefined;
try testing.expectError(error.CannotRead, Repository.open(&facade, testing.allocator, io, tmp.dir, .{}));
}
// Corrupt: a `.zit` whose bytes are not a valid log (short garbage header).
{
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var f = try tmp.dir.createFile(io, ".zit", .{});
try f.writePositionalAll(io, "not a log", 0);
f.close(io);
var facade: Repository = undefined;
try testing.expectError(error.Corrupt, Repository.open(&facade, testing.allocator, io, tmp.dir, .{}));
}
}
test "repository open: no leak or crash under injected OOM at every derive allocation" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
try repo.seal();
// A manual `FailingAllocator` sweep rather than `checkAllAllocationFailures`:
// `open` deliberately performs a best-effort `.zit-cache` write that SWALLOWS
// OOM (the cache is advisory — a failed write is not an open failure), which
// the "never swallow" checker cannot model. This sweep still proves the real
// obligation at every allocation point: `open` returns ok or OutOfMemory,
// never crashes, and never leaks (the wrapped testing.allocator asserts no
// leak at test end). `--no-cache` forces the full openReadOnly + derive path.
var i: usize = 0;
while (true) : (i += 1) {
var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = i });
const a = fa.allocator();
var facade: Repository = undefined;
if (Repository.open(&facade, a, io, repo.tmp.dir, .{ .bypass_cache = true })) |_| {
facade.deinit(a);
// The injected fail point lay at or past the last allocation, so no
// failure was forced this round — every allocation site is covered.
if (!fa.has_induced_failure) break;
} else |err| {
try testing.expectEqual(error.OutOfMemory, err);
}
}
}
test "repository cache parity: a .zit-cache fast-path view equals a from-scratch derive" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A root, a same-branch child, a fork, and a control close — shape enough to
// exercise leaves, propagation, and tags.
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root });
defer testing.allocator.free(child);
const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root });
defer testing.allocator.free(fork);
const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = fork, .value = null }};
const ctl = try putControl(&repo, "2020-01-04T00:00:00", &close);
defer testing.allocator.free(ctl);
try repo.seal();
// First open with --no-cache derives from scratch AND writes a fresh cache.
var scratch: Repository = undefined;
try Repository.open(&scratch, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true });
defer scratch.deinit(testing.allocator);
// Second open takes the O(1) `.zit-cache` fast path.
var cached: Repository = undefined;
try Repository.open(&cached, testing.allocator, io, repo.tmp.dir, .{});
defer cached.deinit(testing.allocator);
// The two views are observably identical (§1 memo == §7.3 rebuild).
try testing.expectEqualStrings(scratch.mainBranch(), cached.mainBranch());
var ls = scratch.state().leaves();
var lc = cached.state().leaves();
while (ls.next()) |rs| {
const rc = lc.next() orelse return error.LeafCountMismatch;
try testing.expectEqual(rs, rc);
try testing.expectEqualStrings(scratch.hashOf(rs), cached.hashOf(rc));
try testing.expectEqual(scratch.state().isOpenLeaf(rs), cached.state().isOpenLeaf(rc));
try testing.expectEqualStrings(scratch.branchOf(rs), cached.branchOf(rc));
}
try testing.expect(lc.next() == null);
}
test "repository manifest/blob: lazy content read returns the uncompressed blob whose hash matches (artifact §7.2)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A plain file blob and a check-in manifest, each content-addressed by its
// sha3-256 (artifact §7.2 hash-over-uncompressed).
const blob_bytes = "hello, repository\n";
const blob_name = try repo.putRaw(blob_bytes);
defer testing.allocator.free(blob_name);
const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
// blob: the lazily-opened log returns the exact uncompressed bytes, and the
// facade verified they hash to the requested name (§7.2).
const got = (try facade.blob(testing.allocator, blob_name)) orelse return error.BlobMissing;
defer testing.allocator.free(got);
try testing.expectEqualStrings(blob_bytes, got);
var digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(got, &digest, .{});
const rehash = try hexName(&digest);
defer testing.allocator.free(rehash);
try testing.expectEqualStrings(blob_name, rehash); // §7.2: name == hash of uncompressed
// An unknown name is a clean "not present", not an error.
try testing.expect((try facade.blob(testing.allocator, "a" ** 64)) == null);
// manifest: resolve the root, read its manifest lazily, and confirm the
// round-trip through parse recovered the D-card timestamp we stored.
const rid = facade.lookup(root).?;
var om = (try facade.manifest(testing.allocator, rid)) orelse return error.ManifestMissing;
defer om.deinit(testing.allocator);
try testing.expectEqualStrings("2020-01-01T00:00:00", om.manifest.d.?.timestamp);
}
// F-card blob ids used by the file-set tests. Any valid 64-hex token works —
// `files` walks manifests, never reads file blobs — so these need not name
// stored content; they only have to round-trip through create/parse.
const ha = "a" ** 64;
const hb = "b" ** 64;
const hc = "c" ** 64;
const hd = "d" ** 64;
test "repository files §8.2: a baseline manifest yields its F cards in canonical order" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// §4.5.3: F cards are sorted by decoded filename; §4.7.6: a baseline's cards
// every carry a hash. The effective set is exactly the authored set, in order.
const files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb), fcard("c", hc) };
const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &files });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
var list = (try facade.files(testing.allocator, facade.lookup(root).?)) orelse return error.NoFiles;
defer list.deinit(testing.allocator);
var got: std.ArrayList(artifact.FCard) = .empty;
defer got.deinit(testing.allocator);
try collectFiles(&list, &got);
try testing.expectEqual(@as(usize, 3), got.items.len);
try testing.expectEqualStrings("a", got.items[0].filename);
try testing.expectEqualStrings("b", got.items[1].filename);
try testing.expectEqualStrings("c", got.items[2].filename);
try testing.expectEqualStrings(ha, got.items[0].hash.?);
try testing.expectEqualStrings(hb, got.items[1].hash.?);
try testing.expectEqualStrings(hc, got.items[2].hash.?);
}
test "repository files §8.2: delta manifest merges baseline+delta; a replaced file takes the delta hash (step 1)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Baseline {a:ha, b:hb}; delta replaces b (→hc, §8.2 step 1) and adds c (hd).
// Effective set (§8.2 filename-ordered merge): a:ha, b:hc, c:hd.
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
defer list.deinit(testing.allocator);
var got: std.ArrayList(artifact.FCard) = .empty;
defer got.deinit(testing.allocator);
try collectFiles(&list, &got);
try testing.expectEqual(@as(usize, 3), got.items.len);
try testing.expectEqualStrings("a", got.items[0].filename);
try testing.expectEqualStrings(ha, got.items[0].hash.?); // unchanged baseline entry
try testing.expectEqualStrings("b", got.items[1].filename);
try testing.expectEqualStrings(hc, got.items[1].hash.?); // step 1: delta hash wins
try testing.expectEqualStrings("c", got.items[2].filename);
try testing.expectEqualStrings(hd, got.items[2].hash.?); // step 3: delta-only add
}
test "repository files §8.2: a delta delete-marker suppresses the baseline file (step 2, §8.2-r2)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Baseline {a:ha, b:hb}; delta carries a delete marker for b (null hash).
// §8.2 step 2: matched delete emits nothing — effective set is {a:ha}.
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{fcard("b", null)};
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
defer list.deinit(testing.allocator);
var got: std.ArrayList(artifact.FCard) = .empty;
defer got.deinit(testing.allocator);
try collectFiles(&list, &got);
try testing.expectEqual(@as(usize, 1), got.items.len);
try testing.expectEqualStrings("a", got.items[0].filename);
try testing.expectEqualStrings(ha, got.items[0].hash.?);
}
test "repository files §8.2: unmatched/tail delete markers never appear in the effective set (§4.7.6 hash-less card)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Baseline {b:hb}; delta carries two delete markers with no baseline match:
// "a" (sorts before b — §8.2 step-3 unmatched) and "c" (delta tail, §8.2-m1).
// Both are emitted by the raw walk but carry no hash, so the effective set is
// just {b:hb} (§4.7.6: consumers filter hash-less cards).
const base_files = [_]artifact.FCard{fcard("b", hb)};
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{ fcard("a", null), fcard("c", null) };
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
var list = (try facade.files(testing.allocator, facade.lookup(delta).?)) orelse return error.NoFiles;
defer list.deinit(testing.allocator);
var got: std.ArrayList(artifact.FCard) = .empty;
defer got.deinit(testing.allocator);
try collectFiles(&list, &got);
try testing.expectEqual(@as(usize, 1), got.items.len);
try testing.expectEqualStrings("b", got.items[0].filename);
try testing.expectEqualStrings(hb, got.items[0].hash.?);
}
test "repository files §8.3: an unresolvable baseline is BaselineUnresolvable (§8.3-m1)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A delta whose B card names a baseline that was never stored. §8.3 hard
// failure: file-set enumeration MUST treat it as fatal, not degrade to
// delta-only.
const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const delta_files = [_]artifact.FCard{fcard("a", ha)};
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .baseline = hb, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectError(error.BaselineUnresolvable, facade.files(testing.allocator, facade.lookup(delta).?));
}
test "repository files §4.7.3: a baseline that is itself a delta is BaselineIsDelta" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A: a genuine baseline. B: a delta over A. C: a delta whose baseline is B —
// a delta-of-delta, rejected at resolution time (§4.7.3).
const base_files = [_]artifact.FCard{fcard("a", ha)};
const a_ci = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(a_ci);
const b_files = [_]artifact.FCard{fcard("a", hb)};
const b_ci = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = a_ci, .baseline = a_ci, .files = &b_files });
defer testing.allocator.free(b_ci);
const c_files = [_]artifact.FCard{fcard("a", hc)};
const c_ci = try putFiles(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = b_ci, .baseline = b_ci, .files = &c_files });
defer testing.allocator.free(c_ci);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectError(error.BaselineIsDelta, facade.files(testing.allocator, facade.lookup(c_ci).?));
}
test "repository files: a non-manifest (control) artifact is NotManifest" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A control artifact is interned (its hash gains a rid) but is not a check-in
// (§5.1); `files` on it is NotManifest, never a file listing.
const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const tags = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }};
const ctl = try putControl(&repo, "2020-01-02T00:00:00", &tags);
defer testing.allocator.free(ctl);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectError(error.NotManifest, facade.files(testing.allocator, facade.lookup(ctl).?));
}
test "repository files: missing target content returns null" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A child whose parent hash was never stored: the parent is interned as a
// phantom (a rid with no content). `files` on it is a clean null, not a fault.
const child = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = hd });
defer testing.allocator.free(child);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const phantom = facade.lookup(hd).?; // interned as the unstored parent
try testing.expect((try facade.files(testing.allocator, phantom)) == null);
}
test "repository files: no leak under injected OOM at every allocation" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A delta over a baseline exercises the deepest allocation path: read+parse
// the target, eagerly read+parse the baseline, then enumerate.
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
// A manual `FailingAllocator` sweep rather than `checkAllAllocationFailures`:
// `open` runs a best-effort `.zit-cache` write that SWALLOWS OOM (the cache is
// advisory), which the "never swallow" checker cannot model — the same reason
// the `open` OOM test sweeps manually. Each round opens a fresh repository and
// runs `files` under one failing allocator, proving both return ok or
// OutOfMemory, never crash, and never leak (the wrapped allocator asserts).
var i: usize = 0;
while (true) : (i += 1) {
var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = i });
const a = fa.allocator();
var facade: Repository = undefined;
if (Repository.open(&facade, a, io, repo.tmp.dir, .{ .bypass_cache = true })) |_| {
const rid = facade.lookup(delta).?;
if (facade.files(a, rid)) |maybe| {
if (maybe) |ml| {
var list = ml;
list.deinit(a);
}
facade.deinit(a);
// The injected fail point lay at or past the last allocation, so
// no failure was forced — every allocation site is covered.
if (!fa.has_induced_failure) break;
} else |err| {
facade.deinit(a);
try testing.expectEqual(error.OutOfMemory, err);
}
} else |err| {
try testing.expectEqual(error.OutOfMemory, err);
}
}
}
test "repository §8.4: fileAt on a baseline manifest returns the named file's F-card hash; an absent name is null" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A baseline {a:ha, b:hb, c:hc}. §8.4 exact-name lookup returns the F card
// whose filename equals the query; a name no card carries is null.
const files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb), fcard("c", hc) };
const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &files });
defer testing.allocator.free(root);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const rid = facade.lookup(root).?;
const found = (try facade.fileAt(testing.allocator, rid, "b")) orelse return error.NotFound;
defer Repository.freeFileCard(testing.allocator, found);
try testing.expectEqualStrings("b", found.filename);
try testing.expectEqualStrings(hb, found.hash.?);
// §8.4: a name entirely absent from the (baseline) manifest is not-found.
try testing.expect((try facade.fileAt(testing.allocator, rid, "zzz")) == null);
}
test "repository §8.4: fileAt searches the delta's own F cards first — a file the delta replaced returns the delta hash, not the baseline hash" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Baseline {a:ha, b:hb}; delta replaces b (→hc) and adds c (hd). §8.4:
// "b" is found in the delta's own cards first (hc wins over the baseline hb);
// "a" is untouched by the delta so it comes from the baseline (ha); "c" is a
// delta-only addition (hd).
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const rid = facade.lookup(delta).?;
const b = (try facade.fileAt(testing.allocator, rid, "b")) orelse return error.NotFound;
defer Repository.freeFileCard(testing.allocator, b);
try testing.expectEqualStrings(hc, b.hash.?); // delta hash, not baseline hb
const a = (try facade.fileAt(testing.allocator, rid, "a")) orelse return error.NotFound;
defer Repository.freeFileCard(testing.allocator, a);
try testing.expectEqualStrings(ha, a.hash.?); // untouched baseline entry
const c = (try facade.fileAt(testing.allocator, rid, "c")) orelse return error.NotFound;
defer Repository.freeFileCard(testing.allocator, c);
try testing.expectEqualStrings(hd, c.hash.?); // delta-only add
}
test "repository §8.4: a delta delete-marker reports not-found without consulting the baseline (§4.7.6 hash-less card)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Baseline {a:ha, b:hb}; delta carries a delete marker (null hash) for b.
// §8.4: an entry whose hash is absent reports not-found for "b" without
// consulting the baseline; "a" (unmentioned by the delta) still resolves.
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{fcard("b", null)};
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const rid = facade.lookup(delta).?;
try testing.expect((try facade.fileAt(testing.allocator, rid, "b")) == null);
const a = (try facade.fileAt(testing.allocator, rid, "a")) orelse return error.NotFound;
defer Repository.freeFileCard(testing.allocator, a);
try testing.expectEqualStrings(ha, a.hash.?);
}
test "repository §8.4: a name entirely absent from the delta resolves against the baseline" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// Baseline {a:ha, b:hb}; delta touches only c (hd). §8.4: "a" is entirely
// absent from the delta, so the baseline is resolved and searched — ha.
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{fcard("c", hd)};
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
const rid = facade.lookup(delta).?;
const a = (try facade.fileAt(testing.allocator, rid, "a")) orelse return error.NotFound;
defer Repository.freeFileCard(testing.allocator, a);
try testing.expectEqualStrings(ha, a.hash.?);
}
test "repository §8.4: fileAt forwards the §8.3/§4.7.3 baseline faults (BaselineUnresolvable, BaselineIsDelta) unchanged" {
const io = testing.io;
// (a) §8.3: a delta whose B card names a never-stored baseline. fileAt runs
// the same §8.2 walk as `files`, so the hard failure surfaces unchanged.
{
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00" });
defer testing.allocator.free(root);
const delta_files = [_]artifact.FCard{fcard("a", ha)};
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .baseline = hb, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectError(error.BaselineUnresolvable, facade.fileAt(testing.allocator, facade.lookup(delta).?, "a"));
}
// (b) §4.7.3: a delta whose baseline is itself a delta (delta-of-delta).
{
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
const base_files = [_]artifact.FCard{fcard("a", ha)};
const a_ci = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(a_ci);
const b_files = [_]artifact.FCard{fcard("a", hb)};
const b_ci = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = a_ci, .baseline = a_ci, .files = &b_files });
defer testing.allocator.free(b_ci);
const c_files = [_]artifact.FCard{fcard("a", hc)};
const c_ci = try putFiles(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = b_ci, .baseline = b_ci, .files = &c_files });
defer testing.allocator.free(c_ci);
try repo.seal();
var facade: Repository = undefined;
try openRepo(&facade, &repo, io);
defer facade.deinit(testing.allocator);
try testing.expectError(error.BaselineIsDelta, facade.fileAt(testing.allocator, facade.lookup(c_ci).?, "a"));
}
}
test "repository §8.4: fileAt leaks nothing under injected OOM at every allocation (checkAllAllocationFailures)" {
const io = testing.io;
var repo: Repo = undefined;
try repo.init(io);
defer repo.cleanup();
// A delta over a baseline: fileAt reads+parses the target, eagerly reads+
// parses the baseline, enumerates, then duplicates the matched card — the
// deepest allocation path. A manual `FailingAllocator` sweep (not
// `checkAllAllocationFailures`) because `open` swallows the advisory cache
// write's OOM, which the never-swallow checker cannot model.
const base_files = [_]artifact.FCard{ fcard("a", ha), fcard("b", hb) };
const base = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &base_files });
defer testing.allocator.free(base);
const delta_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) };
const delta = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &delta_files });
defer testing.allocator.free(delta);
try repo.seal();
var i: usize = 0;
while (true) : (i += 1) {
var fa = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = i });
const a = fa.allocator();
var facade: Repository = undefined;
if (Repository.open(&facade, a, io, repo.tmp.dir, .{ .bypass_cache = true })) |_| {
const rid = facade.lookup(delta).?;
if (facade.fileAt(a, rid, "b")) |maybe| {
if (maybe) |card| Repository.freeFileCard(a, card);
facade.deinit(a);
if (!fa.has_induced_failure) break; // fail point past the last alloc: fully covered
} else |err| {
facade.deinit(a);
try testing.expectEqual(error.OutOfMemory, err);
}
} else |err| {
try testing.expectEqual(error.OutOfMemory, err);
}
}
}