Artifact a63a6c2a2995b42344eabbc80265ceb717f3872edaffb00a87fd1fa5ae8f2187:
- File
src/repository_test.zig
— part of check-in
[c80728261e]
at
2026-08-28 21:29:08
on branch trunk
— import-fossil --submanifest: read and parse each baseline manifest once
repo.files re-read and re-parsed the B-card baseline for every delta check-in — 79% of the mint phase on pkgsrc. Repository.filesMemo takes a caller-owned BaselineMemo keyed by artifact name; consecutive deltas over one baseline borrow the parsed manifest and read nothing. On a 30k-file history of 300 deltas, source enumeration went from 10s to 0.3s and the conversion from 13.5s to 3.7s, every check-in hash identical. The memo holds a heap entry with a stable address; the optional-by-value form did not persist under ReleaseFast. Closes zit#167. (user: dmestas, size: 100882) [annotate] [blame] [check-ins using]
//! 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 rfc_vectors = @import("rfc_vectors.zig"); const artifact = @import("artifact.zig"); const repostate = @import("repostate.zig"); const logstore = @import("logstore.zig"); const repocache = @import("repocache.zig"); const cone_mod = @import("cone.zig"); const repoview = @import("repoview.zig"); const storage = @import("storage.zig"); const checkout = @import("checkout.zig"); const Repository = @import("repository.zig").Repository; const submanifest = @import("repository.zig").submanifest; const repository = @import("repository.zig"); 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); } }; /// Whether a `.zit-cache` sidecar exists beside the repo's `.zit` — the on-disk /// signal that the derivation cache was materialized (its name is a repocache /// internal, so it is spelled literally here rather than imported). fn cacheExists(repo: *Repo, io: std.Io) bool { var f = repo.tmp.dir.openFile(io, ".zit-cache", .{}) catch return false; f.close(io); return true; } 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, .{}); } /// Seed a chain of check-ins over `dirs` directories x `per_dir` files. Each /// churn commit rewrites one file per `mode`: .focus hammers the same file, /// .cycle touches a different directory each time. Returns the tip (caller /// frees). Filenames diverge on the FIRST byte so small k values partition. fn seedReplayHistory(repo: *Repo, dirs: usize, per_dir: usize, churns: usize, mode: enum { focus, cycle }) ![]u8 { const gpa = testing.allocator; var blobs: std.ArrayList([]u8) = .empty; defer { for (blobs.items) |b| gpa.free(b); blobs.deinit(gpa); } var names: std.ArrayList([]u8) = .empty; defer { for (names.items) |n| gpa.free(n); names.deinit(gpa); } var cards: std.ArrayList(artifact.FCard) = .empty; defer cards.deinit(gpa); for (0..dirs) |d| { for (0..per_dir) |i| { const content = try std.fmt.allocPrint(gpa, "seed {d}/{d}\n", .{ d, i }); defer gpa.free(content); const blob = try repo.putRaw(content); try blobs.append(gpa, blob); const fname = try std.fmt.allocPrint(gpa, "{c}dir/file{d:0>3}.txt", .{ @as(u8, 'a') + @as(u8, @intCast(d)), i }); try names.append(gpa, fname); try cards.append(gpa, fcard(fname, blob)); } } var tip = try putFiles(repo, .{ .ts = "2020-01-01T00:00:00", .files = cards.items }); var day: usize = 2; for (0..churns) |c| { const content = try std.fmt.allocPrint(gpa, "churn {d}\n", .{c}); defer gpa.free(content); const blob = try repo.putRaw(content); try blobs.append(gpa, blob); const idx = switch (mode) { .focus => 0, .cycle => (c * per_dir) % cards.items.len, }; cards.items[idx].hash = blob; var ts_buf: [32]u8 = undefined; const ts = try std.fmt.bufPrint(&ts_buf, "2020-01-{d:0>2}T00:00:00", .{day}); day += 1; const child = try putFiles(repo, .{ .ts = ts, .parent = tip, .files = cards.items }); gpa.free(tip); tip = child; } return tip; } test "repository #41 replay: focused churn earns its trees - dedupe credits reuse, bootstrap splits out, verdict positive" { const io = testing.io; const gpa = testing.allocator; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try repo.config("trunk"); const tip = try seedReplayHistory(&repo, 6, 5, 8, .focus); defer gpa.free(tip); try repo.seal(); var r: Repository = undefined; try openRepo(&r, &repo, io); defer r.deinit(gpa); var rep = try submanifest.replay(gpa, &r, 100, &.{ 1, 2 }); defer rep.deinit(gpa); try testing.expectEqual(@as(usize, 9), rep.commits); // base + 8 churns try testing.expect(rep.flat_full > rep.flat_full_steady); try testing.expect(rep.flat_store > rep.flat_store_steady); const w = rep.bestArm() orelse return error.TestUnexpectedResult; try testing.expect(!w.all_flat); // Bootstrap (the whole forest) dwarfs any single steady commit's cost. try testing.expect(w.bootstrap_store > w.steady_store / 8); // Focused churn: every steady commit re-emits only the touched chain, so // materialization beats the flat manifests it replaces. try testing.expect(w.steady_full < rep.flat_full_steady); // Determinism: a second replay reproduces every number byte for byte. var rep2 = try submanifest.replay(gpa, &r, 100, &.{ 1, 2 }); defer rep2.deinit(gpa); try testing.expectEqual(rep.flat_store, rep2.flat_store); for (rep.arms, rep2.arms) |a, b| { try testing.expectEqual(a.steady_store, b.steady_store); try testing.expectEqual(a.steady_full, b.steady_full); try testing.expectEqual(a.bootstrap_store, b.bootstrap_store); } } test "repository #41 replay: a delta-manifest history replays — the synthesized tree root is a baseline (never B and S)" { // pkgsrc-2012 crashed the first replay build here: a foreign history's // DELTA manifests were copied wholesale into the synthesized tree root, // tripping the writer's B-and-S-never-both assert (§4.4). const io = testing.io; const gpa = testing.allocator; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try repo.config("trunk"); const b1 = try repo.putRaw("base one\n"); defer gpa.free(b1); const b2 = try repo.putRaw("base two\n"); defer gpa.free(b2); const b3 = try repo.putRaw("churned\n"); defer gpa.free(b3); const root = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &.{ fcard("adir/a.txt", b1), fcard("bdir/b.txt", b2) } }); defer gpa.free(root); // A DELTA manifest child (B card against the root), fossil-style. const child = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root, .baseline = root, .files = &.{fcard("adir/a.txt", b3)} }); defer gpa.free(child); try repo.seal(); var r: Repository = undefined; try openRepo(&r, &repo, io); defer r.deinit(gpa); var rep = try submanifest.replay(gpa, &r, 100, &.{1}); defer rep.deinit(gpa); try testing.expectEqual(@as(usize, 2), rep.commits); // The flat arm's full-form axis charges MATERIALIZATION: the delta // commit costs its own plaintext PLUS its baseline's — resolving a // B-card manifest requires expanding the baseline, which is exactly why // delta manifests never rescued the F-card wall. Charging the delta's // own bytes alone mis-verdicted the fossil-style corpus this exists for. var m_root = (try r.manifest(gpa, r.lookup(root).?)) orelse return error.TestUnexpectedResult; defer m_root.deinit(gpa); var m_child = (try r.manifest(gpa, r.lookup(child).?)) orelse return error.TestUnexpectedResult; defer m_child.deinit(gpa); try testing.expectEqual( @as(u64, m_root.bytes.len + m_child.bytes.len + m_root.bytes.len), rep.flat_full, ); } test "repository #41 replay: a single-commit history is not evidence, and a flat-at-every-k arm mirrors the flat history" { const io = testing.io; const gpa = testing.allocator; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try repo.config("trunk"); const b1 = try repo.putRaw("only blob\n"); defer gpa.free(b1); const tip = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &.{ fcard("a.txt", b1), fcard("b.txt", b1) } }); defer gpa.free(tip); try repo.seal(); var r: Repository = undefined; try openRepo(&r, &repo, io); defer r.deinit(gpa); var rep = try submanifest.replay(gpa, &r, 100, &.{12}); defer rep.deinit(gpa); try testing.expectEqual(@as(usize, 1), rep.commits); // Two names never partition at k=12: the arm stayed flat and its costs // are exactly the flat history's. try testing.expect(rep.arms[0].all_flat); try testing.expectEqual(rep.flat_store, rep.arms[0].bootstrap_store + rep.arms[0].steady_store); try testing.expectEqual(rep.flat_full, rep.arms[0].bootstrap_full + rep.arms[0].steady_full); } 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, OutOfMemory }` // 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. `OutOfMemory` joined the set when equal-`event.mtime` ties // started being settled by DAG ancestry (`MtimeMaxPick`), which allocates. 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 == 3); // exactly { NotFound, Ambiguous, OutOfMemory } } } 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", .merge_parent = "", .special_op = "", .manifest_flags = "" }); { 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", .merge_parent = "", .special_op = "", .manifest_flags = "" }); { 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 warmCache §1/§7.1: eagerly warming a cold repo writes a .zit-cache whose .fast_read image load is query-equivalent to 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, and a fork — shape enough to exercise the // leaf table (§7.1), the open predicate (§7.4), and branch-of (§6.1). 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); try repo.seal(); // A fresh checkout is cold: no `.zit-cache` yet, so the first read would pay // the full derive. Eagerly warm it — exactly what runClone does at clone-end. try testing.expect(!cacheExists(&repo, io)); Repository.warmCache(testing.allocator, io, repo.tmp.dir); try testing.expect(cacheExists(&repo, io)); // The next read verb now takes the zero-parse image path (backing == .image), // NOT a cold derive, and its freed queries answer identically to a from- // scratch derive (repo-state §1 memo: derived state is a function of the log). var warm: Repository = undefined; try Repository.open(&warm, testing.allocator, io, repo.tmp.dir, .{ .mode = .fast_read }); defer warm.deinit(testing.allocator); try testing.expect(warm.state().backing == .image); var scratch: Repository = undefined; try Repository.open(&scratch, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true }); defer scratch.deinit(testing.allocator); var ls = scratch.state().leaves(); var lw = warm.state().leaves(); while (ls.next()) |rs| { const rw = lw.next() orelse return error.LeafCountMismatch; try testing.expectEqual(rs, rw); // §7.1: same leaf set and order try testing.expectEqualStrings(scratch.hashOf(rs), warm.hashOf(rw)); // §7.2: same names try testing.expectEqual(scratch.state().isOpenLeaf(rs), warm.state().isOpenLeaf(rw)); // §7.4 try testing.expectEqualStrings(scratch.branchOf(rs), warm.branchOf(rw)); // §6.1 } try testing.expect(lw.next() == null); } test "repository warmCacheFromLog: one open committed log produces a fast-read cache equivalent to a cold derive" { 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); const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root }); defer testing.allocator.free(fork); try repo.log.commit(); try testing.expect(!cacheExists(&repo, io)); Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log); try testing.expect(cacheExists(&repo, io)); repo.log.deinit(testing.allocator); repo.file.close(io); var warm: Repository = undefined; try Repository.open(&warm, testing.allocator, io, repo.tmp.dir, .{ .mode = .fast_read }); defer warm.deinit(testing.allocator); try testing.expect(warm.state().backing == .image); var scratch: Repository = undefined; try Repository.open(&scratch, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true }); defer scratch.deinit(testing.allocator); var ls = scratch.state().leaves(); var lw = warm.state().leaves(); while (ls.next()) |rs| { const rw = lw.next() orelse return error.LeafCountMismatch; try testing.expectEqual(rs, rw); try testing.expectEqualStrings(scratch.hashOf(rs), warm.hashOf(rw)); try testing.expectEqual(scratch.state().isOpenLeaf(rs), warm.state().isOpenLeaf(rw)); try testing.expectEqualStrings(scratch.branchOf(rs), warm.branchOf(rw)); } try testing.expect(lw.next() == null); } test "repository cache extension: an appended log is healed by the next open, equal to a cold derive" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); // Prefix: two check-ins, cache written for exactly this state. 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.log.commit(); Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log); try testing.expect(cacheExists(&repo, io)); // Tail: a fork and a control close appended AFTER the cache -- shapes that // exercise linkage, leaf recheck and tag ingestion in the fold, not just a // linear 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(); // The invariant: an open over (stale cache + grown log) equals a cold // derive over the whole log, view for view. var extended: Repository = undefined; try Repository.open(&extended, testing.allocator, io, repo.tmp.dir, .{}); defer extended.deinit(testing.allocator); var cold: Repository = undefined; try Repository.open(&cold, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true }); defer cold.deinit(testing.allocator); try expectRepoViewsEqual(&cold, &extended); // The extension REWROTE the cache for the grown log, so a further open is // an exact-tail hit serving the same view again. var rehit: Repository = undefined; try Repository.open(&rehit, testing.allocator, io, repo.tmp.dir, .{}); defer rehit.deinit(testing.allocator); try expectRepoViewsEqual(&cold, &rehit); } test "repository cache extension: the fold really starts from the stored body, not a silent re-derive" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); // A body captured over the ONE-check-in prefix... const root = try putCheckin(&repo, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); try repo.log.commit(); var short_body: std.ArrayList(u8) = .empty; defer short_body.deinit(testing.allocator); var short_fp: u32 = undefined; var short_branch: []u8 = undefined; { var ro = try logstore.LogStore.openReadOnly(testing.allocator, repo.fs.storageFile()); defer ro.deinit(testing.allocator); var v: repoview.RepoView = undefined; try v.loadCapturing(testing.allocator, &ro, &short_body); short_branch = try testing.allocator.dupe(u8, v.main_branch); v.deinit(testing.allocator); short_fp = ro.shunFingerprint(); } defer testing.allocator.free(short_branch); // ...stored as covering the TWO-check-in log: a forged cache whose key is // honest about the log but whose body omits `child`. const child = try putCheckin(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = root }); defer testing.allocator.free(child); try repo.log.commit(); var mid_key: logstore.LogStore.LogKey = undefined; { var ro = try logstore.LogStore.openReadOnly(testing.allocator, repo.fs.storageFile()); defer ro.deinit(testing.allocator); mid_key = try ro.logKey(); } repocache.store(testing.allocator, io, repo.tmp.dir, mid_key, short_fp, short_branch, short_body.items, ""); // Grow past the forged coverage and open. const fork = try putCheckin(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = root }); defer testing.allocator.free(fork); try repo.seal(); var extended: Repository = undefined; try Repository.open(&extended, testing.allocator, io, repo.tmp.dir, .{}); defer extended.deinit(testing.allocator); // The fold trusted the CRC-validated body -- the same trust `tryLoad` // extends to an exact hit -- so `child`, absent from the forged body and // below the forged key, is absent from the view. This is the canary that // proves the extension path ran rather than silently re-deriving: a full // derive would hold all three check-ins. try testing.expect(extended.lookup(child) == null); try testing.expect(extended.lookup(root) != null); try testing.expect(extended.lookup(fork) != null); } test "repository cache extension: a shun-table change refuses the fold and takes the full derive" { 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.log.commit(); Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log); // The tail is not just growth: the shun table moved. The cached body was // distilled under the old mask, so extension would carry `child` into a // view that must now read it as absent. The fingerprint mismatch forces // the full derive, which applies the mask. var child_digest: [32]u8 = undefined; _ = std.fmt.hexToBytes(&child_digest, child) catch unreachable; try repo.log.putShun(&child_digest, "test", 0); try repo.seal(); var opened: Repository = undefined; try Repository.open(&opened, testing.allocator, io, repo.tmp.dir, .{}); defer opened.deinit(testing.allocator); var cold: Repository = undefined; try Repository.open(&cold, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true }); defer cold.deinit(testing.allocator); try expectRepoViewsEqual(&cold, &opened); try testing.expect(opened.lookup(child) == null); } /// Leaf-by-leaf equality of two opened repositories: rid, hash, open-ness and /// branch. The oracle the warm-cache test already uses, shared. fn expectRepoViewsEqual(a: *Repository, b: *Repository) !void { var la = a.state().leaves(); var lb = b.state().leaves(); while (la.next()) |ra| { const rb = lb.next() orelse return error.LeafCountMismatch; try testing.expectEqual(ra, rb); try testing.expectEqualStrings(a.hashOf(ra), b.hashOf(rb)); try testing.expectEqual(a.state().isOpenLeaf(ra), b.state().isOpenLeaf(rb)); try testing.expectEqualStrings(a.branchOf(ra), b.branchOf(rb)); } try testing.expect(lb.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); } test "repository manifest: a fossil-legacy check-in with a dangling F-card separator is read, not dropped (§4.2 read interop)" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); // A root to parent the legacy check-in, so it derives as a real check-in. const root = try putCheckin(&repo, .{ .ts = "2008-10-24T00:00:00" }); defer testing.allocator.free(root); // Build a well-formed file-bearing child manifest, then mutate its single F // card into fossil's own 2008 cvs2fossil form: a dangling trailing SP where // a perm token would go (`F <name> <hash> `). §4.2's `token = 1*tchar` has // no production for that empty perm, so strict `parse` rejects it — the very // shape that made `timeline` drop check-ins on the full fossil history. const child_hash = "b" ** 64; var parent_buf = [_]artifact.PCard{.{ .parents = root, .count = 1 }}; const files = [_]artifact.FCard{fcard("a", child_hash)}; var m = baseManifest(.manifest, "2008-10-24T12:00:00", parent_buf[0..1], &.{}); m.f = &files; defer m.deinit(); const good = try artifact.create(&m, testing.allocator); defer testing.allocator.free(good); // Append one SP just before the F line's LF, then re-seal the §6.2 Z // self-checksum over the lengthened content so the artifact stays framed. const f_start = std.mem.indexOf(u8, good, "\nF ").? + 1; const f_lf = std.mem.indexOfScalarPos(u8, good, f_start, '\n').?; const legacy = try testing.allocator.alloc(u8, good.len + 1); defer testing.allocator.free(legacy); @memcpy(legacy[0..f_lf], good[0..f_lf]); legacy[f_lf] = ' '; @memcpy(legacy[f_lf + 1 ..], good[f_lf..]); const zn = legacy.len; var zbuf: [32]u8 = undefined; const zsum = artifact.selfChecksum(legacy[0 .. zn - 35], &zbuf); @memcpy(legacy[zn - 33 .. zn - 1], zsum); // Fixture sanity: this really is the legacy form — strict parse rejects it, // the read-path tolerance accepts it. Guards the regression below against a // vacuous pass if the crafted bytes ever stop being the rejecting form. try testing.expectError(error.SyntaxError, artifact.parse(testing.allocator, legacy)); var im = try artifact.parseForImport(testing.allocator, legacy); im.deinit(testing.allocator); // Store the legacy manifest verbatim (its sha3-256 names it) and open. const legacy_name = try repo.putRaw(legacy); defer testing.allocator.free(legacy_name); try repo.seal(); var facade: Repository = undefined; try openRepo(&facade, &repo, io); defer facade.deinit(testing.allocator); // The regression: reading this check-in's manifest must SUCCEED (pre-fix it // is rejected as error.Corrupt, which `timeline` surfaces as a bogus // "input/output error" while silently dropping the check-in and every older // one). The recovered comment/timestamp prove the tolerant read round-trips. const rid = facade.lookup(legacy_name).?; var om = (try facade.manifest(testing.allocator, rid)) orelse return error.ManifestMissing; defer om.deinit(testing.allocator); try testing.expectEqualStrings("2008-10-24T12:00:00", om.manifest.d.?.timestamp); try testing.expectEqualStrings("c", om.manifest.c.?.comment); } // 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 filesMemo (zit#167): two deltas over one baseline read it once — the memo entry survives, a different baseline replaces it, and the sets equal files()" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); 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 d1_files = [_]artifact.FCard{ fcard("b", hc), fcard("c", hd) }; const d1 = try putFiles(&repo, .{ .ts = "2020-01-02T00:00:00", .parent = base, .baseline = base, .files = &d1_files }); defer testing.allocator.free(d1); const d2_files = [_]artifact.FCard{fcard("a", hd)}; const d2 = try putFiles(&repo, .{ .ts = "2020-01-03T00:00:00", .parent = d1, .baseline = base, .files = &d2_files }); defer testing.allocator.free(d2); // A second baseline, and a delta over it. const base2_files = [_]artifact.FCard{fcard("z", ha)}; const base2 = try putFiles(&repo, .{ .ts = "2020-01-04T00:00:00", .parent = d2, .files = &base2_files }); defer testing.allocator.free(base2); const d3_files = [_]artifact.FCard{fcard("y", hb)}; const d3 = try putFiles(&repo, .{ .ts = "2020-01-05T00:00:00", .parent = base2, .baseline = base2, .files = &d3_files }); defer testing.allocator.free(d3); try repo.seal(); var facade: Repository = undefined; try openRepo(&facade, &repo, io); defer facade.deinit(testing.allocator); var memo: ?*Repository.BaselineMemo = null; defer if (memo) |m| m.deinit(testing.allocator); inline for (.{ d1, d2, d3 }, 0..) |hash, i| { const rid = facade.lookup(hash).?; const before = memo; var via_memo = (try facade.filesMemo(testing.allocator, rid, &memo)) orelse return error.NoFiles; defer via_memo.deinit(testing.allocator); // Borrowed: the list owns no baseline of its own. try testing.expect(via_memo.baseline == null); try testing.expect(memo != null); switch (i) { 0 => try testing.expect(before == null), // first read fills the slot 1 => try testing.expect(before == memo), // same baseline: the entry survives 2 => try testing.expect(before != memo), // new baseline: replaced else => unreachable, } try testing.expectEqualStrings(if (i == 2) base2 else base, memo.?.hash); var plain = (try facade.files(testing.allocator, rid)) orelse return error.NoFiles; defer plain.deinit(testing.allocator); var a: std.ArrayList(artifact.FCard) = .empty; defer a.deinit(testing.allocator); var b: std.ArrayList(artifact.FCard) = .empty; defer b.deinit(testing.allocator); try collectFiles(&via_memo, &a); try collectFiles(&plain, &b); try testing.expectEqual(b.items.len, a.items.len); for (a.items, b.items) |x, y| { try testing.expectEqualStrings(y.filename, x.filename); try testing.expectEqualStrings(y.hash.?, x.hash.?); } } } test "repository files §8.3: a delta over a fossil-legacy baseline (dangling F-card separator) merges, not BaselineUnresolvable (§4.2 read interop)" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); // A derived root, so the delta below is a real, look-up-able check-in. const root = try putCheckin(&repo, .{ .ts = "2008-10-24T00:00:00" }); defer testing.allocator.free(root); // Build a well-formed baseline manifest carrying one F card, then mutate that // card into fossil's own 2008 cvs2fossil form: a dangling trailing SP where a // perm token would go (`F <name> <hash> `). §4.2's `token = 1*tchar` has no // production for that empty perm, so strict `parse` rejects — the shape the // delta baseline path (`files`/merge) fed straight into the strict parser. var m = baseManifest(.manifest, "2008-10-24T06:00:00", &.{}, &.{}); const base_files = [_]artifact.FCard{fcard("a", ha)}; m.f = &base_files; defer m.deinit(); const good = try artifact.create(&m, testing.allocator); defer testing.allocator.free(good); // Append one SP just before the F line's LF, then re-seal the §6.2 Z // self-checksum over the lengthened content so the artifact stays framed. const f_start = std.mem.indexOf(u8, good, "\nF ").? + 1; const f_lf = std.mem.indexOfScalarPos(u8, good, f_start, '\n').?; const legacy = try testing.allocator.alloc(u8, good.len + 1); defer testing.allocator.free(legacy); @memcpy(legacy[0..f_lf], good[0..f_lf]); legacy[f_lf] = ' '; @memcpy(legacy[f_lf + 1 ..], good[f_lf..]); const zn = legacy.len; var zbuf: [32]u8 = undefined; const zsum = artifact.selfChecksum(legacy[0 .. zn - 35], &zbuf); @memcpy(legacy[zn - 33 .. zn - 1], zsum); // Vacuity guard: this really is the rejecting legacy form — strict `parse` // refuses it, the read-path tolerance accepts it. If the crafted bytes ever // stop being the rejecting shape, the regression below would pass vacuously. try testing.expectError(error.SyntaxError, artifact.parse(testing.allocator, legacy)); var im = try artifact.parseForImport(testing.allocator, legacy); im.deinit(testing.allocator); // Store the legacy baseline verbatim (its sha3-256 names it); a delta over it // adds file "b" and names it as its B-card baseline. const base_name = try repo.putRaw(legacy); defer testing.allocator.free(base_name); const delta_files = [_]artifact.FCard{fcard("b", hc)}; const delta = try putFiles(&repo, .{ .ts = "2008-10-24T12:00:00", .parent = root, .baseline = base_name, .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); // The regression: `files` must MERGE the legacy baseline with the delta. // Pre-fix `parseBaseline` runs the strict parser, rejects the legacy baseline // as NotManifest, and `enumerate` raises BaselineUnresolvable (§8.3) — the // baseline's file "a" vanishes. Post-fix the tolerant read keeps it: effective // set is a:ha (from the legacy baseline) and b:hc (the delta's add). 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, 2), got.items.len); try testing.expectEqualStrings("a", got.items[0].filename); try testing.expectEqualStrings(ha, got.items[0].hash.?); // read from the legacy baseline try testing.expectEqualStrings("b", got.items[1].filename); try testing.expectEqualStrings(hc, got.items[1].hash.?); // the delta's own 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); } } } // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 §5 — tree check-ins through the facade // --------------------------------------------------------------------------- // // The Appendix A.2/A.3 vectors, byte-for-byte, stored as real artifacts so the // facade's own store-backed §5.2 resolver does the resolving. §9.3 requires // every consumer of a check-in's file set to obtain it through one shared // enumeration interface; these tests pin that `files` IS that interface for a // tree check-in, with nothing about the tree visible above it. const sm_a2_1_leaf = rfc_vectors.submanifest_a2_1_leaf; const sm_a2_2_leaf = rfc_vectors.submanifest_a2_2_leaf; const sm_a3_manifest = rfc_vectors.submanifest_a3_tree_manifest; /// The Appendix A.1 file set: name and content, in canonical filename order. const sm_a1_files = [_]struct { name: []const u8, content: []const u8 }{ .{ .name = "README.md", .content = "# Example\n" }, .{ .name = "src/main.c", .content = "int main(void){return 0;}\n" }, .{ .name = "src/node11.c", .content = "/* node11 */\n" }, .{ .name = "src/util.c", .content = "/* util */\n" }, }; /// Store the four Appendix A.1 file blobs, so a walked enumeration's F-card /// hashes name real content and the §5.5 R aggregate can be recomputed. fn smPutContents(repo: *Repo) !void { for (sm_a1_files) |f| testing.allocator.free(try repo.putRaw(f.content)); } /// Assemble a hand-written artifact: `body` plus the §6.2 Z self-checksum. /// The writer emits no S cards until a later slice, so a tree manifest that is /// not one of the draft's own vectors is spelled out byte by byte here. fn smSeal(buf: []u8, body: []const u8) []const u8 { @memcpy(buf[0..body.len], body); var digest_buf: [32]u8 = undefined; const digest = artifact.selfChecksum(body, &digest_buf); buf[body.len] = 'Z'; buf[body.len + 1] = ' '; @memcpy(buf[body.len + 2 ..][0..32], digest); buf[body.len + 34] = '\n'; return buf[0 .. body.len + 35]; } test "repository submanifests §7.4: a second check-in reuses the sub-manifests the first parsed" { // §7.4: "unchanged subtrees are shared by hash across check-ins, so a // memoizing rebuild parses each distinct sub-manifest once ... A naive // implementation obtains the byte reduction of this format but not the // parse-count reduction that motivated it." // // Two DISTINCT tree check-ins over the same two leaves — the shared-subtree // case the section is about. `Repository` is the enumeration facade §9.3 // routes every file-set consumer through, so this is the production path: // the merge in `cmd/checkout` enumerates three check-ins from one open // repository exactly like this. const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try smPutContents(&repo); testing.allocator.free(try repo.putRaw(sm_a2_1_leaf)); testing.allocator.free(try repo.putRaw(sm_a2_2_leaf)); const tree_a = try repo.putRaw(sm_a3_manifest); defer testing.allocator.free(tree_a); // A second tree manifest naming the SAME two sub-manifests: identical S run, // different C card, so it is a different artifact over an unchanged tree. var body: std.ArrayList(u8) = .empty; defer body.deinit(testing.allocator); var it = std.mem.splitScalar(u8, sm_a3_manifest, '\n'); while (it.next()) |line| { if (line.len == 0) continue; if (line[0] == 'Z') continue; // resealed below if (line[0] == 'C') { try body.appendSlice(testing.allocator, "C second\n"); continue; } try body.appendSlice(testing.allocator, line); try body.append(testing.allocator, '\n'); } var seal_buf: [4096]u8 = undefined; const tree_b_bytes = smSeal(&seal_buf, body.items); const tree_b = try repo.putRaw(tree_b_bytes); defer testing.allocator.free(tree_b); try repo.seal(); var facade: Repository = undefined; // Bypass: a plain open's checkpoint auto-creation enumerates the newest // check-in's files itself, pre-warming the memo this test counts from // zero — the bypass keeps the two calls below the only enumerations. try Repository.open(&facade, testing.allocator, io, repo.tmp.dir, .{ .bypass_cache = true }); defer facade.deinit(testing.allocator); var first = (try facade.files(testing.allocator, facade.lookup(tree_a).?)) orelse return error.NoFiles; defer first.deinit(testing.allocator); const memo = &facade.sub_memo.?; const distinct = memo.misses; try testing.expect(distinct >= 2); // both leaves were parsed for the first tree try testing.expectEqual(@as(usize, 0), memo.hits); var second = (try facade.files(testing.allocator, facade.lookup(tree_b).?)) orelse return error.NoFiles; defer second.deinit(testing.allocator); // The second enumeration parsed NO new sub-manifest: every one it needed was // already resolved, which is precisely the parse-count reduction §7.4 asks // for and a naive implementation does not get. try testing.expectEqual(distinct, memo.misses); try testing.expect(memo.hits >= distinct); try testing.expect(!memo.degraded); // …and the answer is identical, so the memo is transparent (the DST asserts // this per seed; here it is asserted on the real façade). var a_files: std.ArrayList(artifact.FCard) = .empty; defer a_files.deinit(testing.allocator); var b_files: std.ArrayList(artifact.FCard) = .empty; defer b_files.deinit(testing.allocator); try collectFiles(&first, &a_files); try collectFiles(&second, &b_files); try testing.expectEqual(a_files.items.len, b_files.items.len); for (a_files.items, b_files.items) |x, y| try testing.expectEqualStrings(x.filename, y.filename); } test "repository submanifests §5.1/§9.3: a tree check-in enumerates its complete file set" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try smPutContents(&repo); testing.allocator.free(try repo.putRaw(sm_a2_1_leaf)); testing.allocator.free(try repo.putRaw(sm_a2_2_leaf)); const tree = try repo.putRaw(sm_a3_manifest); defer testing.allocator.free(tree); 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(tree).?)) 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); // §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c, // src/node11.c, src/util.c: the same list a flat manifest would carry." try testing.expectEqual(sm_a1_files.len, got.items.len); for (sm_a1_files, got.items) |want, f| { try testing.expectEqualStrings(want.name, f.filename); try testing.expect(f.hash != null); // §4.4: every reachable F card carries a hash } } test "repository submanifests §§5.1–5.2: LogStore and Repository flatten the same tree" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try smPutContents(&repo); testing.allocator.free(try repo.putRaw(sm_a2_1_leaf)); testing.allocator.free(try repo.putRaw(sm_a2_2_leaf)); const tree_name = try repo.putRaw(sm_a3_manifest); defer testing.allocator.free(tree_name); var direct = try artifact.parse(testing.allocator, sm_a3_manifest); defer direct.deinit(); try Repository.flattenTreeFromLog(testing.allocator, &repo.log, &direct); try testing.expectEqual(sm_a1_files.len, direct.flattened.?.len); try repo.seal(); var facade: Repository = undefined; try openRepo(&facade, &repo, io); defer facade.deinit(testing.allocator); var listed = (try facade.files(testing.allocator, facade.lookup(tree_name).?)) orelse return error.NoFiles; defer listed.deinit(testing.allocator); var flattened: std.ArrayList(artifact.FCard) = .empty; defer flattened.deinit(testing.allocator); try collectFiles(&listed, &flattened); try testing.expectEqual(direct.flattened.?.len, flattened.items.len); for (direct.flattened.?, flattened.items) |left, right| { try testing.expectEqualStrings(left.filename, right.filename); try testing.expectEqualStrings(left.hash.?, right.hash.?); } } test "repository submanifests §5.2: missing LogStore target remains a tree fault" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); defer { repo.log.deinit(testing.allocator); repo.file.close(io); } try smPutContents(&repo); testing.allocator.free(try repo.putRaw(sm_a2_1_leaf)); var tree = try artifact.parse(testing.allocator, sm_a3_manifest); defer tree.deinit(); try testing.expectError( error.SubUnresolvable, Repository.flattenTreeFromLog(testing.allocator, &repo.log, &tree), ); try testing.expect(tree.flattened == null); } test "repository submanifests §9.2/§7.3: a persisted SUBMANIFEST is excluded from the crosslink sweep" { // §7.3: "A SUBMANIFEST artifact has no direct crosslink effect: it carries // no state-bearing card and contributes to repository state only through the // manifests whose trees contain it." §9.2 turns that into a requirement on // the sweep itself — exclude SUBMANIFEST artifacts, "otherwise it re-selects // and re-parses them on every pass, forever." // // zit's sweep (`repoview.linkOne`) visits every persisted frame, so the // exclusion is the ONLY thing standing between a repository holding a leaf // sub-manifest and a derive that treats a D-less artifact as a check-in. // Remove the `sweepDisposition` guard and this open returns `Corrupt`: a // SUBMANIFEST has no D card, and a check-in without a parseable D-card // timestamp is structurally corrupt. The whole repository stops opening. const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try smPutContents(&repo); const leaf_1 = try repo.putRaw(sm_a2_1_leaf); defer testing.allocator.free(leaf_1); const leaf_2 = try repo.putRaw(sm_a2_2_leaf); defer testing.allocator.free(leaf_2); const tree = try repo.putRaw(sm_a3_manifest); defer testing.allocator.free(tree); try repo.seal(); var facade: Repository = undefined; try openRepo(&facade, &repo, io); // Corrupt without the exclusion defer facade.deinit(testing.allocator); // The tree manifest DID crosslink: it carries a D card and is a check-in, // so the exclusion is proven discriminating, not a blanket skip. const tree_rid = facade.lookup(tree) orelse return error.TreeNotCrosslinked; try testing.expect(facade.view.state.mtimeOf(tree_rid) != null); // Neither sub-manifest produced a derived row of its own. `lookup` answers // over the derived view's identity space, so a null IS "no row" — the // negative space a §9.2-conforming sweep leaves behind, and the reason a // negative-space sweep would re-select them forever. try testing.expect(facade.lookup(leaf_1) == null); try testing.expect(facade.lookup(leaf_2) == null); // …while the tree they belong to still enumerates through them (§7.3's // "contributes to repository state only through the manifests whose trees // contain it"), so the exclusion cost the file set nothing. var list = (try facade.files(testing.allocator, tree_rid)) 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(sm_a1_files.len, got.items.len); } test "repository submanifests §5.5: a tree check-in's R card verifies over the walked enumeration" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try smPutContents(&repo); testing.allocator.free(try repo.putRaw(sm_a2_1_leaf)); testing.allocator.free(try repo.putRaw(sm_a2_2_leaf)); const tree = try repo.putRaw(sm_a3_manifest); defer testing.allocator.free(tree); try repo.seal(); var facade: Repository = undefined; try openRepo(&facade, &repo, io); defer facade.deinit(testing.allocator); // §A.3 states the value: "MD5 of these 117 bytes = // 152bc688378069a3c22a1a4c55bc8b1a, which matches the R card." try testing.expectEqual( @as(?bool, true), try facade.verifyChecksum(testing.allocator, facade.lookup(tree).?), ); } test "repository submanifests §5.2: a tree check-in with an absent sub-manifest fails hard" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); // Only the first leaf is stored. §5.2: "An implementation MUST NOT present // a partially resolved tree as a complete file set" — so this is a fault, // not a two-file enumeration. (The deferred-record retry path of §5.2 and // §9.4 is post-receive processing, a later slice.) try smPutContents(&repo); testing.allocator.free(try repo.putRaw(sm_a2_1_leaf)); const tree = try repo.putRaw(sm_a3_manifest); defer testing.allocator.free(tree); 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(tree).?), ); } test "repository submanifests §3.6: an S card naming a non-SUBMANIFEST fails the walk" { const io = testing.io; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); // §3.6: "The referenced artifact MUST be of type SUBMANIFEST; a reference // to any other type MUST cause the tree walk to fail." The S card here // names a plain file blob, which is stored but is not an artifact at all. const blob = try repo.putRaw("# Example\n"); defer testing.allocator.free(blob); var body_buf: [256]u8 = undefined; var art_buf: [256]u8 = undefined; const body = try std.fmt.bufPrint( &body_buf, "C tree\nD 2020-01-01T00:00:00\nR d41d8cd98f00b204e9800998ecf8427e\nS README.md {s}\nU u\n", .{blob}, ); const tree = try repo.putRaw(smSeal(&art_buf, body)); defer testing.allocator.free(tree); 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(tree).?), ); } fn costShapeEntries(arena: std.mem.Allocator, n: usize, hash_digit: u8) ![]artifact.FCard { std.debug.assert(hash_digit >= '0'); std.debug.assert(hash_digit <= '9'); var entries: std.ArrayList(artifact.FCard) = .empty; for (0..n) |i| { const filename = try std.fmt.allocPrint(arena, "src/file{d:0>5}.zig", .{i}); const hash = try arena.alloc(u8, 64); @memset(hash, hash_digit); try entries.append(arena, .{ .filename = filename, .hash = hash, .perm = null, .old_name = null, .mode = .regular, }); } return entries.items; } test "repository submanifests §6.3: cost-shape digest ignores same-width content hashes" { // draft-fossil-submanifests-00 §6.3 defines C(k) solely from serialized // leaf and leaf-naming S-card bytes. Equal-width content hashes therefore // leave every candidate cost unchanged; the digest must do the same. const gpa = testing.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = try costShapeEntries(arena, 400, '1'); const after = try costShapeEntries(arena, 400, '2'); var before_digest: submanifest.CostShapeDigest = undefined; var after_digest: submanifest.CostShapeDigest = undefined; try testing.expectEqualSlices( u8, submanifest.costShapeDigest(before, &before_digest), submanifest.costShapeDigest(after, &after_digest), ); try testing.expectEqual(@as(usize, 32), before_digest.len); try testing.expectEqual(@as(usize, 32), after_digest.len); var k: u4 = artifact.min_k; while (true) : (k += 1) { const before_cost = try submanifest.cost(gpa, before, k, .sha3_256); const after_cost = try submanifest.cost(gpa, after, k, .sha3_256); try testing.expectEqualDeep(before_cost, after_cost); if (k == artifact.max_k) { break; } } } test "repository submanifests §6.3: cost-shape digest frames every cost input" { // draft-fossil-submanifests-00 §6.3 candidate construction depends on // filename partitioning and serialized F-card widths. This local cache key // must invalidate whenever an input changes; framing prevents aliases. const hash_64 = "0" ** 64; const hash_40 = "0" ** 40; const base = [_]artifact.FCard{.{ .filename = "src/a.zig", .hash = hash_64, .perm = null, .old_name = null, .mode = .regular, }}; var base_digest: submanifest.CostShapeDigest = undefined; const base_bytes = submanifest.costShapeDigest(&base, &base_digest); const cases = [_]artifact.FCard{ .{ .filename = "src/b.zig", .hash = hash_64, .perm = null, .old_name = null, .mode = .regular, }, .{ .filename = "src/a.zig", .hash = hash_64, .perm = "x", .old_name = null, .mode = .regular, }, .{ .filename = "src/a.zig", .hash = hash_64, .perm = null, .old_name = "src/old.zig", .mode = .regular, }, .{ .filename = "src/a.zig", .hash = hash_40, .perm = null, .old_name = null, .mode = .regular, }, }; for (cases) |entry| { var digest: submanifest.CostShapeDigest = undefined; const candidate = submanifest.costShapeDigest(&.{entry}, &digest); try testing.expect(!std.mem.eql(u8, base_bytes, candidate)); try testing.expectEqual(@as(usize, 32), digest.len); } } test "repository content channel: the scan index is written on miss, adopted on the next open, and never consulted under bypass" { const io = testing.io; const gpa = testing.allocator; var repo: Repo = undefined; try repo.init(io); defer repo.cleanup(); try repo.config("trunk"); const blob_hash = try repo.putRaw("file body\n"); defer testing.allocator.free(blob_hash); const tip = try putFiles(&repo, .{ .ts = "2020-01-01T00:00:00", .files = &.{fcard("a.txt", blob_hash)} }); defer testing.allocator.free(tip); try repo.seal(); const index_exists = struct { fn f(aio: std.Io, dir: std.Io.Dir) bool { var fl = dir.openFile(aio, ".zit-cache/index", .{}) catch return false; fl.close(aio); return true; } }.f; // The first open already persists the index: checkpoint auto-creation's // file-set fingerprint pays the content scan during the full derive. var r1: Repository = undefined; try openRepo(&r1, &repo, io); try testing.expect(index_exists(io, repo.tmp.dir)); const b1 = (try r1.blob(gpa, blob_hash)).?; defer gpa.free(b1); try testing.expect(index_exists(io, repo.tmp.dir)); r1.deinit(gpa); // The next open adopts the index; every answer is identical. var r2: Repository = undefined; try openRepo(&r2, &repo, io); const b2 = (try r2.blob(gpa, blob_hash)).?; defer gpa.free(b2); try testing.expectEqualSlices(u8, b1, b2); r2.deinit(gpa); // A corrupted index is a silent miss: the answer is still right, and the // rewrite-on-miss replaces it with a valid one. { var f = try repo.tmp.dir.createFile(io, ".zit-cache/index", .{ .truncate = true }); try f.writePositionalAll(io, "garbage over the index", 0); f.close(io); } var r3: Repository = undefined; try openRepo(&r3, &repo, io); const b3 = (try r3.blob(gpa, blob_hash)).?; defer gpa.free(b3); try testing.expectEqualSlices(u8, b1, b3); r3.deinit(gpa); // --no-cache: same answer, and the sidecar is neither read nor rewritten // (delete it and confirm bypass leaves it absent). repo.tmp.dir.deleteFile(io, ".zit-cache/index") catch {}; var r4: Repository = undefined; try Repository.open(&r4, gpa, io, repo.tmp.dir, .{ .bypass_cache = true }); const b4 = (try r4.blob(gpa, blob_hash)).?; defer gpa.free(b4); try testing.expectEqualSlices(u8, b1, b4); r4.deinit(gpa); try testing.expect(!index_exists(io, repo.tmp.dir)); } test "repository open: the store cone never perturbs the derived view or its cache" { // Sparse-cone RFC section 7 (corrected during implementation): the // derived record is cone-independent -- elision is computed at read // time, never folded into the record -- so planting or removing a store // cone must not invalidate the cache OR change any derived answer. The // round-trip of the member itself is pinned here too. 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.log.commit(); Repository.warmCacheFromLog(testing.allocator, io, repo.tmp.dir, &repo.log); repo.log.deinit(testing.allocator); var before: Repository = undefined; try openRepo(&before, &repo, io); var lb = before.state().leaves(); const tip_rid = lb.next().?; try testing.expect(lb.next() == null); before.deinit(testing.allocator); var cone = try cone_mod.Cone.init(testing.allocator, &.{"a"}, &.{}); defer cone.deinit(testing.allocator); repocache.writeStoreCone(testing.allocator, io, repo.tmp.dir, &cone); var after: Repository = undefined; try openRepo(&after, &repo, io); var la = after.state().leaves(); try testing.expectEqual(tip_rid, la.next().?); try testing.expect(la.next() == null); after.deinit(testing.allocator); repocache.writeStoreCone(testing.allocator, io, repo.tmp.dir, null); var dense_again: Repository = undefined; try openRepo(&dense_again, &repo, io); defer dense_again.deinit(testing.allocator); var ld = dense_again.state().leaves(); try testing.expectEqual(tip_rid, ld.next().?); try testing.expect(ld.next() == null); }