Artifact 6a2ea0a4b70896b2a6a95e8aa97f15f0dbf3e2ef21d1167ade3c762cb8636398:
- File
src/checkpoint.zig
— part of check-in
[adae602908]
at
2026-08-11 22:45:56
on branch sub-manifest
— checkpoint: one-pass build, incremental advance, opportunistic refresh
Build paid a bypass derive, a whole-prefix delta scan, and a capture re-derive; a view-free captureRecords (byte-identical to the deriving walk, pinned at every boundary) now does capture and delta detection in one pass, and an existing sidecar's body plus its delta-free attestation reduce a rebuild to the unscanned tail. pkgsrc: cold build 17:58 -> 4:21; rebuild or anchor advance -> 5.1s.
Repository.open's healing paths keep an existing checkpoint fresh: witness shape forced (the automatic path can never mint an artifact, whatever §8.1 says — a tree pin advances by demotion and is re-pinned deliberately), gated on 4MB growth AND anchor progress so blob-only growth never churns the sidecar. No checkpoint, no auto-creation: building once is the opt-in. (user: dmestas, size: 34848) [annotate] [blame] [check-ins using]
//! The durable S-tree checkpoint (hybrid-history acceleration). //! //! A `.zit-cache/checkpoint` sidecar records one prefix-anchored claim: "as of //! check-in C, whose frame closes the log prefix `[0, prefix_key.end)`, the //! fully-verified file set is fingerprinted here (and, in `tree` shape, //! witnessed by the stored S-tree rooted at `tree_hash`)" — plus the crosslink //! record body for that prefix, so `Repository.open`'s miss path can //! `RepoView.resumeFrom` the prefix instead of re-parsing a permanently flat //! F-card segment on every from-scratch derive (submanifests §7.4 applied to //! the segment §7.4 cannot reach). //! //! Advisory-tier all the way down, exactly like `repocache.zig`, on which this //! container is modelled: deleting the file changes no answer, every //! validation failure is a MISS (never an error), `--no-cache` bypasses it, //! and the claim is independently re-derivable (`verify`). The container //! validates magic/version/self-checksum/prefix-key and hands back opaque //! bytes; it knows nothing about the record format and never resolves //! `tree_hash`. //! //! Strictly local, never synced, never history: the sidecar appears in no //! artifact and crosses no wire. The `tree` shape's S-tree, by contrast, IS //! made of ordinary content-addressed `SUBMANIFEST` artifacts — inert per //! §7.3, permanent like everything in the log, and only ever minted when the //! §8.1 emission gate is open. const std = @import("std"); const artifact = @import("artifact.zig"); const logstore = @import("logstore.zig"); const repocache = @import("repocache.zig"); const repository = @import("repository.zig"); const repostate = @import("repostate.zig"); const repoview = @import("repoview.zig"); const storage = @import("storage.zig"); const crc32 = @import("crc32.zig"); /// The sidecar's member inside the advisory `.zit-cache/` directory (the home /// the consolidation reserved for it). Ignore-glob'd and never committed; /// disposable by construction. pub const checkpoint_filename: []const u8 = repocache.cache_dirname ++ "/checkpoint"; const magic = "ZITCKPNT"; // Bumped to 2 with repocache's record-format change (the C-card comment // joined the crosslink record): the sidecar carries a record body, so an old // one must miss rather than fail late inside `resumeFrom`. const format_version: u16 = 2; /// Which witness the checkpoint carries. `tree` means a canonical S-tree was /// stored and `tree_hash` names its root; `witness` means no artifact was /// created and `fileset_fingerprint` alone witnesses the file set. The /// distinction is the §8.1 emission gate's, never a performance knob. pub const Shape = enum(u8) { witness = 0, tree = 1 }; /// The sidecar's identity claim, exactly what the header stores. Hash slices /// borrow the load's backing buffer (`Loaded.buf`) or the builder's arena. pub const Claim = struct { shape: Shape, /// The check-in whose frame closes the prefix. checkin_hash: []const u8, /// The stored S-tree's root, or empty in `witness` shape. tree_hash: []const u8, /// `artifact.fileSetFingerprint` over C's complete file set. fileset_fingerprint: [32]u8, /// The partition parameter the tree was built at (0 in `witness` shape). k: u8, algo: artifact.HashAlgo, /// The log prefix the record body distills; `logstore.validatePrefix` is /// the O(1) revalidation on every load. prefix_key: logstore.LogStore.LogKey, /// The shun-table fingerprint at capture: a record body distilled under an /// old mask may carry a record the mask must now hide, so any difference /// is a MISS (the same rule `.zit-cache` extension follows). shun_fp: u32, }; /// Bytes before the variable spans: /// `magic(8) | format_version(2) | shape(1) | reserved(1) | prefix_end(8) | /// prefix_frame_count(8) | prefix_last_frame_offset(8) | /// prefix_last_header_crc(4) | shun_fp(4) | checkin_hash_len(1) | /// tree_hash_len(1) | k(1) | algo(1) | fileset_fingerprint(32) | /// main_branch_len(2) | body_len(4)`. const fixed_prefix_len: usize = 8 + 2 + 1 + 1 + 8 + 8 + 8 + 4 + 4 + 1 + 1 + 1 + 1 + 32 + 2 + 4; // = 86 const checksum_len: usize = 4; /// A validated load: `claim`, `main_branch` and `body` slice into `buf`. pub const Loaded = struct { buf: []u8, claim: Claim, main_branch: []const u8, body: []const u8, pub fn deinit(self: *Loaded, gpa: std.mem.Allocator) void { gpa.free(self.buf); self.* = undefined; } }; /// Read and fully validate the sidecar against the LIVE log: magic, version, /// self-checksum over every pre-checksum byte, structural spans that exactly /// account for the file size, a known shape/algo byte, and a prefix key that /// still validates (`validatePrefix` — the log may have GROWN, which is the /// whole point; truncation or a rewritten boundary is a miss). Returns null on /// ANY failure — a checkpoint is advisory, so an unreadable one just means /// "derive from the log". The caller still owns the shun-fingerprint compare /// (it needs an open scan) and the shape/emission-gate compare. /// Evidence seam for the paranoid-bypass tests: every `tryLoad` entry counts, /// BEFORE the sidecar is opened. `--no-cache` must leave this untouched — the /// promise is "trust nothing derived", and a bypass that still consults the /// sidecar is not one. Single-threaded like every derive path. pub var tryload_consults: u64 = 0; pub fn tryLoad(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, log_file: storage.StorageFile) ?Loaded { tryload_consults += 1; var file = dir.openFile(io, checkpoint_filename, .{}) catch return null; defer file.close(io); const st = file.stat(io) catch return null; const size = std.math.cast(usize, st.size) orelse return null; if (size < fixed_prefix_len + checksum_len) return null; const buf = gpa.alloc(u8, size) catch return null; errdefer gpa.free(buf); const n = file.readPositionalAll(io, buf, 0) catch { gpa.free(buf); return null; }; if (n != size) { gpa.free(buf); return null; } const loaded = parse(buf) orelse { gpa.free(buf); return null; }; if (!logstore.LogStore.validatePrefix(log_file, loaded.claim.prefix_key)) { gpa.free(buf); return null; } return loaded; } /// The pure structural half of `tryLoad`, split out so corruption tests can /// drive it byte-by-byte. `buf` is the whole file; the returned slices borrow /// it. Null on any framing violation — never partial trust. fn parse(buf: []u8) ?Loaded { if (buf.len < fixed_prefix_len + checksum_len) return null; if (!std.mem.eql(u8, buf[0..8], magic)) return null; if (std.mem.readInt(u16, buf[8..10], .little) != format_version) return null; // The whole-file checksum first: no declared length below is trusted // before the bytes carrying it prove intact. const stored_crc = std.mem.readInt(u32, buf[buf.len - checksum_len ..][0..4], .little); var crc = crc32.Crc32.init(); crc.update(buf[0 .. buf.len - checksum_len]); if (crc.final() != stored_crc) return null; const shape: Shape = switch (buf[10]) { 0 => .witness, 1 => .tree, else => return null, }; if (buf[11] != 0) return null; // reserved const key: logstore.LogStore.LogKey = .{ .end = std.mem.readInt(u64, buf[12..20], .little), .frame_count = std.mem.readInt(u64, buf[20..28], .little), .last_frame_offset = std.mem.readInt(u64, buf[28..36], .little), .last_header_crc = std.mem.readInt(u32, buf[36..40], .little), }; const shun_fp = std.mem.readInt(u32, buf[40..44], .little); const ckh_len: usize = buf[44]; const th_len: usize = buf[45]; const k = buf[46]; const algo: artifact.HashAlgo = switch (buf[47]) { 0 => .sha1, 1 => .sha3_256, else => return null, }; var fp: [32]u8 = undefined; @memcpy(&fp, buf[48..80]); const mb_len: usize = std.mem.readInt(u16, buf[80..82], .little); const body_len: usize = std.mem.readInt(u32, buf[82..86], .little); // Artifact names are 40 or 64 lowercase hex; a tree hash is absent // (witness) or one of those. Shape and spans must agree. if (ckh_len != 40 and ckh_len != 64) return null; if (th_len != 0 and th_len != 40 and th_len != 64) return null; if ((shape == .tree) != (th_len != 0)) return null; if (mb_len == 0) return null; // Exact byte accounting: the declared spans plus framing must equal the // file, so neither truncation nor trailing garbage survives. const want = fixed_prefix_len + ckh_len + th_len + mb_len + body_len + checksum_len; if (want != buf.len) return null; var pos: usize = fixed_prefix_len; const ckh = buf[pos..][0..ckh_len]; pos += ckh_len; const th = buf[pos..][0..th_len]; pos += th_len; const mb = buf[pos..][0..mb_len]; pos += mb_len; const body = buf[pos..][0..body_len]; return .{ .buf = buf, .claim = .{ .shape = shape, .checkin_hash = ckh, .tree_hash = th, .fileset_fingerprint = fp, .k = k, .algo = algo, .prefix_key = key, .shun_fp = shun_fp, }, .main_branch = mb, .body = body, }; } /// Atomically materialize the sidecar (temp + fsync + rename, the sequence /// every sibling writer uses). Every failure is swallowed: a checkpoint is an /// optimization, never a correctness dependency, so the worst outcome of a /// failed write is the derive it would have skipped. pub fn store( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, claim: Claim, main_branch: []const u8, body: []const u8, ) void { std.debug.assert(claim.checkin_hash.len == 40 or claim.checkin_hash.len == 64); std.debug.assert((claim.shape == .tree) == (claim.tree_hash.len != 0)); std.debug.assert(main_branch.len != 0); if (!repocache.ensureCacheDir(io, dir)) return; var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); appendAll(gpa, &out, claim, main_branch, body) catch return; var af = dir.createFileAtomic(io, checkpoint_filename, .{ .replace = true }) catch return; defer af.deinit(io); af.file.writePositionalAll(io, out.items, 0) catch return; af.file.sync(io) catch return; af.replace(io) catch return; } fn appendAll( gpa: std.mem.Allocator, out: *std.ArrayList(u8), claim: Claim, main_branch: []const u8, body: []const u8, ) error{OutOfMemory}!void { try out.appendSlice(gpa, magic); try appendInt(gpa, out, u16, format_version); try out.append(gpa, @intFromEnum(claim.shape)); try out.append(gpa, 0); // reserved try appendInt(gpa, out, u64, claim.prefix_key.end); try appendInt(gpa, out, u64, claim.prefix_key.frame_count); try appendInt(gpa, out, u64, claim.prefix_key.last_frame_offset); try appendInt(gpa, out, u32, claim.prefix_key.last_header_crc); try appendInt(gpa, out, u32, claim.shun_fp); try out.append(gpa, @intCast(claim.checkin_hash.len)); try out.append(gpa, @intCast(claim.tree_hash.len)); try out.append(gpa, claim.k); try out.append(gpa, switch (claim.algo) { .sha1 => 0, .sha3_256 => 1, }); try out.appendSlice(gpa, &claim.fileset_fingerprint); try appendInt(gpa, out, u16, @intCast(main_branch.len)); try appendInt(gpa, out, u32, @intCast(body.len)); try out.appendSlice(gpa, claim.checkin_hash); try out.appendSlice(gpa, claim.tree_hash); try out.appendSlice(gpa, main_branch); try out.appendSlice(gpa, body); var crc = crc32.Crc32.init(); crc.update(out.items); try appendInt(gpa, out, u32, crc.final()); std.debug.assert(out.items.len == fixed_prefix_len + claim.checkin_hash.len + claim.tree_hash.len + main_branch.len + body.len + checksum_len); } fn appendInt(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime T: type, v: T) error{OutOfMemory}!void { var b: [@sizeOf(T)]u8 = undefined; std.mem.writeInt(T, &b, v, .little); try out.appendSlice(gpa, &b); } /// Delete the sidecar. A missing file is success, not an error — the file is /// disposable by construction (`zit checkpoint drop`). pub fn drop(io: std.Io, dir: std.Io.Dir) void { dir.deleteFile(io, checkpoint_filename) catch {}; } // --------------------------------------------------------------------------- // The builder (DESIGN §4/§5). One explicit operator act: enumerate C's file // set through the §9.3 seam, fingerprint it, mint the canonical tree when — // and only when — the §8.1 emission gate is open, capture the prefix record // body, and atomically write the sidecar. Ordering matters: artifacts are // durable in the log BEFORE the sidecar that names them exists, so no crash // leaves a sidecar holding a dangling hash that validates. // --------------------------------------------------------------------------- pub const BuildError = error{ OutOfMemory, Storage, Corrupt, /// `C` is absent from the log, interned only as a phantom, or (for the /// null-checkin default) the repository has no leaves to anchor on. CheckinNotFound, /// `C` names an artifact that is not a check-in manifest. NotACheckin, /// A checkpoint off the mainline is refused: `C` must be an ancestor of /// every open leaf (merge-base fold when no check-in was named). CheckinNotAncestorOfAllLeaves, /// The prefix contains a delta manifest — out of scope (zit's writer /// emits none; this guards imported history). DeltaManifestInPrefix, }; pub const BuildReport = struct { shape: Shape, /// The §8.1/§6.3 gate outcome the shape honored; `.out_of_range` carries /// the offending k for the operator report the SHOULD asks for. gate: repository.submanifest.Emission, checkin_hash_buf: [64]u8, checkin_hash_len: u8, tree_hash_buf: [64]u8, tree_hash_len: u8, files: usize, prefix_frames: u64, nodes_created: usize, nodes_deduplicated: usize, pub fn checkinHash(self: *const BuildReport) []const u8 { return self.checkin_hash_buf[0..self.checkin_hash_len]; } pub fn treeHash(self: *const BuildReport) []const u8 { return self.tree_hash_buf[0..self.tree_hash_len]; } }; /// Build (or rebuild) the checkpoint for `repo` anchored at `checkin` (a full /// lowercase-hex check-in name), or at the merge-base fold of every open leaf /// when null. Idempotent over unchanged history: `putArtifact` deduplicates /// byte-identical nodes and the sidecar rewrite is atomic. pub fn build( gpa: std.mem.Allocator, io: std.Io, repo: *repository.Repository, dir: std.Io.Dir, checkin: ?[]const u8, ) BuildError!BuildReport { return buildShaped(gpa, io, repo, dir, checkin, true); } /// `build` with the tree branch gated: the automatic advance path passes /// `allow_tree = false` so it can NEVER mint an artifact, whatever the §8.1 /// setting says — an act nobody invoked must not grow the permanent log. fn buildShaped( gpa: std.mem.Allocator, io: std.Io, repo: *repository.Repository, dir: std.Io.Dir, checkin: ?[]const u8, allow_tree: bool, ) BuildError!BuildReport { const state = &repo.view.state; // 1. Every open leaf, then C: named, or the merge-base fold of the leaves. var leaf_list: std.ArrayList(repostate.Rid) = .empty; defer leaf_list.deinit(gpa); var it = state.leaves(); while (it.next()) |rid| try leaf_list.append(gpa, rid); const c_rid: repostate.Rid = blk: { if (checkin) |hex| break :blk repo.view.lookup(hex) orelse return error.CheckinNotFound; if (leaf_list.items.len == 0) return error.CheckinNotFound; var anchor = leaf_list.items[0]; for (leaf_list.items[1..]) |leaf| { anchor = (try state.mergeBaseOf(gpa, anchor, leaf)) orelse return error.CheckinNotAncestorOfAllLeaves; // disjoint roots have no mainline } break :blk anchor; }; // 2. Mainline rule: C must be an ancestor of every open leaf. for (leaf_list.items) |leaf| { if (!(try state.isAncestorOf(gpa, c_rid, leaf))) return error.CheckinNotAncestorOfAllLeaves; } // 3. C's file set, through the one §9.3 seam (`Repository.files` walks // trees via `flattenIfTree`; no raw F-card iteration here). var fl = (repo.files(gpa, c_rid) catch |e| return mapFilesError(e)) orelse return error.CheckinNotFound; // interned phantom: no content to enumerate defer fl.deinit(gpa); var entries: std.ArrayList(artifact.FCard) = .empty; defer entries.deinit(gpa); var files_it = fl.iterator(); while (files_it.next()) |f| try entries.append(gpa, f); const fingerprint = try artifact.fileSetFingerprint(gpa, entries.items); // 4. The log, read-write: the builder appends tree nodes and captures the // prefix from the same scan. var file = dir.openFile(io, repository.repo_filename, .{ .mode = .read_write }) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; defer log.deinit(gpa); // 5. C's frame closes the prefix. const c_hex = repo.view.hashOf(c_rid); var digest: [logstore.max_hash_len]u8 = undefined; const c_raw = digest[0 .. c_hex.len / 2]; _ = std.fmt.hexToBytes(c_raw, c_hex) catch return error.Corrupt; const c_logrid = log.lookup(c_raw) orelse return error.CheckinNotFound; const key = log.logKeyAt(c_logrid) catch |e| return switch (e) { error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; // 6. The prefix record body and the delta refusal, in ONE walk, from the // cheapest source. ADVANCE: a valid existing sidecar whose prefix is a // prefix of ours already carries [0, L0) — and its existence attests // that span delta-free, because this builder refuses delta prefixes, // so a sidecar it wrote proves the property for everything it covers. // Only the tail (L0, L) is parsed. COLD: capture the whole prefix // view-free; the capture IS the delta scan. (The old implementation // paid a bypass derive, a whole-prefix delta scan, AND a capture // re-derive — three full passes where one suffices.) var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); var facts: repoview.CaptureFacts = .{}; var advanced_from: ?u64 = null; reuse: { var old = tryLoad(gpa, io, dir, fs.storageFile()) orelse break :reuse; defer old.deinit(gpa); if (old.claim.shun_fp != log.shunFingerprint()) break :reuse; if (old.claim.prefix_key.end > key.end) break :reuse; // anchor moved backward: rebuild cold body.appendSlice(gpa, old.body) catch return error.OutOfMemory; advanced_from = old.claim.prefix_key.end; } const capture_from: u64 = advanced_from orelse logstore.header_len; repoview.captureRecords(gpa, &log, capture_from, key.end, &body, &facts) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; if (facts.saw_delta_manifest) return error.DeltaManifestInPrefix; // 7. The §8.1 gate, read from the live config, decides the shape. var oc = log.readConfig(gpa) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Corrupt, }; defer if (oc) |*c| c.deinit(gpa); const settings = if (oc) |c| repository.submanifest.Settings.of(c.config) else repository.submanifest.Settings.off; const gate = repository.submanifest.emission(settings); var report: BuildReport = .{ .shape = .witness, .gate = gate, .checkin_hash_buf = undefined, .checkin_hash_len = @intCast(c_hex.len), .tree_hash_buf = undefined, .tree_hash_len = 0, .files = entries.items.len, .prefix_frames = key.frame_count, .nodes_created = 0, .nodes_deduplicated = 0, }; @memcpy(report.checkin_hash_buf[0..c_hex.len], c_hex); // 8. Tree shape iff the gate is open AND the construction actually trees // (§6.2 step 2's flat exit falls back to witness — never a single leaf // carrying every F card). Nodes are stored dependency-first (the list // order `buildTree` guarantees), then one canonical interior node over // the manifest-level S run gives the checkpoint its single root H. var k_used: u8 = 0; if (if (allow_tree) gate.treeK() else null) |k| { k_used = k; var built = try artifact.buildTree(gpa, entries.items, k, .sha3_256); defer built.deinit(); if (!built.isFlat()) { report.shape = .tree; for (built.subs) |sub| try storeNode(&log, &report, sub.name, sub.bytes); const root_bytes = artifact.serializeSubmanifestInterior(gpa, built.s) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.EmptySubmanifest => unreachable, // a non-flat tree has ≥ 2 top cards }; defer gpa.free(root_bytes); var root_hex_buf: [64]u8 = undefined; const root_hex = artifact.artifactName(.sha3_256, root_bytes, &root_hex_buf); try storeNode(&log, &report, root_hex, root_bytes); report.tree_hash_len = @intCast(root_hex.len); @memcpy(report.tree_hash_buf[0..root_hex.len], root_hex); // Artifacts durable BEFORE any sidecar names them. log.commit() catch return error.Storage; } } // 10. The sidecar, last. `store` swallows failures: the checkpoint is an // optimization, and everything durable above is already correct. store(gpa, io, dir, .{ .shape = report.shape, .checkin_hash = c_hex, .tree_hash = report.treeHash(), .fileset_fingerprint = fingerprint, .k = if (report.shape == .tree) k_used else 0, .algo = .sha3_256, .prefix_key = key, .shun_fp = log.shunFingerprint(), }, repo.view.main_branch, body.items); return report; } /// How many bytes the log must have grown past a checkpoint's prefix before /// the open path bothers advancing it. Below this, the resume already covers /// the tail cheaply and an advance would spend more than it saves; above it, /// the one-time advance cost (a tail parse plus one file-set fingerprint) /// keeps the checkpoint from drifting toward a full re-derive. pub const advance_min_growth_bytes: u64 = 4 << 20; /// Opportunistically ADVANCE an existing checkpoint to the current mainline /// anchor, in WITNESS shape only — never minting an artifact (tree re-pinning /// stays a deliberate `zit checkpoint build`; an automatic act must not grow /// the permanent log, and an advanced claim keeping the OLD tree hash would /// be a lie `verify` would rightly fail). Called from `Repository.open`'s /// healing paths behind the growth threshold. Best-effort end to end: every /// refusal or failure is silent, and the worst outcome is the checkpoint /// staying where it was. No-op when no checkpoint exists — the operator opts /// in by building once; this only keeps that choice fresh. pub fn advanceWitness(gpa: std.mem.Allocator, io: std.Io, repo: *repository.Repository, dir: std.Io.Dir) void { var file = dir.openFile(io, repository.repo_filename, .{}) catch return; defer file.close(io); const st = file.stat(io) catch return; var fs = storage.FileStorage.init(io, file, st.size); var loaded = tryLoad(gpa, io, dir, fs.storageFile()) orelse return; defer loaded.deinit(gpa); if (st.size < loaded.claim.prefix_key.end) return; // tryLoad validated, but stat raced if (st.size - loaded.claim.prefix_key.end < advance_min_growth_bytes) return; // Progress gate: the growth above may be blobs alone, with the mainline // anchor unmoved — advancing then would rewrite the same prefix forever // (and demote a tree pin for nothing). The would-be anchor is the same // merge-base fold `build` performs, compared by hash so no frame walk is // paid to discover there is nothing to do. { const state = &repo.view.state; var it = state.leaves(); var anchor: ?repostate.Rid = null; while (it.next()) |leaf| { anchor = if (anchor) |a| (state.mergeBaseOf(gpa, a, leaf) catch return) orelse return else leaf; } const a = anchor orelse return; if (std.mem.eql(u8, repo.view.hashOf(a), loaded.claim.checkin_hash)) return; } // Witness-forced: whatever the §8.1 setting says, the automatic path // mints nothing. A tree-shape sidecar is thereby DEMOTED to witness at // the new anchor — nothing permanent is lost (the tree's artifacts stay // in the log), the rebuild win moves forward, and the operator re-pins a // tree with an explicit `zit checkpoint build` whenever they choose. _ = buildShaped(gpa, io, repo, dir, null, false) catch return; } /// Store one tree node, tallying the created/deduplicated split by whether /// the log already held it (`putArtifact` is idempotent either way). fn storeNode(log: *logstore.LogStore, report: *BuildReport, hex: []const u8, bytes: []const u8) BuildError!void { var digest: [logstore.max_hash_len]u8 = undefined; const raw = digest[0 .. hex.len / 2]; _ = std.fmt.hexToBytes(raw, hex) catch return error.Corrupt; const held = log.lookup(raw) != null; _ = log.putArtifact(raw, bytes, null) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, }; if (held) report.nodes_deduplicated += 1 else report.nodes_created += 1; } // --------------------------------------------------------------------------- // The verifier (DESIGN §6): re-derive every claim from real history alone. // The sidecar's record body and hashes are COMPARANDS here, never inputs — // the derivation, the file set, and the tree are all rebuilt independently // and the sidecar must match them byte for byte. This is the same oracle the // tests use, surfaced to the operator so the checkpoint's correctness is not // a claim only the test suite can check. // --------------------------------------------------------------------------- pub const Verdict = union(enum) { ok: BuildReport, no_checkpoint, /// The sidecar does not validate against the live log (unparseable, or a /// prefix key the log no longer backs) — the same conditions the open /// path treats as a silent miss, named for the operator. stale, /// §6.1: an independently captured prefix record body differs from the /// sidecar's — a resume from this sidecar would not equal a full derive. derivation_mismatch, /// §6.2: the recomputed canonical tree differs from the claim — the /// diverging artifact names are reported. tree_mismatch: TreeMismatch, /// The independently derived file set at C fingerprints differently — /// the deep check the open path deliberately never pays for. fileset_mismatch, }; /// Verify the sidecar against real history: open the repository with BOTH /// derived sidecars bypassed, re-capture the prefix record body, re-enumerate /// C's file set through the §9.3 seam, re-run batch tree construction, and /// compare everything. Reads the sidecar only to know what is being claimed. pub fn verify(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) BuildError!Verdict { // The claim (comparand). Absence is its own verdict; any validation // failure — container or prefix key — is `stale`. var probe = dir.openFile(io, checkpoint_filename, .{}) catch return .no_checkpoint; probe.close(io); var file = dir.openFile(io, repository.repo_filename, .{}) catch return error.Storage; defer file.close(io); const st = file.stat(io) catch return error.Storage; var fs = storage.FileStorage.init(io, file, st.size); var loaded = tryLoad(gpa, io, dir, fs.storageFile()) orelse return .stale; defer loaded.deinit(gpa); // The paranoid derivation: trust nothing derived. var repo: repository.Repository = undefined; repository.Repository.open(&repo, gpa, io, dir, .{ .bypass_cache = true }) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.NotARepo, error.CannotRead => error.Storage, error.Corrupt => error.Corrupt, }; defer repo.deinit(gpa); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; defer log.deinit(gpa); if (loaded.claim.shun_fp != log.shunFingerprint()) return .stale; // §6.1: the prefix record body, re-captured from the log alone. { var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); var pv: repoview.RepoView = undefined; pv.loadCapturingPrefix(gpa, &log, &body, loaded.claim.prefix_key.end) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; pv.deinit(gpa); if (!std.mem.eql(u8, body.items, loaded.body)) return .derivation_mismatch; } // C's file set, independently, through the seam. const c_rid = repo.view.lookup(loaded.claim.checkin_hash) orelse return .stale; var fl = (repo.files(gpa, c_rid) catch |e| return mapFilesError(e)) orelse return .stale; defer fl.deinit(gpa); var entries: std.ArrayList(artifact.FCard) = .empty; defer entries.deinit(gpa); var fit = fl.iterator(); while (fit.next()) |f| try entries.append(gpa, f); const fingerprint = try artifact.fileSetFingerprint(gpa, entries.items); if (!std.mem.eql(u8, &fingerprint, &loaded.claim.fileset_fingerprint)) return .fileset_mismatch; var report: BuildReport = .{ .shape = loaded.claim.shape, .gate = .off, .checkin_hash_buf = undefined, .checkin_hash_len = @intCast(loaded.claim.checkin_hash.len), .tree_hash_buf = undefined, .tree_hash_len = @intCast(loaded.claim.tree_hash.len), .files = entries.items.len, .prefix_frames = loaded.claim.prefix_key.frame_count, .nodes_created = 0, .nodes_deduplicated = 0, }; @memcpy(report.checkin_hash_buf[0..loaded.claim.checkin_hash.len], loaded.claim.checkin_hash); @memcpy(report.tree_hash_buf[0..loaded.claim.tree_hash.len], loaded.claim.tree_hash); // §6.2, tree shape only: recompute the canonical tree and compare H plus // every reachable artifact byte-for-byte against the store. if (loaded.claim.shape == .tree) { if (loaded.claim.k < artifact.min_k or loaded.claim.k > artifact.max_k) return .stale; var built = try artifact.buildTree(gpa, entries.items, @intCast(loaded.claim.k), loaded.claim.algo); defer built.deinit(); if (built.isFlat()) return .fileset_mismatch; // a tree claim over a set that no longer trees for (built.subs) |sub| { if (try treeNodeDiverges(gpa, &log, sub.name, sub.bytes)) |verdict| return verdict; } const root_bytes = artifact.serializeSubmanifestInterior(gpa, built.s) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.EmptySubmanifest => unreachable, }; defer gpa.free(root_bytes); var root_hex_buf: [64]u8 = undefined; const root_hex = artifact.artifactName(loaded.claim.algo, root_bytes, &root_hex_buf); if (!std.mem.eql(u8, root_hex, loaded.claim.tree_hash)) { return .{ .tree_mismatch = treeMismatch(root_hex, loaded.claim.tree_hash) }; } if (try treeNodeDiverges(gpa, &log, root_hex, root_bytes)) |verdict| return verdict; } return .{ .ok = report }; } /// One expected tree node against the store: absent bytes or different bytes /// under the expected name are a `tree_mismatch` naming that artifact. fn treeNodeDiverges(gpa: std.mem.Allocator, log: *logstore.LogStore, hex: []const u8, want: []const u8) BuildError!?Verdict { var digest: [logstore.max_hash_len]u8 = undefined; const raw = digest[0 .. hex.len / 2]; _ = std.fmt.hexToBytes(raw, hex) catch return error.Corrupt; const missing: Verdict = .{ .tree_mismatch = treeMismatch(hex, "") }; const rid = log.lookup(raw) orelse return missing; const stored = (log.content(gpa, rid) catch |e| return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }) orelse return missing; defer gpa.free(stored); if (!std.mem.eql(u8, stored, want)) return missing; return null; } pub const TreeMismatch = struct { expected_buf: [64]u8, expected_len: u8, found_buf: [64]u8, found_len: u8, pub fn expected(self: *const TreeMismatch) []const u8 { return self.expected_buf[0..self.expected_len]; } pub fn found(self: *const TreeMismatch) []const u8 { return self.found_buf[0..self.found_len]; } }; fn treeMismatch(expected: []const u8, found: []const u8) TreeMismatch { var out: TreeMismatch = .{ .expected_buf = undefined, .expected_len = @intCast(expected.len), .found_buf = undefined, .found_len = @intCast(found.len), }; @memcpy(out.expected_buf[0..expected.len], expected); @memcpy(out.found_buf[0..found.len], found); return out; } fn mapFilesError(e: repository.Repository.FilesError) BuildError { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.NotManifest => error.NotACheckin, // The check-in's own history is unreadable through the seam — the // repository, not the request, is at fault. error.NotARepo, error.CannotRead => error.Storage, error.Corrupt, error.BaselineUnresolvable, error.BaselineIsDelta => error.Corrupt, }; }