Artifact 215ceb1de82a83c851801fef6c8bd86aa17d0cafc6530150f3d890f3ebb7400e:
- File
src/repoview.zig
— part of check-in
[37f746e59f]
at
2026-08-07 17:49:07
on branch sub-manifest
— repoview: name the artifact a failed derive rejected
'repository is corrupt' says a repository cannot be read, not what in it is unreadable -- and the walk that discovers it visits every artifact in the store, minutes on a large one. Whoever holds the broken repository was left bisecting it or attaching a debugger.
The derive now records which artifact it rejected and why; openRepo reports it, so every verb's open gets the name. Plain data, no allocation, so it survives the errdefer that tears down a half-built view. Repository.open clears it per attempt because callers declare a Repository undefined.
Also splits open's two identical diagnostics: deriving the view and reopening the log for materialization both printed the same line, which made a failure in one indistinguishable from the other. (#42) (user: dmestas, size: 58776) [annotate] [blame] [check-ins using]
//! zit — the shared repo-view loader (a `zit` library module, re-exported from //! root.zig and used internally by `Repository`). //! //! It bridges the persisted `LogStore` (bytes) to the derived `RepoState` //! (branches, leaves, tags, current-trunk) that the query verbs — `leaves` //! first, then `branch`/`tag`/`info` — read through the `Repository` facade. //! One `load`, one `deinit`: a caller builds the whole crosslinked view in a //! single call and asks its questions of `RepoState` directly, never //! re-deriving. //! //! Determinism seam: this module touches no clock, RNG, filesystem, or network. //! It operates on an already-open `LogStore` (whose I/O capability was injected //! at construction), so the effectful file handling stays in the CLI layer. const std = @import("std"); const artifact = @import("artifact.zig"); const repostate = @import("repostate.zig"); const repoimage = @import("repoimage.zig"); const logstore = @import("logstore.zig"); /// Lowercase-hex identity space. The `RepoState`/`MemStore` identity for a /// check-in is its 40/64-char artifact name — the lowercase hex of the log's /// binary digest. P-card parents and T-card targets are already that hex on the /// wire, so encoding `LogStore.hashOf` to hex puts every reference in one space. const hex_digits = "0123456789abcdef"; /// The RFC repo-state §6.1 default-branch fallback: `main-branch` is used only /// when the config setting is unset. Never the branch of a check-in that /// carries its own active branch tag. const default_branch: []const u8 = "trunk"; /// A fully crosslinked view of a repository, ready for the derived queries of /// repo-state §6-9. Pinned in place by the caller: `state` holds an /// `ArtifactStore` that borrows `&self.store`, so the value must not be moved /// after `load`. pub const RepoView = struct { /// Identity space only: interned hashes (hex names), no content bytes. Leaf /// derivation reads no content, so the store stays a pure hash↔rid map. store: repostate.MemStore, /// The derived state every query reads: leaf table, branch tags, closed /// markers, event times. Borrows `&self.store`. state: repostate.RepoState, /// The live config frame, retained so `main_branch` can borrow its bytes. /// null when the log carries no config (repo-state §6.1: caller owns the /// default). owned_config: ?logstore.OwnedConfig, /// The repo-state §6.1 default-branch name: the config `main-branch` value /// when set and non-empty, else `"trunk"`. Threaded into `branchOf`; never a /// hardcoded literal in the presence of a setting. main_branch: []const u8, /// Set only on the cache-hit path (`buildFromRecordBody`), where there is no /// `owned_config` to borrow the name from: an owned copy of `main_branch` /// that `deinit` frees. Null on the normal load path, where `main_branch` /// borrows `owned_config` (or is the `"trunk"` literal). owned_main_branch: ?[]u8 = null, /// Set only on the zero-parse image path (`buildFromImage`): the owned cache /// buffer whose image section `state`'s columns view. Freed by `deinit` /// after `state` (which borrows into it) is torn down. Null on every derive /// path, where the state owns its own hashmaps. image_bytes: ?[]u8 = null, /// Set by the derive when it rejects an artifact; read only after a failed /// `load`/`open`. `Repository.open` clears it before every attempt, because /// a `Repository` is declared `undefined` and this field would otherwise /// carry whatever was on the stack. fault: ?Fault = null, /// Every failure `load` can surface: allocation, a storage device error, or /// a structurally corrupt check-in (a manifest/control with no parseable /// timestamp). A closed set so the CLI can switch exhaustively. pub const LoadError = error{ OutOfMemory, Storage, Corrupt }; /// WHICH artifact the derive rejected, and why. /// /// `error.Corrupt` alone says a repository cannot be read; it does not say /// what in it is unreadable, and the walk that discovers this runs over /// every artifact in the store — minutes on a large one. Without the name, /// the only way to find the offending artifact is to bisect the store or /// attach a debugger, neither of which is available to whoever is holding /// the broken repository. /// /// Plain data with no allocation, so it survives the `errdefer` that tears /// down a partially built view: the caller reads it after `open` has already /// failed, at which point nothing else on the view is valid. pub const Fault = struct { /// Lowercase-hex artifact name, as `hexName` renders it. name: [64]u8 = [_]u8{0} ** 64, name_len: u8 = 0, reason: Reason, pub const Reason = enum { /// A manifest or control artifact with no D card at all. missing_timestamp, /// A D card whose timestamp `parseMtime` refuses. unparsable_timestamp, }; pub fn artifactName(self: *const Fault) []const u8 { return self.name[0..self.name_len]; } }; /// Every failure `buildFromRecordBody` can surface: allocation, or a record /// body whose framing is malformed (a length that runs past the buffer, a /// bad tag/kind byte). A malformed body is `Corrupt` so the cache layer /// treats it as a miss and rebuilds — never a trusted partial view. pub const BuildError = error{ OutOfMemory, Corrupt }; /// Crosslink every persisted check-in and control artifact into a derived /// `RepoState`. `self` is initialized in place; `log` is borrowed for the /// duration and still owned by the caller. Transactional: on success the /// caller owns the view and must `deinit` it; on any error `load` tears down /// everything it built, so the caller neither can nor should `deinit`. /// /// Sequence: read the config (resolving `main_branch`), then walk the frames /// in persisted order. A frame whose bytes are not a structural artifact (a /// file blob) is skipped; a check-in manifest is crosslinked with its /// parents and tags; a control artifact's tags are ingested; every other /// artifact kind has no effect on the check-in DAG. pub fn load(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore) LoadError!void { return self.loadInner(gpa, log, null); } /// Identical to `load`, but also serializes every crosslink input record /// (in persisted order) into `sink` — the exact byte body a `.zit-cache` /// stores. Replaying that body through `buildFromRecordBody` reconstructs a /// byte-identical view without touching the log, which is what makes a cache /// hit provably equal to a from-scratch derivation: the same records feed /// the same `crosslink`/`ingestControlTags` calls in the same order. `sink` /// is caller-owned; on failure the view tears itself down and `sink` (which /// may hold a partial body) is the caller's to discard. pub fn loadCapturing(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: *std.ArrayList(u8)) LoadError!void { return self.loadInner(gpa, log, sink); } /// Sets §6.1's main-branch name on the view AND on the state that answers /// branch queries. One setter because they are one fact: `branchOf` reads /// the view's copy through its parameter while `branchValueOf` reads the /// state's field, and a check-in with no active branch tag resolves through /// whichever one the caller happened to take. Assigning them separately is /// how they drift, and drift here means the display and the §7.2 leaf test /// disagree about which branch an untagged check-in is on. fn setMainBranch(self: *RepoView, name: []const u8) void { std.debug.assert(name.len != 0); self.main_branch = name; self.state.main_branch = name; } fn loadInner(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, sink: ?*std.ArrayList(u8)) LoadError!void { self.store = repostate.MemStore.init(gpa); self.state = repostate.RepoState.init(gpa, self.store.store()); self.owned_config = null; self.owned_main_branch = null; self.image_bytes = null; self.setMainBranch(default_branch); // Self-clean on failure: a partially built view frees itself so the // caller's contract is "deinit only a view that loaded". errdefer { if (self.owned_config) |*oc| oc.deinit(gpa); self.state.deinit(); self.store.deinit(); } self.owned_config = try log.readConfig(gpa); if (self.owned_config) |oc| { // An empty stored value is treated as unset (defensive: commit // always writes a real name), so `main_branch` is never empty and // `branchOf`'s `len != 0` precondition always holds. if (oc.config.default_branch.len != 0) self.setMainBranch(oc.config.default_branch); } std.debug.assert(self.main_branch.len != 0); const rids = try log.ridsInPersistedOrder(gpa); defer gpa.free(rids); // A bounded plaintext memo for this one sweep: bases persist strictly // before their deltas, so resolving rids in persisted order keeps each // base hot and collapses delta-chain read-amplification to ~1x. Costs // nothing on an all-raw log (raw content is never cached). var cache = logstore.PlaintextCache.init(); defer cache.deinit(gpa); // Bounded by the persisted artifact count; each rid is visited once. for (rids) |rid| try self.linkOne(gpa, log, rid, &cache, sink); } /// Reconstruct a view from a `.zit-cache` record body (as produced by /// `loadCapturing`) plus the resolved `main_branch`, WITHOUT reading the /// log. Replays each record in stored order through the same /// `crosslink`/`ingestControlTags` procedure the live load uses, so the /// resulting `RepoState`/`MemStore` are byte-identical to a from-scratch /// derivation over the same log prefix. `body` and `main_branch` borrow the /// caller's cache buffer only for the duration of the call (every hash, tag /// name, and value is duped into the store), so the buffer may be freed on /// return. Transactional like `load`: on any error the view is torn down. pub fn buildFromRecordBody(self: *RepoView, gpa: std.mem.Allocator, body: []const u8, main_branch: []const u8) BuildError!void { std.debug.assert(main_branch.len != 0); self.store = repostate.MemStore.init(gpa); self.state = repostate.RepoState.init(gpa, self.store.store()); self.owned_config = null; self.owned_main_branch = null; self.image_bytes = null; self.setMainBranch(default_branch); errdefer { if (self.owned_main_branch) |b| gpa.free(b); self.state.deinit(); self.store.deinit(); } self.owned_main_branch = try gpa.dupe(u8, main_branch); self.setMainBranch(self.owned_main_branch.?); var r: RecordReader = .{ .body = body }; // Bounded: each record consumes at least one byte, so the loop runs at // most `body.len` times and every decode strictly advances `pos`. while (!r.atEnd()) { const before = r.pos; try self.replayRecord(gpa, &r); std.debug.assert(r.pos > before); } } /// Reconstruct a view from a `.zit-cache` image section (as produced by /// `repoimage.serialize`) with ZERO derivation. On success `self` takes /// ownership of `buf` (the whole cache buffer) — the loaded image's columns /// are offset windows into `image`, a sub-slice of `buf`, so the buffer must /// outlive the view. A malformed image is `Corrupt`, which the cache layer /// treats as a miss and rebuilds; on any error `self` is torn down and `buf` /// is NOT taken (the caller frees it). Transactional like `buildFromRecordBody`. pub fn buildFromImage(self: *RepoView, gpa: std.mem.Allocator, buf: []u8, image: []const u8, main_branch: []const u8) BuildError!void { std.debug.assert(main_branch.len != 0); const loaded = (repoimage.Image.load(gpa, image) catch return error.OutOfMemory) orelse return error.Corrupt; self.store = repostate.MemStore.init(gpa); self.state = repostate.RepoState.fromImage(gpa, self.store.store(), loaded); // state.img now owns `loaded` self.owned_config = null; self.owned_main_branch = null; self.image_bytes = null; self.setMainBranch(default_branch); errdefer { self.state.deinit(); // frees the image's owned leaf-rid slice self.store.deinit(); } self.owned_main_branch = try gpa.dupe(u8, main_branch); self.setMainBranch(self.owned_main_branch.?); self.image_bytes = buf; // ownership transferred; freed in deinit after state } /// Frees the config frame and the derived state. Call only on a view that /// `load` returned successfully — a failed `load` already tore itself down. /// `state` borrows `store` (and, in the image backing, borrows `image_bytes`), /// so it is torn down first, then the owned cache buffer. pub fn deinit(self: *RepoView, gpa: std.mem.Allocator) void { if (self.owned_config) |*oc| oc.deinit(gpa); if (self.owned_main_branch) |b| gpa.free(b); self.state.deinit(); self.store.deinit(); if (self.image_bytes) |b| gpa.free(b); self.* = undefined; } /// The lowercase-hex artifact name of `rid` in this view's identity space — /// what a verb prints and compares against the current checkout. `rid` is a /// `RepoState` rid (the `MemStore` space), not a `LogStore` rid. pub fn hashOf(self: *RepoView, rid: repostate.Rid) []const u8 { if (self.state.img) |*img| return img.hashOf(rid); return self.store.store().hashOf(rid); } /// The `RepoState` rid for a lowercase-hex artifact name, or null when the /// name was never interned in this view. A verb resolves the current /// checkout's version this way to mark the corresponding leaf (§7.5). pub fn lookup(self: *RepoView, hash: []const u8) ?repostate.Rid { if (self.state.img) |*img| return img.lookup(hash); return self.store.store().lookup(hash); } /// Crosslink or ingest one persisted frame. A parse failure means the bytes /// are not a structural artifact (a file blob) — skipped, not an error, /// matching the "crosslink whatever parses" model. Allocation failure /// propagates. fn linkOne(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, rid: logstore.Rid, cache: *logstore.PlaintextCache, sink: ?*std.ArrayList(u8)) LoadError!void { // Null ⇒ nothing to crosslink: a phantom (no frame) or an artifact in the // repository shun table (frame on disk, masked by `contentCached`). The // second is deliberate and consequential — a shunned check-in manifest // contributes no timeline event, no leaf and no tag, i.e. it leaves the // derived view exactly as if the repository never held it, which is what // "reads as ABSENT" has to mean for a view. const bytes = (try log.contentCached(gpa, rid, cache)) orelse return; defer gpa.free(bytes); // Read path: tolerate the legacy F-card trailing-separator quirk in // fossil-origin history (artifact.parseForImport), never on the writer. var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return, // not a structural artifact — a file blob, skipped }; defer im.deinit(gpa); const m = &im.manifest; // Only check-in manifests and control artifacts affect the check-in DAG // and its tags; clusters, wiki, tickets, technotes, and attachments do // not. // // draft-fossil-submanifests-00 §9.2/§7.3 — the admission rule is stated // ONCE, over the artifact type, in `repostate.sweepDisposition`. §7.3: a // SUBMANIFEST "has no direct crosslink effect ... it carries no // state-bearing card and contributes to repository state only through // the manifests whose trees contain it". Reading the rule from there // rather than restating it here is what §9.2 is asking for: this sweep // covers every persisted frame, and a hand-written condition is exactly // where a new type silently acquires the wrong disposition. The switch // is exhaustive, so adding an artifact type cannot compile without // deciding this. if (repostate.sweepDisposition(m.kind) != .crosslink) return; try self.link(gpa, log, rid, m, sink); } /// Record which artifact the derive is rejecting, then fail. Returns the /// error so callers read `return self.faulted(...)` — the name is captured /// on the same line that gives up, which is the only way it stays accurate /// as new rejection sites are added. fn faulted(self: *RepoView, hash: []const u8, reason: Fault.Reason) error{Corrupt} { var f: Fault = .{ .reason = reason }; const n = @min(hash.len, f.name.len); @memcpy(f.name[0..n], hash[0..n]); f.name_len = @intCast(n); self.fault = f; return error.Corrupt; } /// Feed one manifest or control artifact into `RepoState`. Both carry a hash, /// an event timestamp, and tag cards; a manifest additionally carries its /// parent references (crosslinked into the DAG), while a control's hash gains /// no event — only its tags are ingested (repo-state §4.2, §7). fn link(self: *RepoView, gpa: std.mem.Allocator, log: *logstore.LogStore, rid: logstore.Rid, m: *const artifact.Manifest, sink: ?*std.ArrayList(u8)) LoadError!void { var hex_buf: [64]u8 = undefined; const hash = hexName(log.hashOf(rid), &hex_buf); // A check-in or control artifact without a parseable D-card timestamp is // structurally corrupt — no silent skip of a malformed check-in. const d = m.d orelse return self.faulted(hash, .missing_timestamp); const mtime = repostate.parseMtime(d.timestamp) orelse return self.faulted(hash, .unparsable_timestamp); var tags: std.ArrayList(repostate.TagOp) = .empty; defer tags.deinit(gpa); // Bounded by the manifest's own T-card count. for (m.t) |tc| try tags.append(gpa, tagOpOf(tc)); switch (m.kind) { .manifest => { var parents: std.ArrayList([]const u8) = .empty; defer parents.deinit(gpa); // Bounded by the manifest's own P-card token count; each `next` // strictly shrinks the iterator. for (m.p) |pc| { var it = pc.hashes(); while (it.next()) |parent| try parents.append(gpa, parent); } if (sink) |s| try encodeRecord(gpa, s, .manifest, hash, mtime, parents.items, tags.items); _ = try self.state.crosslink(.{ .hash = hash, .mtime = mtime, .parents = parents.items, .tags = tags.items, }); }, .control => { if (sink) |s| try encodeRecord(gpa, s, .control, hash, mtime, &[_][]const u8{}, tags.items); _ = try self.state.ingestControlTags(hash, mtime, tags.items); }, else => unreachable, // linkOne admits only manifest/control } } /// Decode and replay the next record at `r`, driving the same derivation the /// live path drives. A manifest record crosslinks; a control record ingests /// tags. Every wire length is validated against the body bound before it is /// used to slice (TigerStyle: a claimed-huge length is `Corrupt`, never an /// out-of-bounds read), and the temporary parent/tag slices borrow the body, /// which the crosslink step dupes. fn replayRecord(self: *RepoView, gpa: std.mem.Allocator, r: *RecordReader) BuildError!void { const kind_byte = try r.byte(); const hash = try r.lenPrefixed(u16); if (hash.len == 0) return error.Corrupt; // a record hash is never empty const mtime = try r.int(u64); const parent_count = try r.int(u32); var parents: std.ArrayList([]const u8) = .empty; defer parents.deinit(gpa); // Bounded: `parent_count` slices are each ≥ 2 body bytes, so the reader // exhausts the buffer before an oversized count can over-allocate. var pi: u32 = 0; while (pi < parent_count) : (pi += 1) { const p = try r.lenPrefixed(u16); if (p.len == 0) return error.Corrupt; try parents.append(gpa, p); } const tag_count = try r.int(u32); var tags: std.ArrayList(repostate.TagOp) = .empty; defer tags.deinit(gpa); var ti: u32 = 0; while (ti < tag_count) : (ti += 1) { const tt: repostate.TagType = switch (try r.byte()) { 0 => .cancel, 1 => .singleton, 2 => .propagate, else => return error.Corrupt, }; const name = try r.lenPrefixed(u16); if (name.len == 0) return error.Corrupt; const target: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u16) else null; const value: ?[]const u8 = if (try r.byte() != 0) try r.lenPrefixed(u32) else null; try tags.append(gpa, .{ .type = tt, .name = name, .target = target, .value = value }); } switch (kind_byte) { 0 => _ = self.state.crosslink(.{ .hash = hash, .mtime = mtime, .parents = parents.items, .tags = tags.items }) catch |e| return e, 1 => _ = self.state.ingestControlTags(hash, mtime, tags.items) catch |e| return e, else => return error.Corrupt, } } }; /// The kind byte a record carries: 0 = a check-in manifest (crosslinked with /// its parents), 1 = a control artifact (tags only). Matches `artifact.Kind`'s /// two structural cases the loader admits. const RecordKind = enum(u8) { manifest = 0, control = 1 }; /// Append one crosslink input record to `s` in the `.zit-cache` wire format: /// `kind u8 | hash(u16-len) | mtime u64 | parent_count u32 [parent(u16-len)]… | /// tag_count u32 [type u8, name(u16-len), has_target u8 [target(u16-len)], /// has_value u8 [value(u32-len)]]…`. All little-endian, self-delimiting, so the /// decoder needs no separate record count. Deterministic: the fields come only /// from the persisted artifact, in persisted order — no clock, no RNG. fn encodeRecord( gpa: std.mem.Allocator, s: *std.ArrayList(u8), kind: RecordKind, hash: []const u8, mtime: repostate.Mtime, parents: []const []const u8, tags: []const repostate.TagOp, ) error{OutOfMemory}!void { try s.append(gpa, @intFromEnum(kind)); try appendLenPrefixed(gpa, s, u16, hash); try appendInt(gpa, s, u64, mtime); try appendInt(gpa, s, u32, @intCast(parents.len)); for (parents) |p| try appendLenPrefixed(gpa, s, u16, p); try appendInt(gpa, s, u32, @intCast(tags.len)); for (tags) |t| { try s.append(gpa, @intFromEnum(t.type)); try appendLenPrefixed(gpa, s, u16, t.name); if (t.target) |target| { try s.append(gpa, 1); try appendLenPrefixed(gpa, s, u16, target); } else try s.append(gpa, 0); if (t.value) |value| { try s.append(gpa, 1); try appendLenPrefixed(gpa, s, u32, value); } else try s.append(gpa, 0); } } fn appendInt(gpa: std.mem.Allocator, s: *std.ArrayList(u8), comptime T: type, v: T) error{OutOfMemory}!void { var buf: [@sizeOf(T)]u8 = undefined; std.mem.writeInt(T, &buf, v, .little); try s.appendSlice(gpa, &buf); } fn appendLenPrefixed(gpa: std.mem.Allocator, s: *std.ArrayList(u8), comptime L: type, bytes: []const u8) error{OutOfMemory}!void { std.debug.assert(bytes.len <= std.math.maxInt(L)); try appendInt(gpa, s, L, @intCast(bytes.len)); try s.appendSlice(gpa, bytes); } /// A bounds-checked cursor over a `.zit-cache` record body. Every read validates /// against the remaining bytes before advancing, so a truncated or lying body /// yields `error.Corrupt` rather than an out-of-bounds slice. const RecordReader = struct { body: []const u8, pos: usize = 0, fn atEnd(self: *const RecordReader) bool { return self.pos >= self.body.len; } fn byte(self: *RecordReader) error{Corrupt}!u8 { if (self.pos + 1 > self.body.len) return error.Corrupt; const b = self.body[self.pos]; self.pos += 1; return b; } fn int(self: *RecordReader, comptime T: type) error{Corrupt}!T { const n = @sizeOf(T); if (self.pos + n > self.body.len) return error.Corrupt; const v = std.mem.readInt(T, self.body[self.pos..][0..n], .little); self.pos += n; return v; } fn lenPrefixed(self: *RecordReader, comptime L: type) error{Corrupt}![]const u8 { const len: usize = try self.int(L); if (self.pos + len > self.body.len) return error.Corrupt; const out = self.body[self.pos..][0..len]; self.pos += len; return out; } }; /// Map a parsed T card (repo-state §5.1 sign syntax) to a `RepoState` tag op: /// `+` singleton, `-` cancel, `*` propagate. `name` is the sign-stripped, /// escape-decoded tag name; `target` is null for a `*` self-tag; `value` passes /// through. Total over `TSign`. fn tagOpOf(tc: artifact.TCard) repostate.TagOp { return .{ .type = switch (tc.sign) { .add => .singleton, .cancel => .cancel, .propagate => .propagate, }, .name = tc.name, .target = tc.target, .value = tc.value, }; } /// Encode a binary digest to its lowercase-hex artifact name into `out`, /// returning the used prefix. A 20-byte (sha1) or 32-byte (sha3-256) digest /// yields a 40- or 64-char name — always ≤ 64. fn hexName(raw: []const u8, out: *[64]u8) []const u8 { std.debug.assert(raw.len >= 1 and raw.len <= 32); for (raw, 0..) |b, i| { out[i * 2] = hex_digits[b >> 4]; out[i * 2 + 1] = hex_digits[b & 0x0f]; } return out[0 .. raw.len * 2]; } // =========================================================================== // Tests — the loader's job is to reproduce the repo-state §6-7 derived views // from persisted bytes. Fixtures are assembled with `artifact.create` (the // library serializer) and stored through a real `LogStore`; every expected // value comes from the repo-state RFC text, never from Fossil. // =========================================================================== const storage = @import("storage.zig"); const testing = std.testing; /// A tmp-backed writable `LogStore` fixture plus the file handle it borrows. /// Mirrors the FileStorage/tmp pattern the logstore tests use. `deinit` tears /// the store down; the tmp dir is the caller's to clean up. const Fixture = struct { fs: storage.FileStorage, log: logstore.LogStore, /// Opens a fresh empty `.zit`-style log in `tmp`. `hash_kind` 0 accepts the /// 32-byte sha3-256 digests the fixtures use. Initialized in place: the /// store's `StorageFile` borrows `&self.fs`, so `self` must not be moved /// after this call. fn init(self: *Fixture, tmp: *std.testing.TmpDir, io: std.Io) !void { const raw = try tmp.dir.createFile(io, "log", .{ .read = true }); self.fs = storage.FileStorage.init(io, raw, 0); self.log = try logstore.LogStore.open(testing.allocator, self.fs.storageFile(), 0); } fn deinit(self: *Fixture) void { self.log.deinit(testing.allocator); } /// Serialize `m`, store it under its own sha3-256 digest (the content-address /// commit uses), and return the 64-hex artifact name (caller frees). fn put(self: *Fixture, m: *const artifact.Manifest) ![]u8 { const bytes = try artifact.create(m, testing.allocator); defer testing.allocator.free(bytes); var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{}); _ = try self.log.put(&digest, bytes); var name_buf: [64]u8 = undefined; return testing.allocator.dupe(u8, hexName(&digest, &name_buf)); } }; /// A skeleton check-in manifest (repo-state §6.1: a root carries the propagating /// branch + sym tags; a child carries a P card and inherits by propagation). /// `parent` null ⇒ a root check-in with the `trunk` branch tags; non-null ⇒ a /// child of that parent name. `branch_value`/`extra_tags` let a test place an /// explicit branch or closed tag. Files are irrelevant to leaf derivation, so /// there are none. const CheckinSpec = struct { ts: []const u8, parent: ?[]const u8 = null, /// When set, the root's branch/sym tags use this value instead of "trunk". branch_value: []const u8 = "trunk", /// Extra T cards appended after the branch tags (e.g. a self closed tag). extra_tags: []const artifact.TCard = &.{}, }; fn manifestOf(spec: CheckinSpec, tag_buf: []artifact.TCard, parent_buf: []artifact.PCard) artifact.Manifest { const is_root = spec.parent == null; var n: usize = 0; if (is_root) { 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 .{ .arena_state = std.heap.ArenaAllocator.init(testing.allocator), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = "c" }, .d = .{ .timestamp = spec.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_slice, .q = &.{}, .t = tag_buf[0..n], }; } /// Store a check-in built from `spec`, returning its 64-hex name (caller frees). fn putCheckin(fx: *Fixture, spec: CheckinSpec) ![]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 fx.put(&m); } /// Store a control artifact carrying `tags` (repo-state §4.2/§7.4 tag mutation). /// `create` classifies a D/T/U artifact with no C card and no parents as a /// control (tag) artifact. fn putControl(fx: *Fixture, ts: []const u8, tags: []const artifact.TCard) ![]u8 { var m: artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(testing.allocator), .kind = .control, .a = null, .b = null, .c = 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 = &.{}, .q = &.{}, .t = tags, }; defer m.deinit(); return fx.put(&m); } /// Load a view over `fx.log`. Caller pins `view` and `deinit`s it. fn loadView(view: *RepoView, fx: *Fixture) !void { try view.load(testing.allocator, &fx.log); } /// Resolve a 64-hex name to its `RepoState` rid via the view's identity space. fn ridOf(view: *RepoView, name: []const u8) ?repostate.Rid { return view.store.store().lookup(name); } /// Whether `name` appears among the view's open leaves. fn openLeafPresent(view: *RepoView, name: []const u8) bool { const rid = ridOf(view, name) orelse return false; var it = view.state.leaves(); while (it.next()) |leaf| { if (leaf == rid) return view.state.isOpenLeaf(rid); } return false; } test "repoview: a rejected artifact is NAMED, not just reported as a corrupt repository" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const good = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(good); // Well-formed enough to be stored, unparsable as a timestamp: `parseMtime` // wants 19-23 characters once byte 10 is `T`, and this is 16. That is the // shape that gets past artifact validation and dies in the derive. const bad = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00" }); defer testing.allocator.free(bad); var view: RepoView = undefined; view.fault = null; if (view.load(testing.allocator, &fx.log)) |_| { view.deinit(testing.allocator); // loaded after all: tear down before failing return error.TestExpectedCorrupt; } else |e| { try testing.expectEqual(error.Corrupt, e); } // The point of the whole mechanism: the walk that found this runs over every // artifact in the store — minutes on a real one — so failing without saying // WHICH artifact leaves bisecting the store as the only way to find out. const f = view.fault orelse return error.TestExpectedFault; try testing.expectEqual(RepoView.Fault.Reason.unparsable_timestamp, f.reason); try testing.expectEqualStrings(bad, f.artifactName()); } test "repoview §7.1: a single-commit repo loads its lone root as the open leaf" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); const rid = ridOf(&view, root).?; try testing.expect(view.state.isLeaf(rid)); // §7.1: no same-branch child try testing.expect(view.state.isOpenLeaf(rid)); // §7.4: no closed tag try testing.expectEqualStrings("trunk", view.state.branchOf(rid, view.main_branch)); } test "repoview §7.1: a closed leaf stays in leaves() but isOpenLeaf is false (closed derived, not stored)" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A root that self-closes: an active +closed tag on itself (§7.4). const closed = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = null, .value = null }}; const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .extra_tags = &closed }); defer testing.allocator.free(root); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); const rid = ridOf(&view, root).?; try testing.expect(view.state.isLeaf(rid)); // §7.1: still a leaf-table member try testing.expect(!view.state.isOpenLeaf(rid)); // §7.4: closed ⇒ not open } test "repoview §7.2: a check-in with a same-branch primary child is not a leaf" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root }); defer testing.allocator.free(child); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expect(!view.state.isLeaf(ridOf(&view, root).?)); // §7.2: has a same-branch child try testing.expect(view.state.isLeaf(ridOf(&view, child).?)); // the tip is the leaf } test "repoview §7.2: a same-branch child via a non-primary (merge) P-card disqualifies the parent leaf" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A root and a second independent root, both on trunk; then a merge child // whose PRIMARY parent is `mainline` and whose SECOND (merge, non-primary) // parent is `side`. §7.2: the merge edge still disqualifies `side` as a leaf. const mainline = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(mainline); const side = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00" }); defer testing.allocator.free(side); // Build the merge child by hand: two P tokens, primary then merge. var pbuf: [130]u8 = undefined; const joined = try std.fmt.bufPrint(&pbuf, "{s} {s}", .{ mainline, side }); var m: artifact.Manifest = .{ .arena_state = std.heap.ArenaAllocator.init(testing.allocator), .kind = .manifest, .a = null, .b = null, .c = .{ .comment = "merge" }, .d = .{ .timestamp = "2020-01-03T00:00:00" }, .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 = &.{.{ .parents = joined, .count = 2 }}, .q = &.{}, .t = &.{}, }; defer m.deinit(); const child = try fx.put(&m); defer testing.allocator.free(child); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); // Both parents are on trunk (default fallback); the merge child is on trunk. // §7.2: neither parent is a leaf — `side` is reached only via a merge edge. try testing.expect(!view.state.isLeaf(ridOf(&view, mainline).?)); try testing.expect(!view.state.isLeaf(ridOf(&view, side).?)); try testing.expect(view.state.isLeaf(ridOf(&view, child).?)); } test "repoview §7.3: open builds the full leaf table over every crosslinked check-in" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A linear chain of three: only the tip is a leaf; the two interior // check-ins are excluded because each has a same-branch child (§7.3 full // rebuild over every check-in, roots included). const a = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(a); const b = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = a }); defer testing.allocator.free(b); const c = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = b }); defer testing.allocator.free(c); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); var count: usize = 0; var it = view.state.leaves(); while (it.next()) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); try testing.expect(view.state.isLeaf(ridOf(&view, c).?)); } test "repoview §7.3: a root check-in with no parent loads as a leaf" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // Two independent roots (no parent at all): §7.3 says a parentless check-in // is a leaf when it has no same-branch child, and MUST be included. const r1 = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(r1); const r2 = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00" }); defer testing.allocator.free(r2); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expect(view.state.isLeaf(ridOf(&view, r1).?)); try testing.expect(view.state.isLeaf(ridOf(&view, r2).?)); } test "repoview §7.4: a leaf without a closed tag is open; a control artifact closes it" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); // Open before any closed tag. { var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); // §7.4 } // A control artifact applies an active +closed tag to the leaf (§7.4: the // closed status is an active tag-cross-reference row, tagtype > 0). const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }}; const ctl = try putControl(&fx, "2020-01-02T00:00:00", &close); defer testing.allocator.free(ctl); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); const rid = ridOf(&view, root).?; try testing.expect(view.state.isLeaf(rid)); // still a leaf try testing.expect(!view.state.isOpenLeaf(rid)); // §7.4: now closed } test "repoview §7.4: a cancel (-closed) control reopens a previously closed leaf" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = root, .value = null }}; const ctl_close = try putControl(&fx, "2020-01-02T00:00:00", &close); defer testing.allocator.free(ctl_close); // A later cancel row (tagtype 0) removes the active closed marker (§7.4: // closed is only "an assignment, not a cancellation"). const reopen = [_]artifact.TCard{.{ .sign = .cancel, .name = "closed", .target = root, .value = null }}; const ctl_open = try putControl(&fx, "2020-01-03T00:00:00", &reopen); defer testing.allocator.free(ctl_open); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); // reopened } test "repoview §7.5: two open leaves on one branch both load (ambiguous tip)" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A root with two children on the same branch: a fork. §7.5: both children // are open leaves of trunk, so each is an ambiguous leaf of the other. const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const c1 = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root }); defer testing.allocator.free(c1); const c2 = try putCheckin(&fx, .{ .ts = "2020-01-03T00:00:00", .parent = root }); defer testing.allocator.free(c2); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); const r1 = ridOf(&view, c1).?; const r2 = ridOf(&view, c2).?; try testing.expect(view.state.isOpenLeaf(r1)); try testing.expect(view.state.isOpenLeaf(r2)); try testing.expect(!view.state.isLeaf(ridOf(&view, root).?)); // parent forked try testing.expect(view.state.isAmbiguousLeaf(r1)); // §7.5: another open leaf on the branch try testing.expect(view.state.isAmbiguousLeaf(r2)); } test "repoview §6.1: the configured main-branch names the default branch; absent config falls back to trunk" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // (a) No config frame: a tagless check-in falls back to "trunk". But our // root carries a propagating branch tag, so assert on a value-carrying root // named "trunk" and on the fallback via main_branch. { var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expectEqualStrings("trunk", view.main_branch); // §6.1: unset ⇒ trunk } // (b) A config frame naming a different main-branch: the loader reads it and // threads it into main_branch (§6.1: MUST read the configured value). { var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); try fx.log.putConfig(.{ .hash_policy = 0, .default_branch = "mainline", .project_id = "p" }); try fx.log.commit(); // A check-in with NO branch tag (a bare control-free child is awkward, so // use a root but assert the fallback name only when a tagless check-in is // present). Here assert on main_branch itself — the §6.1 configured value. const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .branch_value = "mainline" }); defer testing.allocator.free(root); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expectEqualStrings("mainline", view.main_branch); // §6.1: configured value try testing.expectEqualStrings("mainline", view.state.branchOf(ridOf(&view, root).?, view.main_branch)); } } test "repoview §6.1: a cancelled branch tag falls back to the configured main branch (tagtype>0 filter)" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A root on trunk, then a control that CANCELS its branch tag. §6.1: only an // active (tagtype > 0) branch tag names a branch; a cancelled one falls back // to the default branch exactly like a missing one. const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .branch_value = "feature" }); defer testing.allocator.free(root); const cancel = [_]artifact.TCard{.{ .sign = .cancel, .name = "branch", .target = root, .value = "feature" }}; const ctl = try putControl(&fx, "2020-01-02T00:00:00", &cancel); defer testing.allocator.free(ctl); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); // The cancelled branch tag is inactive, so branchOf returns the fallback. try testing.expectEqualStrings("trunk", view.state.branchOf(ridOf(&view, root).?, view.main_branch)); } test "repoview §6.1: a check-in's absent P-card parent is an eventless phantom" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A child whose P card names a parent hash never stored: the loader interns // that name as a phantom rid with no event row. §6.1: such a rid has no // mtime, so it is not a resolvable tip — the guard predicate a checkout-mark // resolver relies on (mtimeOf == null ⇒ nothing to mark). const absent = "a" ** 64; const child = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00", .parent = absent }); defer testing.allocator.free(child); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); const phantom = view.lookup(absent) orelse return error.PhantomNotInterned; try testing.expect(view.state.mtimeOf(phantom) == null); // eventless try testing.expect(view.state.mtimeOf(ridOf(&view, child).?) != null); // child bears an event } test "repoview §7.7: a closed non-leaf does not appear among open leaves (enumeration ≠ name resolution)" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A root, then a child that is the tip. Close the root (a non-leaf). §7.7: // leaf enumeration answers "no same-branch descendant and still open"; the // closed root is neither a leaf nor an open leaf regardless of any name it // might resolve. const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const child = try putCheckin(&fx, .{ .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(&fx, "2020-01-03T00:00:00", &close); defer testing.allocator.free(ctl); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expect(!openLeafPresent(&view, root)); // not a leaf at all try testing.expect(openLeafPresent(&view, child)); // the actual open leaf } test "repoview: a non-artifact file blob is skipped, not an error" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // Store raw non-artifact bytes (a file blob) alongside one real check-in. // The loader skips the blob (parse fails) and still derives the check-in. const blob = "this is not a manifest\n"; var bdigest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(blob, &bdigest, .{}); _ = try fx.log.put(&bdigest, blob); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); var view: RepoView = undefined; try loadView(&view, &fx); defer view.deinit(testing.allocator); try testing.expect(view.state.isOpenLeaf(ridOf(&view, root).?)); } test "repoview: load is leak-free under every allocation failure" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root }); defer testing.allocator.free(child); const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = child, .value = null }}; const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close); defer testing.allocator.free(ctl); const Ctx = struct { fx: *Fixture, fn run(a: std.mem.Allocator, self: @This()) !void { var view: RepoView = undefined; try view.load(a, &self.fx.log); view.deinit(a); } }; try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{Ctx{ .fx = &fx }}); } test "repoview fuzz: load never crashes on adversarial or mutated artifact bytes" { // Invariant (repo-state §7): building a view over arbitrary persisted bytes // returns only a declared LoadError or a consistent state — never a crash, // out-of-bounds read, or unhandled trap. Non-artifact frames are skipped; // a structurally corrupt check-in is a declared error. var prng = std.Random.DefaultPrng.init(0x1eaf5eed); const rand = prng.random(); // A valid baseline manifest to mutate. var base_tag: [8]artifact.TCard = undefined; var base_p: [1]artifact.PCard = undefined; var base_m = manifestOf(.{ .ts = "2020-01-01T00:00:00" }, &base_tag, &base_p); const base = try artifact.create(&base_m, testing.allocator); base_m.deinit(); defer testing.allocator.free(base); var round: usize = 0; while (round < 256) : (round += 1) { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // Put a handful of frames: some random bytes, some byte-flipped copies of // the valid baseline. Each is stored under its own digest. var k: usize = 0; const frames = rand.intRangeAtMost(usize, 1, 5); while (k < frames) : (k += 1) { var payload: std.ArrayList(u8) = .empty; defer payload.deinit(testing.allocator); if (rand.boolean()) { const len = rand.intRangeAtMost(usize, 1, 128); try payload.resize(testing.allocator, len); rand.bytes(payload.items); } else { try payload.appendSlice(testing.allocator, base); const flips = rand.intRangeAtMost(usize, 1, 6); var f: usize = 0; while (f < flips) : (f += 1) { const idx = rand.intRangeLessThan(usize, 0, payload.items.len); payload.items[idx] ^= rand.int(u8); } } var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(payload.items, &digest, .{}); _ = fx.log.put(&digest, payload.items) catch continue; } var view: RepoView = undefined; view.load(testing.allocator, &fx.log) catch |e| { // Only the declared, closed error set may surface; a failed load has // already torn itself down, so no deinit here. switch (e) { error.OutOfMemory, error.Storage, error.Corrupt => {}, } continue; }; view.deinit(testing.allocator); } } test "repoview §1: a captured record body replays to a view equal to from-scratch load" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); // A root, a same-branch child, a fork, and a control close — enough shape to // exercise leaves, propagation, and tags in the round-trip. const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root }); defer testing.allocator.free(child); const fork = try putCheckin(&fx, .{ .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(&fx, "2020-01-04T00:00:00", &close); defer testing.allocator.free(ctl); // Reference: a plain from-scratch derivation. var ref: RepoView = undefined; try ref.load(testing.allocator, &fx.log); defer ref.deinit(testing.allocator); // Capture the record body, then replay it WITHOUT the log. var body: std.ArrayList(u8) = .empty; defer body.deinit(testing.allocator); var cap: RepoView = undefined; try cap.loadCapturing(testing.allocator, &fx.log, &body); cap.deinit(testing.allocator); // we only needed the body var replay: RepoView = undefined; try replay.buildFromRecordBody(testing.allocator, body.items, ref.main_branch); defer replay.deinit(testing.allocator); // §1: the replayed view is observably identical — same rids (same intern // order), leaves, open/closed status, branches, and mtimes. try testing.expectEqualStrings(ref.main_branch, replay.main_branch); try testing.expectEqual(ref.store.store().count(), replay.store.store().count()); var lr = ref.state.leaves(); var lp = replay.state.leaves(); while (lr.next()) |a| { const b = lp.next() orelse return error.LeafCountMismatch; try testing.expectEqual(a, b); try testing.expectEqual(ref.state.isOpenLeaf(a), replay.state.isOpenLeaf(b)); try testing.expectEqualStrings(ref.state.branchOf(a, ref.main_branch), replay.state.branchOf(b, replay.main_branch)); } try testing.expect(lp.next() == null); var rid: repostate.Rid = 1; const count = ref.store.store().count(); while (rid <= count) : (rid += 1) { try testing.expectEqualStrings(ref.hashOf(rid), replay.hashOf(rid)); try testing.expectEqual(ref.state.mtimeOf(rid), replay.state.mtimeOf(rid)); try testing.expectEqual(ref.state.isLeaf(rid), replay.state.isLeaf(rid)); } } test "repoview: buildFromRecordBody is leak-free under every allocation failure" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var fx: Fixture = undefined; try fx.init(&tmp, testing.io); defer fx.deinit(); const root = try putCheckin(&fx, .{ .ts = "2020-01-01T00:00:00" }); defer testing.allocator.free(root); const child = try putCheckin(&fx, .{ .ts = "2020-01-02T00:00:00", .parent = root }); defer testing.allocator.free(child); const close = [_]artifact.TCard{.{ .sign = .add, .name = "closed", .target = child, .value = null }}; const ctl = try putControl(&fx, "2020-01-03T00:00:00", &close); defer testing.allocator.free(ctl); // Capture a valid body once with the real allocator; then replay it under // injected OOM at every allocation site. var body: std.ArrayList(u8) = .empty; defer body.deinit(testing.allocator); var cap: RepoView = undefined; try cap.loadCapturing(testing.allocator, &fx.log, &body); cap.deinit(testing.allocator); const Ctx = struct { body: []const u8, fn run(a: std.mem.Allocator, self: @This()) !void { var view: RepoView = undefined; view.buildFromRecordBody(a, self.body, "trunk") catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Corrupt => unreachable, // a valid body never decodes as corrupt }; view.deinit(a); } }; try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{Ctx{ .body = body.items }}); }