Skip to content
zit Artifact [415b657baf]
Login

Artifact [415b657baf]

Artifact 415b657baf90c5d0fdc5c875c491ffd5a13ce0e1f5e523d3f07ac1b4c76d6843:


//! 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";
const format_version: u16 = 1;

/// 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 {
    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. Refuse a delta manifest anywhere in the prefix (build-time O(prefix)
    //    parse — the explicit act pays it once; the resume path never does).
    {
        const rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory;
        defer gpa.free(rids);
        var pcache = logstore.PlaintextCache.init();
        defer pcache.deinit(gpa);
        for (rids) |rid| {
            if (log.offsetOf(rid) >= key.end) continue;
            const bytes = (log.contentCached(gpa, rid, &pcache) catch |e| return switch (e) {
                error.OutOfMemory => error.OutOfMemory,
                error.Storage => error.Storage,
                error.Corrupt => error.Corrupt,
            }) orelse continue;
            defer gpa.free(bytes);
            var im = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
                error.OutOfMemory => return error.OutOfMemory,
                else => continue, // a file blob, not a structural artifact
            };
            defer im.deinit(gpa);
            if (im.manifest.kind == .manifest and im.manifest.b != null) 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 (gate.treeK()) |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;
        }
    }

    // 9. The prefix record body — the same bytes `resumeFrom` replays.
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(gpa);
    var pv: repoview.RepoView = undefined;
    pv.loadCapturingPrefix(gpa, &log, &body, key.end) catch |e| return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };
    pv.deinit(gpa);

    // 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;
}

/// 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,
    };
}