Artifact 02512ca6469a141db9b32fccc31acd937f9a5ccf7a7078e31352bb65dd2278a4:
- File
src/update.zig
— part of check-in
[8797f53546]
at
2026-08-10 13:41:20
on branch sub-manifest
— update: cost tracks the diff, not the width
Three fixes, each found by measuring the previous one's residue.
markUnchanged: a path byte-identical between baseline and target -- same digest, same mode, with requireClean having already proven the on-disk file matches -- is skipped by every mutation phase: staging both directions, the undo journal, deletion and rewrite. An update used to delete and rewrite the entire tree regardless of how little changed.
.zit-vstat: the stat sidecar written at materialization is now the BASELINE PLAN -- version-bound, CRC'd, carrying mode and plan order -- so requireClean rebuilds the baseline with zero tree flattens when it describes the current check-in, and proves most files clean by inode/size/mtime instead of reconstructing their content out of the log. Advisory throughout: any mismatch, staleness or corruption falls back to the flatten and the content compare, which stay authoritative.
ManagedSet.fromSortedPaths: building the 66k-entry managed set through insert was quadratic -- an O(n) sorted-position memmove plus the LIVE sortedUnique assert per call -- and measured at 23.9 SECONDS of a pkgsrc update, larger than every real phase combined. The plan's own order is already sorted and unique, so the set is now built in one pass, order enforced per adjacent pair, invariant asserted once.
Measured, same store and targets as #52's gate: sqlite (2.2k files): 117.8s flat -> 0.6s adjacent / 1.2s distant pkgsrc (66k files): 42.2s flat -> 6.8s adjacent / 8.9s distant pkgsrc user CPU: ~25s -> 1.3s Residue is stat I/O (two ~66k-file sweeps), not computation.
The lockstep flatten prune #52 proposed was never built: the flatten measured 178ms at 66k files once the real costs fell.
Tests: dirty AND delete of a skipped-diff file both refuse with the tree untouched; sidecar intact/absent/rebound-to-wrong-check-in all produce identical trees; an unchanged file provably keeps its inode; an updated tree equals a from-scratch materialization. Closes #52. (user: dmestas, size: 150627) [annotate] [blame] [check-ins using]
//! Transactional working-tree materialization for `zit update CHECKIN`. //! //! This layer is deliberately the sole owner of update's filesystem effects: //! it validates and expands the requested manifest, stages every payload under //! an exclusive worktree-local transaction directory, proves the current tree //! clean, then writes `.zit-managed` before atomically committing //! `.zit-checkout`. const std = @import("std"); const artifact = @import("artifact.zig"); const checkout = @import("checkout.zig"); const logstore = @import("logstore.zig"); const crc32 = @import("crc32.zig"); const managed = @import("managed.zig"); const repository = @import("repository.zig"); const repostate = @import("repostate.zig"); const control_paths = @import("control_paths.zig"); /// The single-level undo/redo journal (contracts/undo.md). Re-exported so the /// CLI reaches one instance — the same one this module writes through the /// `record` seam below (mutual import; Zig resolves it, no comptime cycle). pub const journal = @import("undo.zig"); /// How one materialization decides which working-tree manifest files to write. /// Fossil derives this from a repository setting; zit does NOT read settings — /// the opt-in is explicit and per checkout, so what a checkout promises is /// exactly what the user asked it for. `open --manifest` is the only verb that /// `set`s it; every other materialization `inherit`s what the checkout record /// already carries, which is what makes the files stay refreshed. pub const ManifestOpt = union(enum) { inherit, set: []const u8, }; /// The working-tree file each `checkout.manifest_selectors` character names, /// in the same order. /// /// Public because the Git importer's mirror classifier reads the same list: /// the set of root files it may drop from an imported Fossil→Git mirror has /// to be exactly the set this module writes, and two copies of the list could /// drift into disagreeing about which names are metadata. pub const manifest_paths = [_][]const u8{ "manifest", "manifest.uuid", "manifest.tags" }; /// The line fossil appends to the on-disk `manifest` so the file cannot be /// mistaken for — or re-ingested as — the artifact it was copied from. /// /// Public for the same reason as `manifest_paths`: the importer's classifier /// undoes exactly this transformation, and it must undo the bytes this writer /// produces rather than a second literal that merely looks the same. pub const sterilizer = "# Remove this line to create a well-formed Fossil manifest.\n"; /// The reason text every verb uses for `ApplyError.StateRecordInvalid`. Shared /// so the update, open and merge diagnostics cannot drift into disagreeing /// about what the error means — and, in particular, so none of them can /// silently collapse back into the `Corrupt` "repository is corrupt" wording. /// The two errors are different claims: `Corrupt` means stored bytes do not /// match the name they are stored under, while this one means a stored /// artifact was not usable as a check-in state record, which says nothing /// about the repository's integrity. pub const state_record_invalid_reason = "a stored artifact is not a usable check-in state record"; /// Errors intentionally describe the caller-visible update decision rather /// than leaking individual `std.Io` platform errors. pub const ApplyError = error{ /// A file the check-in names has no artifact in this repository, or the /// artifact it names does not hash to the name. Distinct from `Corrupt` /// because it is a different claim: the repository read fine and the /// check-in parsed fine — one file's CONTENT is absent or wrong, and the /// path and hash below say which. `verify` cannot surface it, because it /// checks that stored artifacts are valid, never that referenced ones exist. FileContentMissing, InvalidCheckinId, CheckinNotFound, CheckinContentMissing, CheckinNameMismatch, TargetNotManifest, BaselineNotFound, BaselineContentMissing, BaselineNameMismatch, BaselineNotManifest, BaselineIsDelta, /// A stored artifact could not be turned into a check-in state record. /// This is a statement about one artifact's shape, not about repository /// integrity — see `Corrupt` for that. StateRecordInvalid, InvalidPath, ReservedPath, SymlinkUnsupported, CurrentStateInvalid, WorktreeNotClean, UnmanagedCollision, Storage, Corrupt, OutOfMemory, TransactionUnavailable, RollbackFailed, CleanupFailed, }; const ParentError = ApplyError || error{PathMissing}; const TestFault = enum { after_materialize, before_cleanup, merge_mid_apply, }; // Tests use these boundaries to deterministically exercise the transactional // branches that ordinary filesystem failures cannot reliably reach. Production // builds always return false and therefore retain their normal behavior. var test_fault: ?TestFault = null; fn shouldInjectTestFault(point: TestFault) bool { if (comptime @import("builtin").is_test) return test_fault == point; return false; } const PlanEntry = struct { path: []const u8, name: []const u8, digest: [logstore.max_hash_len]u8, digest_len: u8, mode: artifact.FileMode, /// This path is byte-identical (same content digest, same mode) in the /// BASELINE plan, and `requireClean` has already proven the file on disk /// matches the baseline -- so the correct on-disk state for it under the /// target is the file that is already there. Every mutation-phase loop /// skips it: it is not staged in either direction, not journaled, not /// deleted, not rewritten. Set by `markUnchanged` on `update`'s plans; /// never set on `open`'s (whose baseline is empty) or on any other path, /// so their behavior is exactly what it was. unchanged: bool = false, fn deinit(self: *PlanEntry, gpa: std.mem.Allocator) void { gpa.free(self.path); gpa.free(self.name); self.* = undefined; } fn digestSlice(self: *const PlanEntry) []const u8 { return self.digest[0..self.digest_len]; } }; const Plan = struct { entries: std.ArrayList(PlanEntry) = .empty, fn deinit(self: *Plan, gpa: std.mem.Allocator) void { for (self.entries.items) |*entry| entry.deinit(gpa); self.entries.deinit(gpa); self.* = undefined; } fn add(self: *Plan, gpa: std.mem.Allocator, file: artifact.FCard) ApplyError!void { const name = file.hash orelse return; try validateWorktreePath(file.filename); var digest: [logstore.max_hash_len]u8 = undefined; const decoded = try decodeValidatedName(name, &digest); const path = gpa.dupe(u8, file.filename) catch return error.OutOfMemory; errdefer gpa.free(path); const owned_name = gpa.dupe(u8, name) catch return error.OutOfMemory; errdefer gpa.free(owned_name); self.entries.append(gpa, .{ .path = path, .name = owned_name, .digest = digest, .digest_len = @intCast(decoded.len), .mode = file.mode, }) catch return error.OutOfMemory; } fn validateTopology(self: *const Plan) ApplyError!void { if (self.entries.items.len < 2) return; for (self.entries.items[1..], self.entries.items[0 .. self.entries.items.len - 1]) |entry, previous| { if (std.mem.eql(u8, entry.path, previous.path)) return error.InvalidPath; if (isPathPrefix(previous.path, entry.path)) return error.InvalidPath; } } }; const LoadedManifest = struct { bytes: []u8, manifest: artifact.Manifest, /// Non-null when `parseForImport` normalized a legacy trailing-separator F /// card: the manifest borrows this copy, not `bytes` (read-path only). normalized: ?[]u8 = null, fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void { self.manifest.deinit(); if (self.normalized) |b| gpa.free(b); gpa.free(self.bytes); self.* = undefined; } }; const BaselineContext = struct { expected_hash: ?[]const u8 = null, manifest: ?*const artifact.Manifest = null, }; /// The derived-state result of `assembleState`. Published (with /// `assembleState`) as the DST-facing entry into update's log→state /// derivation, so `src/sim/assembly_sweep.zig` can drive the §4 /// unclassifiable-artifact rule against an independent oracle without going /// through the worktree-mutating `applyCheckin` path. pub const StateAssembly = struct { gpa: std.mem.Allocator, mem: *repostate.MemStore, state: repostate.RepoState, target_rid: repostate.Rid, pub fn deinit(self: *StateAssembly) void { self.state.deinit(); self.mem.deinit(); self.gpa.destroy(self.mem); self.* = undefined; } }; const OpenedParent = struct { dir: std.Io.Dir, name: []const u8, owned: bool, fn close(self: OpenedParent, io: std.Io) void { if (self.owned) self.dir.close(io); } }; const ExistingPath = union(enum) { missing, stat: std.Io.File.Stat, }; /// Directories made while materializing the target are held as borrowed /// slices into the already-owned plan. Capacity is reserved before any /// worktree mutation so recording a successful `createDir` cannot fail. const CreatedDirectories = struct { paths: std.ArrayList([]const u8) = .empty, fn init(gpa: std.mem.Allocator, plan: *const Plan) ApplyError!CreatedDirectories { var capacity: usize = 0; for (plan.entries.items) |entry| { for (entry.path) |byte| { if (byte == '/') capacity += 1; } } var result = CreatedDirectories{}; result.paths.ensureTotalCapacity(gpa, capacity) catch return error.OutOfMemory; return result; } fn deinit(self: *CreatedDirectories, gpa: std.mem.Allocator) void { self.paths.deinit(gpa); self.* = undefined; } fn record(self: *CreatedDirectories, path: []const u8) void { self.paths.appendAssumeCapacity(path); } fn remove(self: *const CreatedDirectories, io: std.Io, worktree: std.Io.Dir) ApplyError!void { var index = self.paths.items.len; while (index > 0) { index -= 1; const path = self.paths.items[index]; const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) { error.PathMissing => continue, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.FileNotFound => {}, error.DirNotEmpty => return error.Storage, else => return error.Storage, }; } } }; /// Materialize canonical `target_id` into `worktree`. /// /// `target_id` is a public canonical artifact name. It is decoded to a raw /// digest for every `LogStore.lookup`; textual names are never used as log /// index keys. pub fn applyCheckin( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, target_id: []const u8, manifest: ManifestOpt, ) ApplyError!void { var target = try buildPlan(gpa, log, target_id); defer target.deinit(gpa); // The effective manifest opt-in for this materialization AND for the record // it commits. Read before anything mutates, and held in a function-scoped // buffer the borrowed slice outlives. var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const manifest_flags = switch (manifest) { .set => |f| f, .inherit => blk: { const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err); break :blk if (cur) |c| c.manifest_flags else ""; }, }; var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer old_managed.deinit(); var old = try requireClean(gpa, io, worktree, log, &old_managed); defer old.deinit(gpa); const want_tags = std.mem.indexOfScalar(u8, manifest_flags, 't') != null; var facts = cachedTargetFacts(gpa, io, worktree, target_id, want_tags) orelse try deriveTargetFacts(gpa, log, target_id, want_tags); defer facts.deinit(gpa); const branch = facts.branch; // Bulk-built from the plan's own (sorted, unique) order: the one-at-a-time // `insert` loop this replaces was quadratic and measured at 23.9s of a // 68k-file update -- larger than every real phase of the verb combined. const plan_paths = gpa.alloc([]const u8, target.entries.items.len) catch return error.OutOfMemory; defer gpa.free(plan_paths); for (target.entries.items, plan_paths) |entry, *slot| slot.* = entry.path; var desired_managed = managed.ManagedSet.fromSortedPaths(gpa, plan_paths) catch |err| return mapManagedError(err); defer desired_managed.deinit(); // The managed set this update will commit is EXACTLY the target plan's // membership: one entry per plan path. `validateTopology` proved those paths // unique (and non-nesting), so every dedup insert added a distinct path — // the two can never disagree in count, the first half of the managed-set / // record / tree agreement the commit below depends on. std.debug.assert(desired_managed.iterate().len == target.entries.items.len); // Both plans are final from here on, so the unchanged marks computed now // hold for every phase below. try markUnchanged(gpa, &target, &old); const txn_name = try createTransaction(gpa, io, worktree); defer gpa.free(txn_name); var transaction_live = true; defer if (transaction_live) worktree.deleteTree(io, txn_name) catch {}; var txn = worktree.openDir(io, txn_name, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; defer txn.close(io); txn.createDir(io, "next", .default_dir) catch return error.Storage; txn.createDir(io, "previous", .default_dir) catch return error.Storage; var next = txn.openDir(io, "next", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; defer next.close(io); var previous = txn.openDir(io, "previous", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; defer previous.close(io); // No worktree data is touched until both the desired and rollback payloads // are verified and durably staged in this same-filesystem transaction dir. try stagePlan(gpa, io, log, next, &target); try stagePlan(gpa, io, log, previous, &old); try preflight(gpa, io, worktree, &target, &old_managed); var created_directories = try CreatedDirectories.init(gpa, &target); defer created_directories.deinit(gpa); // Journal the undo image (B-undo-3/14): the pre-mutation bytes of every path // this update writes or deletes — `old_managed ∪ target` — plus the current // `.zit-checkout`/`.zit-managed`. Written and fsynced HERE, before // `commitMaterialization` moves the first working-file byte. Placed after // `requireClean`/`preflight`, so a rejected update never journals; the // rollback branch below clears it so a cleanly-undone update leaves no level. try recordUndoImage(gpa, io, worktree, &old, &target); commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{ .version = target_id, .branch = branch, // `update` never carries a pending merge (that is `merge`'s job); a // plain check-out always clears it — and never a pending special merge. .merge_parent = "", .special_op = "", .manifest_flags = manifest_flags, }, .{ .log = log, .target_id = target_id, .tags = facts.tags }) catch |err| { rollbackMaterialization(gpa, io, worktree, previous, &target, &old, &old_managed, &created_directories) catch { // The staged `previous/` tree is the only complete recovery copy. // Retain it for recovery rather than letting the deferred cleanup // destroy it after a failed inverse. The undo journal is retained // too: it is the crash-recovery image for the half-applied tree. transaction_live = false; return error.RollbackFailed; }; // Clean rollback: the tree is back to pre-mutation, so there is nothing // to undo — drop the journal (keeps B-undo-9 honest). journal.clear(io, worktree); return err; }; // The tree and record are committed; remember what was just written so // the next clean check can stat instead of reconstruct. After the commit // point deliberately: a sidecar describing a tree that failed to commit // would be a lie, while a missing sidecar is only slowness. VStat.writeForPlan(gpa, io, worktree, &target, target_id); // `.zit-checkout` is the commit point. A post-commit cleanup error is // reported but must never roll the committed tree or metadata back. transaction_live = false; if (shouldInjectTestFault(.before_cleanup)) return error.CleanupFailed; worktree.deleteTree(io, txn_name) catch return error.CleanupFailed; } /// Build the affected-path set (`old_managed ∪ target`, deduplicated by a /// throwaway `ManagedSet`) and hand it to the undo journal (B-undo-14: exactly /// one seam per mutator). The set unifies both directions of the mutation — a /// path the update writes, a path it deletes — so `zit undo` restores every one. fn recordUndoImage( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, old: *const Plan, target: *const Plan, ) ApplyError!void { var affected = managed.ManagedSet.init(gpa); defer affected.deinit(); // The OLD PLAN stands in for the old managed set: `requireClean` proved // their memberships identical before this runs, and the plan carries the // unchanged marks the set cannot. An unchanged path is neither written // nor deleted by this update, so B-undo-3's "every path this update // writes or deletes" excludes it by its own words. for (old.entries.items) |entry| { if (entry.unchanged) continue; _ = affected.insert(entry.path) catch |err| return mapManagedError(err); } for (target.entries.items) |entry| { if (entry.unchanged) continue; _ = affected.insert(entry.path) catch |err| return mapManagedError(err); } // The affected set is the union of both plans' CHANGED entries, journaled // before the first working byte moves (B-undo-3/14). It can therefore never // be larger than either plan, and never smaller than the target's changed // count -- the direction the durability rule actually needs. std.debug.assert(affected.iterate().len <= old.entries.items.len + target.entries.items.len); journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // A journal device fault must abort BEFORE any working file moves: the // durability rule forbids mutating without a recoverable pre-image. error.Storage, error.Corrupt, error.Empty => return error.Storage, }; } fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!Plan { if (!artifact.validateName(target_id)) return error.InvalidCheckinId; var target_digest: [logstore.max_hash_len]u8 = undefined; const raw_target = try decodeValidatedName(target_id, &target_digest); const target_rid = log.lookup(raw_target) orelse return error.CheckinNotFound; const target_bytes = log.content(gpa, target_rid) catch |err| return mapContentError(err); if (target_bytes == null) return error.CheckinContentMissing; var target = LoadedManifest{ .bytes = target_bytes.?, .manifest = undefined, }; { errdefer gpa.free(target.bytes); if (!artifact.verify(target_id, target.bytes)) return error.CheckinNameMismatch; // Read path: verify identity over the original bytes, then tolerate the // legacy F-card trailing separator when parsing fossil-origin history. const im = artifact.parseForImport(gpa, target.bytes) catch |err| return mapTargetParseError(err); target.manifest = im.manifest; target.normalized = im.normalized; } defer target.deinit(gpa); if (target.manifest.kind != .manifest) return error.TargetNotManifest; // One memo across BOTH flattens below. Target and baseline share every node // the check-in did not touch, so the baseline walk answers from here instead // of re-reading the store. Owned by the plan build: it dies with this call, // caching nothing across invocations. var sub_memo: ?repostate.SubManifestMemo = null; defer if (sub_memo) |*m| m.deinit(); // submanifests §5.1: a tree check-in's file set lives behind its S cards, so // walk it into a flat card list before §8 ever looks. §4.4 makes this // exclusive with the B-card branch below (never both B and S), so a check-in // is walked or resolved, never both. try flattenIfTree(gpa, log, &target.manifest, &sub_memo); var baseline: ?LoadedManifest = null; defer if (baseline) |*item| item.deinit(gpa); var baseline_context = BaselineContext{}; if (target.manifest.b) |b| { var digest: [logstore.max_hash_len]u8 = undefined; const raw = decodeValidatedName(b.hash, &digest) catch return error.BaselineNotFound; const rid = log.lookup(raw) orelse return error.BaselineNotFound; const bytes = log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return error.BaselineContentMissing; var item = LoadedManifest{ .bytes = bytes.?, .manifest = undefined }; { errdefer gpa.free(item.bytes); if (!artifact.verify(b.hash, item.bytes)) return error.BaselineNameMismatch; const im = artifact.parseForImport(gpa, item.bytes) catch |err| return mapBaselineParseError(err); item.manifest = im.manifest; item.normalized = im.normalized; } if (item.manifest.kind != .manifest) { item.deinit(gpa); return error.BaselineNotManifest; } baseline = item; // submanifests §5.4: "a delta manifest MAY name a tree manifest in its B // card ... the baseline cursor of the merge-walk ... is the enumeration // of Section 5.1." Flattening here makes that composition free. try flattenIfTree(gpa, log, &baseline.?.manifest, &sub_memo); baseline_context.expected_hash = b.hash; baseline_context.manifest = &baseline.?.manifest; } const target_files = repository.manifestFilesOf(&target.manifest); var walk = artifact.enumerate(target_files, .{ .ctx = @ptrCast(&baseline_context), .resolveFn = resolveBaseline, }) catch |err| switch (err) { error.BaselineUnresolvable => return error.BaselineNotFound, error.BaselineIsDelta => return error.BaselineIsDelta, }; var result = Plan{}; errdefer result.deinit(gpa); while (walk.next()) |file| { if (file.hash == null) continue; try result.add(gpa, file); } try result.validateTopology(); return result; } /// submanifests §5.1/§5.4: derive `manifest`'s complete file set when it is a /// tree manifest, leaving a flat or delta manifest untouched. Runs before the §8 /// enumeration below, so `artifact.enumerate` and the merge walk stay unchanged /// (§5.4: "the merge-walk itself is unchanged"). /// /// The §5.2 resolve-verify-parse walk itself is NOT reimplemented here. /// `Repository.flattenTreeFromLog` already takes an already-open LogStore — /// precisely what this module holds — so update reaches the same resolver /// `commit` writes through, and §5.2 has exactly one implementation in the tree. /// That matters beyond deduplication: a second copy drifted once already, over /// the §3.5 rule that an S card's hash is case-insensitive. The shared resolver /// decodes the token to raw digest bytes and compares those, so an uppercase /// spelling names the same artifact — which is what §3.5 says it is. /// /// Every §5.1 fault becomes `BaselineNotFound`, the code this file already uses /// for §8.3's `BaselineUnresolvable`. §5.2 puts S resolution "under the same two /// disciplines" as the B card and mandates hard failure without mandating a /// distinct code; the five-way `TreeError` distinction the conformance vectors /// test lives at the artifact layer, where it is observable. The store faults /// stay distinct: the mapping below is the exact inverse of the one /// `Repository`'s log source applies, so no `ApplyError` consumer loses a /// distinction it could branch on. /// `slot` is a caller-owned memo shared by the TARGET and BASELINE flattens of /// one plan. §5.4 lets a delta manifest name a tree manifest in its B card, so /// those two walks run over trees that differ only where the check-in changed — /// which on a wide repository is a handful of paths out of tens of thousands. /// Without the memo each walk re-inflates and re-parses every shared node from /// the store, and the second one learns nothing from the first. /// /// This does NOT make the cost proportional to what changed: both walks still /// enumerate the whole tree. It removes the redundant PARSE, not the redundant /// WALK. Pruning the walk needs the lockstep descent `pack.zig` uses, which is /// a larger change (#36). fn flattenIfTree( gpa: std.mem.Allocator, log: *logstore.LogStore, manifest: *artifact.Manifest, slot: *?repostate.SubManifestMemo, ) ApplyError!void { if (manifest.s.len == 0) return; // §5: a tree manifest is exactly an S-bearing one repository.Repository.flattenTreeFromLogMemo(gpa, log, manifest, slot) catch |err| return switch (err) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, // The log source renames the LogStore's `Storage` on the way out; rename // it back so a store fault still reads as one here. error.CannotRead => error.Storage, // artifact.TreeError, listed rather than caught by `else` so a new §5.1 // fault code has to be triaged here instead of silently collapsing. error.SubUnresolvable, error.DepthExceeded, error.ReferenceCycle, error.FirstFilenameMismatch, error.OrderViolation, => error.BaselineNotFound, // §5.2 hard failure }; std.debug.assert(manifest.flattened != null); // §5.2: never a partial file set } fn resolveBaseline(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles { const baseline: *const BaselineContext = @ptrCast(@alignCast(context)); const expected = baseline.expected_hash orelse return null; const manifest = baseline.manifest orelse return null; if (!std.mem.eql(u8, expected, hash)) return null; return repository.manifestFilesOf(manifest); } fn requireClean( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, current_managed: *const managed.ManagedSet, ) ApplyError!Plan { for (current_managed.iterate()) |path| { validateWorktreePath(path) catch return error.CurrentStateInvalid; } var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const current = checkout.read(io, worktree, &checkout_buf) catch |err| return mapCheckoutError(err); // An UNBORN record (`version == ""`) names no check-in, so there is no plan // to build against — the same situation as an absent record, and handled by // the same arm so the two cannot drift apart. Unborn is the normal, expected // state of every fresh repository: `init` writes it unconditionally (and // `clone` delegates repo creation to `init`), so without this the first // `update` after a clone would hand the empty version to `buildPlan`, fail // `artifact.validateName("")`, and report `CurrentStateInvalid` — a clean, // correct tree diagnosed as broken. if (current == null or current.?.version.len == 0) { if (current_managed.iterate().len != 0) return error.WorktreeNotClean; // A record that names no check-in yet claims a merge parent or a // pending special operation is incoherent, not a fresh clone: both // describe work in progress against a check-in that the record does not // name. Only a present record can carry them. if (current) |c| { if (c.merge_parent.len != 0 or c.special_op.len != 0) return error.CurrentStateInvalid; } return Plan{}; } var vstat = VStat.read(gpa, io, worktree); defer vstat.deinit(gpa); // The sidecar-as-plan fast path: when the sidecar describes exactly the // current check-in AND its membership agrees with the authoritative // managed set, the baseline plan is reconstructed with no tree flatten at // all -- removing one of the two O(width) walks that made `update` cost // the same regardless of how little changed. A sidecar that fails either // test is discarded (not refused): staleness costs a flatten, never an // error, and every entry is still individually verified below before // anything acts on it. var plan = blk: { if (vstat.planFor(gpa, current.?.version)) |candidate_const| { var candidate = candidate_const; if (sameMembership(&candidate, current_managed)) break :blk candidate; candidate.deinit(gpa); } break :blk buildPlan(gpa, log, current.?.version) catch |err| switch (err) { error.OutOfMemory, error.Storage, error.Corrupt => return err, else => return error.CurrentStateInvalid, }; }; errdefer plan.deinit(gpa); if (!sameMembership(&plan, current_managed)) return error.WorktreeNotClean; for (plan.entries.items) |*entry| { // Stat-first: a sidecar row carrying THIS entry's digest whose inode, // size and mtime still match the file proves it unmodified since the // materialization that wrote both. Everything else takes the full // content compare, which stays the authority. if (entry.mode != .symlink) { const existing = try statExisting(io, worktree, entry.path); switch (existing) { .missing => return error.WorktreeNotClean, .stat => |stat| if (modeMatches(stat, entry.mode) and vstat.proves(entry, stat)) continue, } } if (!try pathMatchesEntry(gpa, io, worktree, log, entry)) return error.WorktreeNotClean; } // Postcondition: a returned plan is the current check-in's, and its // membership equals the recorded managed set — the guard above rejected any // drift, so on every success path the record and the managed set agree. std.debug.assert(sameMembership(&plan, current_managed)); return plan; } fn sameMembership(plan: *const Plan, current: *const managed.ManagedSet) bool { const paths = current.iterate(); if (paths.len != plan.entries.items.len) return false; for (paths, plan.entries.items) |path, entry| { if (!std.mem.eql(u8, path, entry.path)) return false; } return true; } /// The stat sidecar: `(path, inode, size, mtime, digest)` for every file the /// last materialization wrote, so the NEXT clean check can prove "unmodified /// since we wrote it" with one stat instead of reconstructing the file's /// plaintext out of the log. On a store with deep per-file delta chains that /// reconstruction dominated `update` outright: a no-op update measured ~109s /// of pure CPU, all of it `requireClean` re-deriving content that had not /// moved. /// /// Advisory, never authoritative. A row is trusted only when its digest is /// the digest the CURRENT baseline expects AND inode, size and mtime all /// still match the file on disk; anything else -- absent file, absent row, /// stale digest, any stat drift, unreadable or malformed sidecar -- falls /// back to the full content compare below, which remains the authority. The /// accepted trade is fossil's own vfile trade: an edit that restores mtime, /// size AND inode exactly is invisible to the stat check. const VStat = struct { const filename = ".zit-vstat"; const magic = "zitvstat"; /// v2: the sidecar carries the check-in it describes plus each entry's /// mode, a whole-file CRC, and PLAN ORDER -- which together make it the /// BASELINE PLAN, not merely a stat memo. A v1 file fails the version /// check, reads as empty, and costs one flatten to rebuild. const version: u16 = 2; const Row = struct { inode: u64, size: u64, mtime_ns: i96, digest: [logstore.max_hash_len]u8, digest_len: u8, mode: artifact.FileMode, }; const Entry = struct { path: []const u8, row: Row, }; /// The check-in whose materialization wrote these rows. The rows are only /// meaningful AS A PLAN for exactly this version; `planFor` refuses any /// other, and staleness therefore costs a flatten rather than an error. checkin: []const u8 = &.{}, /// In plan order -- `artifact.enumerate` emits paths sorted, `writeForPlan` /// preserves that, and `validateTopology` depends on it. entries: std.ArrayList(Entry) = .empty, index: std.StringHashMapUnmanaged(usize) = .empty, fn appendLittle(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime T: type, value: T) error{OutOfMemory}!void { var buf: [@divExact(@typeInfo(T).int.bits, 8)]u8 = undefined; std.mem.writeInt(T, &buf, value, .little); try out.appendSlice(gpa, &buf); } fn deinit(self: *VStat, gpa: std.mem.Allocator) void { if (self.checkin.len != 0) gpa.free(self.checkin); for (self.entries.items) |entry| gpa.free(entry.path); self.entries.deinit(gpa); self.index.deinit(gpa); self.* = undefined; } /// Absent, unreadable, malformed, torn, or wrong-version reads as EMPTY -- /// the sidecar can only ever remove work, never add a failure mode. fn read(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) VStat { var self: VStat = .{}; var file = worktree.openFile(io, filename, .{}) catch return self; defer file.close(io); const st = file.stat(io) catch return self; const size: usize = std.math.cast(usize, st.size) orelse return self; const buf = gpa.alloc(u8, size) catch return self; defer gpa.free(buf); const n = file.readPositionalAll(io, buf, 0) catch return self; self.parse(gpa, buf[0..n]) catch { self.deinit(gpa); self = .{}; }; return self; } fn parse(self: *VStat, gpa: std.mem.Allocator, bytes: []const u8) !void { const crc_len = 4; if (bytes.len < magic.len + 4 + crc_len) return error.Corrupt; // Whole-file CRC first: a torn write must read as absence, never as a // shorter-but-plausible plan. const stored_crc = std.mem.readInt(u32, bytes[bytes.len - crc_len ..][0..4], .little); var crc = crc32.Crc32.init(); crc.update(bytes[0 .. bytes.len - crc_len]); if (crc.final() != stored_crc) return error.Corrupt; const body = bytes[0 .. bytes.len - crc_len]; var pos: usize = 0; if (!std.mem.eql(u8, body[0..magic.len], magic)) return error.Corrupt; pos = magic.len; if (std.mem.readInt(u16, body[pos..][0..2], .little) != version) return error.Corrupt; pos += 2; const checkin_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little); pos += 2; if (body.len - pos < checkin_len) return error.Corrupt; self.checkin = try gpa.dupe(u8, body[pos..][0..checkin_len]); pos += checkin_len; while (pos < body.len) { if (body.len - pos < 2) return error.Corrupt; const path_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little); pos += 2; const fixed = 8 + 8 + 12 + 1; if (body.len - pos < path_len + fixed) return error.Corrupt; const path = body[pos..][0..path_len]; pos += path_len; var row: Row = undefined; row.inode = std.mem.readInt(u64, body[pos..][0..8], .little); pos += 8; row.size = std.mem.readInt(u64, body[pos..][0..8], .little); pos += 8; row.mtime_ns = @bitCast(std.mem.readInt(u96, body[pos..][0..12], .little)); pos += 12; row.digest_len = body[pos]; pos += 1; if (row.digest_len > logstore.max_hash_len or body.len - pos < row.digest_len + 1) return error.Corrupt; @memcpy(row.digest[0..row.digest_len], body[pos..][0..row.digest_len]); pos += row.digest_len; const mode_byte = body[pos]; pos += 1; if (mode_byte > @intFromEnum(artifact.FileMode.symlink)) return error.Corrupt; row.mode = @enumFromInt(mode_byte); const owned = try gpa.dupe(u8, path); errdefer gpa.free(owned); const gop = try self.index.getOrPut(gpa, owned); if (gop.found_existing) { gpa.free(owned); return error.Corrupt; // a duplicate path is not a record this writer produces } gop.value_ptr.* = self.entries.items.len; try self.entries.append(gpa, .{ .path = owned, .row = row }); } } /// Whether `entry`'s file can be proven unmodified by stat alone. fn proves(self: *const VStat, entry: *const PlanEntry, stat: std.Io.File.Stat) bool { const at = self.index.get(entry.path) orelse return false; const row = self.entries.items[at].row; if (row.digest_len != entry.digest_len) return false; if (!std.mem.eql(u8, row.digest[0..row.digest_len], entry.digestSlice())) return false; if (row.inode != @as(u64, @bitCast(stat.inode))) return false; if (row.size != stat.size) return false; return row.mtime_ns == stat.mtime.nanoseconds; } /// The BASELINE PLAN, reconstructed with no flatten, when and only when /// this sidecar describes exactly `checkin_id`. This is the step that /// removes one of `update`'s two O(width) tree walks: the rows were /// serialized from the plan the last materialization ran, in plan order, /// under a whole-file CRC -- the same trust `.zit-managed` gets, and less /// than `.zit-cache` gets, since every entry is still individually /// verified (stat-first, content on any doubt) by `requireClean` before /// anything acts on it. Null on any shortfall; the caller flattens. fn planFor(self: *const VStat, gpa: std.mem.Allocator, checkin_id: []const u8) ?Plan { if (self.checkin.len == 0 or !std.mem.eql(u8, self.checkin, checkin_id)) return null; var plan = Plan{}; var ok = false; defer if (!ok) plan.deinit(gpa); for (self.entries.items) |entry| { validateWorktreePath(entry.path) catch return null; var name_buf: [64]u8 = undefined; const name = encodeName(entry.row.digest[0..entry.row.digest_len], &name_buf); const path = gpa.dupe(u8, entry.path) catch return null; const owned_name = gpa.dupe(u8, name) catch { gpa.free(path); return null; }; plan.entries.append(gpa, .{ .path = path, .name = owned_name, .digest = entry.row.digest, .digest_len = entry.row.digest_len, .mode = entry.row.mode, }) catch { gpa.free(path); gpa.free(owned_name); return null; }; } plan.validateTopology() catch return null; ok = true; return plan; } /// Stat every plan entry and write the sidecar, atomically, mirroring the /// managed set's writer. Best-effort at every step -- EXCEPT that a file /// that cannot be stat'ed abandons the write entirely: v2's rows are a /// plan, and a plan missing an entry would reconstruct a baseline missing /// a file, where v1's per-row omission only cost a content compare. fn writeForPlan(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, plan: *const Plan, checkin_id: []const u8) void { if (checkin_id.len == 0 or checkin_id.len > std.math.maxInt(u16)) return; var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); out.appendSlice(gpa, magic) catch return; appendLittle(gpa, &out, u16, version) catch return; appendLittle(gpa, &out, u16, @intCast(checkin_id.len)) catch return; out.appendSlice(gpa, checkin_id) catch return; for (plan.entries.items) |entry| { if (entry.path.len > std.math.maxInt(u16)) return; const existing = statExisting(io, worktree, entry.path) catch return; const stat = switch (existing) { .missing => return, .stat => |value| value, }; appendLittle(gpa, &out, u16, @intCast(entry.path.len)) catch return; out.appendSlice(gpa, entry.path) catch return; appendLittle(gpa, &out, u64, @bitCast(stat.inode)) catch return; appendLittle(gpa, &out, u64, stat.size) catch return; appendLittle(gpa, &out, u96, @bitCast(stat.mtime.nanoseconds)) catch return; appendLittle(gpa, &out, u8, entry.digest_len) catch return; out.appendSlice(gpa, entry.digestSlice()) catch return; appendLittle(gpa, &out, u8, @intFromEnum(entry.mode)) catch return; } var crc = crc32.Crc32.init(); crc.update(out.items); appendLittle(gpa, &out, u32, crc.final()) catch return; var af = worktree.createFileAtomic(io, 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 pathMatchesEntry( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, entry: *const PlanEntry, ) ApplyError!bool { const existing = try statExisting(io, worktree, entry.path); const stat = switch (existing) { .missing => return false, .stat => |value| value, }; switch (entry.mode) { .symlink => if (stat.kind != .sym_link) return false, .regular, .executable => if (stat.kind != .file or !modeMatches(stat, entry.mode)) return false, } const rid = log.lookup(entry.digestSlice()) orelse return error.CurrentStateInvalid; const expected = log.content(gpa, rid) catch |err| return mapContentError(err); if (expected == null) return error.CurrentStateInvalid; defer gpa.free(expected.?); if (!artifact.verify(entry.name, expected.?)) return error.CurrentStateInvalid; const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) { error.PathMissing => return false, else => return @errorCast(err), }; defer parent.close(io); switch (entry.mode) { .symlink => { const actual = gpa.alloc(u8, std.math.add(usize, expected.?.len, 1) catch return error.OutOfMemory) catch return error.OutOfMemory; defer gpa.free(actual); const n = parent.dir.readLink(io, parent.name, actual) catch return false; return n == expected.?.len and std.mem.eql(u8, actual[0..n], expected.?); }, .regular, .executable => { if (stat.size != expected.?.len) return false; var file = parent.dir.openFile(io, parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return false; defer file.close(io); const actual = gpa.alloc(u8, expected.?.len) catch return error.OutOfMemory; defer gpa.free(actual); const n = file.readPositionalAll(io, actual, 0) catch return error.Storage; return n == actual.len and std.mem.eql(u8, actual, expected.?); }, } } /// What one materialization needs out of the derived repository state: the /// branch the record commits, and — only when the `t` selector asked for it — /// the check-in's symbolic tag names. Both come from ONE state derive; a second /// pass just for the tags would double the most expensive read on the path. const TargetFacts = struct { branch: []u8, /// One symbolic tag name per line, newline-terminated; empty when `t` was /// not requested (and when the check-in carries no symbolic tag). tags: []u8, fn deinit(self: *TargetFacts, gpa: std.mem.Allocator) void { gpa.free(self.branch); gpa.free(self.tags); self.* = undefined; } }; /// The cached-view fast path for the facts below: the same questions answered /// from the `.zit-cache`-backed `Repository` view instead of `assembleState`'s /// from-scratch sweep over every artifact in the store. On a warm cache this /// is milliseconds where the sweep was tens of seconds, and the answers are /// the same by the cache's own contract -- the view it serves is proven /// equivalent to a cold derive. Null on ANY shortfall (no cache, open failure, /// target not in the view), and the caller falls back to the sweep, which /// stays the authority. fn cachedTargetFacts( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, target_id: []const u8, want_tags: bool, ) ?TargetFacts { var repo: repository.Repository = undefined; repo.open(gpa, io, root_dir, .{}) catch return null; defer repo.deinit(gpa); const rid = repo.lookup(target_id) orelse return null; const owned_branch = gpa.dupe(u8, repo.branchOf(rid)) catch return null; errdefer gpa.free(owned_branch); const tags: []u8 = if (want_tags) tagsListing(gpa, repo.state(), rid, owned_branch) catch { gpa.free(owned_branch); return null; } else &.{}; return .{ .branch = owned_branch, .tags = tags }; } fn deriveTargetFacts( gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8, want_tags: bool, ) ApplyError!TargetFacts { var assembly = try assembleState(gpa, log, target_id); defer assembly.deinit(); var config = log.readConfig(gpa) catch |err| return mapContentError(err); defer if (config) |*owned| owned.deinit(gpa); // Storage deliberately leaves an absent config to its caller. The // repository-state convention is the same `trunk` fallback used by the // existing state tests; a present config always overrides it. // An empty stored value is treated as unset, matching `RepoView.loadInner`; // `branchOf` requires a non-empty name and an empty one would trip it. const main_branch = blk: { if (config) |owned| { if (owned.config.default_branch.len != 0) break :blk owned.config.default_branch; } break :blk "trunk"; }; // This assembly builds its own bare `RepoState`, so nothing has told it // §6.1's configured name -- `RepoView` is what does that on the ordinary // load path. Tell it here, at the point the fact becomes known, so the // state's own `branchValueOf` (the §7.2 leaf test) and `branchOf` below // resolve an untagged check-in to the SAME branch. Leaving them to // disagree is what made an imported repository's untagged check-ins // display as one branch and leaf-test as another. assembly.state.main_branch = main_branch; const branch = assembly.state.branchOf(assembly.target_rid, main_branch); const owned_branch = gpa.dupe(u8, branch) catch return error.OutOfMemory; errdefer gpa.free(owned_branch); const tags: []u8 = if (want_tags) try tagsListing(gpa, &assembly.state, assembly.target_rid, branch) else &.{}; return .{ .branch = owned_branch, .tags = tags }; } /// Fossil's `manifest.tags` layout: the branch line, then one `tag` line per /// SYMBOLIC tag active on this check-in. Reserved non-symbolic names /// ("branch", "closed", ...) are not names a user checks out and are excluded, /// as fossil excludes them. Fossil emits its tag lines in tag-creation order, /// which no other repository can reproduce; `tagsOf` returns the active rows /// name-ascending, so zit's file is the same lines in a deterministic order. fn tagsListing(gpa: std.mem.Allocator, state: *const repostate.RepoState, rid: repostate.Rid, branch: []const u8) ApplyError![]u8 { const sym_prefix = "sym-"; const entries = state.tagsOf(gpa, rid) catch return error.OutOfMemory; defer gpa.free(entries); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); out.print(gpa, "branch {s}\n", .{branch}) catch return error.OutOfMemory; for (entries) |e| { if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue; out.print(gpa, "tag {s}\n", .{e.name[sym_prefix.len..]}) catch return error.OutOfMemory; } return out.toOwnedSlice(gpa) catch return error.OutOfMemory; } /// State-record classification is intentionally conservative: any successful /// classified manifest is crosslinked, any successful classified control /// artifact ingests only tag effects, and all other successful classifications /// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque: /// the log frame format has no structural-kind bit to distinguish a blob from /// malformed cards. Integrity failures remain fatal. /// /// A classification failure is NOT fatal, and is not evidence of damage. A /// conforming repository contains artifacts a conforming parser declines: the /// artifact grammar requires the U card on a WIKI artifact, fossil elides it /// when the submitter is "nobody", and fossil stores and syncs the result, so a /// clone of an intact fossil repository carries such artifacts. Repository /// state §4 separates the two conditions by the name: bytes that hash to the /// name they are stored under are stored correctly and merely unclassifiable, /// so they are opaque to state and skipped; bytes that do not are an integrity /// failure and stay fatal. Verification is what distinguishes them, so it runs /// before the skip rather than after — see the arm below. /// /// This is why assembly must not answer a decline with a corruption claim: the /// verified name has already ruled damage out. pub fn assembleState(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!StateAssembly { const mem = gpa.create(repostate.MemStore) catch return error.OutOfMemory; mem.* = repostate.MemStore.init(gpa); errdefer { mem.deinit(); gpa.destroy(mem); } var state = repostate.RepoState.init(gpa, mem.store()); errdefer state.deinit(); var target_rid: ?repostate.Rid = null; const persisted_rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory; defer gpa.free(persisted_rids); // Same memo, same reason as `RepoView.loadInner`: bases persist strictly // before their deltas, so persisted-order resolution keeps each base hot // and collapses delta-chain read-amplification to ~1x. This walk ran // WITHOUT one while duplicating loadInner's sweep, which made every // update and commit re-reconstruct each delta chain from its root. var plaintext = logstore.PlaintextCache.init(); defer plaintext.deinit(gpa); var raw_name: [64]u8 = undefined; for (persisted_rids) |rid| { const name = encodeName(log.hashOf(rid), &raw_name); const bytes = log.contentCached(gpa, rid, &plaintext) catch |err| return mapContentError(err); if (bytes == null) continue; defer gpa.free(bytes.?); // Read path: repostate recovery over persisted (fossil-origin) history // tolerates the legacy F-card trailing separator. var parsed = artifact.parseForImport(gpa, bytes.?) catch |err| switch (err) { error.SyntaxError => continue, error.OutOfMemory => return error.OutOfMemory, // Classification declined the artifact. Repository state §4 splits // this from damage by the name: bytes that hash to the name they // are stored under arrived and were stored correctly, so the only // event is that this reader's grammar does not admit them. Such an // artifact is opaque to state — it is skipped exactly as a // non-participating type is. Bytes that do NOT hash to their name // are the other condition, and stay fatal. // // A conforming repository reaches this: the artifact grammar makes // the U card required on a WIKI artifact, fossil elides it when the // submitter is "nobody", and fossil stores and syncs the result. else => { if (artifact.verify(name, bytes.?)) continue; return error.Corrupt; }, }; defer parsed.deinit(gpa); switch (parsed.manifest.kind) { .manifest => { if (!artifact.verify(name, bytes.?)) return error.Corrupt; const record = try manifestRecord(gpa, name, &parsed.manifest); defer { gpa.free(record.parents); gpa.free(record.tags); } const state_rid = state.crosslink(record) catch return error.OutOfMemory; if (std.mem.eql(u8, name, target_id)) target_rid = state_rid; }, .control => { if (!artifact.verify(name, bytes.?)) return error.Corrupt; const record = try controlRecord(gpa, &parsed.manifest); defer gpa.free(record.tags); _ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory; }, else => {}, } } return .{ .gpa = gpa, .mem = mem, .state = state, // Unreachable on the shipped path, and deliberately not given its own // variant for that reason. `applyCheckin` runs `buildPlan` on this same // `target_id` first, and `buildPlan` already proved the name valid, the // rid present, the content non-null, `artifact.verify` passing, // `parseForImport` succeeding and `kind == .manifest`. The loop above // re-parses those same bytes with the same two tests, and // `ridsInPersistedOrder` filters only phantoms — which a non-null // `content` already excluded — so the target is always crosslinked by // the time this runs. It stays a state-record failure rather than a // user error: were it ever to fire it would mean the assembler and the // log disagree, which is not something the caller named wrongly. .target_rid = target_rid orelse return error.StateRecordInvalid, }; } const StateRecordParts = struct { mtime: repostate.Mtime, parents: []const []const u8 = &.{}, tags: []const repostate.TagOp = &.{}, }; fn manifestRecord(gpa: std.mem.Allocator, name: []const u8, manifest: *const artifact.Manifest) ApplyError!repostate.Record { const parts = try stateRecordParts(gpa, manifest, true); return .{ .hash = name, .mtime = parts.mtime, .parents = parts.parents, .tags = parts.tags }; } fn controlRecord(gpa: std.mem.Allocator, manifest: *const artifact.Manifest) ApplyError!StateRecordParts { return stateRecordParts(gpa, manifest, false); } fn stateRecordParts(gpa: std.mem.Allocator, manifest: *const artifact.Manifest, include_parents: bool) ApplyError!StateRecordParts { const date = manifest.d orelse return error.StateRecordInvalid; const mtime = repostate.parseMtime(date.timestamp) orelse return error.StateRecordInvalid; var parents = std.ArrayList([]const u8).empty; errdefer parents.deinit(gpa); if (include_parents) { for (manifest.p) |card| { var it = card.hashes(); while (it.next()) |hash| { parents.append(gpa, hash) catch return error.OutOfMemory; } } } const tags = gpa.alloc(repostate.TagOp, manifest.t.len) catch return error.OutOfMemory; errdefer gpa.free(tags); for (manifest.t, tags) |card, *out| { out.* = .{ .type = switch (card.sign) { .add => .singleton, .cancel => .cancel, .propagate => .propagate, }, .name = card.name, .target = card.target, .value = card.value, }; } return .{ .mtime = mtime, .parents = parents.toOwnedSlice(gpa) catch return error.OutOfMemory, .tags = tags }; } /// Which file staging could not materialize. Plain data, no allocation: the /// path borrows the plan, which outlives the call, and the name is copied. /// /// This exists because staging walks every file in the check-in — thousands on /// a real repository, minutes of work — and a bare `Corrupt` at the end of that /// says only that something, somewhere, is wrong. The path and hash are what /// turn it into a question someone can answer: is that artifact in the source /// repository, and did it fail to arrive? pub const StageFault = struct { /// COPIED, not borrowed. `PlanEntry.path` is `gpa.dupe`d and freed by /// `Plan.deinit`, which `applyCheckin` runs from its own `defer` on the way /// out — so it is already gone by the time a caller renders this. An earlier /// version of this struct held the slice and documented it as borrowing a /// plan that "outlives the call"; it does not, and the diagnostic printed /// freed heap. A fault report that lies about the file is worse than the /// generic message it replaced. path_buf: [path_max]u8 = [_]u8{0} ** path_max, path_len: u16 = 0, /// Also copied, for the same reason: `PlanEntry.name` is owned by the plan. name: [64]u8 = [_]u8{0} ** 64, name_len: u8 = 0, reason: enum { not_in_repository, content_absent, content_mismatch }, /// Long enough for any path this repository can hold (`managed.max_path_len` /// is PATH_MAX), so a diagnostic never truncates a real filename. pub const path_max: usize = 4096; pub fn artifactName(self: *const StageFault) []const u8 { return self.name[0..self.name_len]; } pub fn filePath(self: *const StageFault) []const u8 { return self.path_buf[0..self.path_len]; } }; /// Human-readable cause for the last staging fault, or a neutral line if none /// was recorded. Shared so every verb that can hit this says the same thing /// about the same failure. pub fn stageFaultReason(buf: []u8) []const u8 { const f = last_stage_fault orelse return "a file's content is missing from this repository"; const why: []const u8 = switch (f.reason) { .not_in_repository => "is not in this repository", .content_absent => "has no stored content in this repository", .content_mismatch => "does not match its recorded name", }; return std.fmt.bufPrint(buf, "'{s}': content {s} {s}", .{ f.filePath(), f.artifactName(), why }) catch "a file's content is missing from this repository"; } /// Why a referenced file can be absent from a repository that verifies clean. /// /// Shared by every verb that can hit `FileContentMissing`, because the cause is /// the same in all of them and it is not guessable from the missing path. The /// format types artifacts by STRUCTURE (artifact-format §5.1) and repo-state /// §4.4 exempts only what a parser declines to classify — so a stored blob whose /// bytes are a valid check-in manifest IS a check-in, joins the DAG, and can /// carry its own branch tags. A repository that tracks such a file therefore /// holds check-ins of ANOTHER repository, whose file content it never had. /// /// That is conformant derivation, not damage, which is why the refusal explains /// rather than alleging corruption. pub const foreign_checkin_note: []const u8 = "this happens when a repository stores artifacts that parse as check-ins of\n" ++ "ANOTHER repository -- imported mirror metadata is the common source -- so they\n" ++ "join the branch as check-ins whose blobs were never here. Nothing was modified.\n"; /// Set by `stagePlan` when it refuses a file; read only after `applyCheckin` /// returns `error.FileContentMissing`. pub var last_stage_fault: ?StageFault = null; fn stageFaulted(entry_path: []const u8, name: []const u8, reason: anytype) error{FileContentMissing} { var f: StageFault = .{ .reason = reason }; const n = @min(name.len, f.name.len); @memcpy(f.name[0..n], name[0..n]); f.name_len = @intCast(n); const pn = @min(entry_path.len, StageFault.path_max); @memcpy(f.path_buf[0..pn], entry_path[0..pn]); f.path_len = @intCast(pn); last_stage_fault = f; return error.FileContentMissing; } /// Mark, in BOTH plans, every path whose (digest, mode) pair is identical on /// the two sides. Everything else -- content moved, mode flipped, path only on /// one side -- stays unmarked and takes the full mutation path. /// /// This is what makes `update`'s cost track the DIFF rather than the width: /// before it, every update staged, journaled, deleted and rewrote the entire /// tree, and the measured cost was ~52ms per tracked file regardless of how /// little changed. It is sound only downstream of `requireClean`, which has /// already proven every baseline file byte-correct on disk -- an unchanged /// entry is not "skipped work", it is work whose result is already present. fn markUnchanged(gpa: std.mem.Allocator, target: *Plan, old: *Plan) error{OutOfMemory}!void { var by_path: std.StringHashMapUnmanaged(*PlanEntry) = .empty; defer by_path.deinit(gpa); try by_path.ensureTotalCapacity(gpa, @intCast(old.entries.items.len)); for (old.entries.items) |*entry| by_path.putAssumeCapacity(entry.path, entry); for (target.entries.items) |*entry| { const prior = by_path.get(entry.path) orelse continue; if (prior.mode != entry.mode) continue; if (!std.mem.eql(u8, prior.digestSlice(), entry.digestSlice())) continue; entry.unchanged = true; prior.unchanged = true; } } fn stagePlan(gpa: std.mem.Allocator, io: std.Io, log: *logstore.LogStore, stage: std.Io.Dir, plan: *const Plan) ApplyError!void { for (plan.entries.items) |entry| { if (entry.unchanged) continue; const rid = log.lookup(entry.digestSlice()) orelse return stageFaulted(entry.path, entry.name, .not_in_repository); const bytes = log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return stageFaulted(entry.path, entry.name, .content_absent); defer gpa.free(bytes.?); if (!artifact.verify(entry.name, bytes.?)) return stageFaulted(entry.path, entry.name, .content_mismatch); try writePayload(io, stage, entry.path, bytes.?, entry.mode, true, null); } } fn preflight( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, target: *const Plan, current_managed: *const managed.ManagedSet, ) ApplyError!void { for (target.entries.items) |entry| { if (entry.unchanged) continue; var replaces_managed_ancestor = false; var component_end: usize = 0; while (std.mem.indexOfScalarPos(u8, entry.path, component_end, '/')) |slash| { const parent = entry.path[0..slash]; const existing = try statExisting(io, worktree, parent); switch (existing) { .missing => {}, .stat => |stat| switch (stat.kind) { .directory => {}, .file, .sym_link => if (current_managed.contains(parent)) { replaces_managed_ancestor = true; break; } else return error.UnmanagedCollision, else => return error.UnmanagedCollision, }, } component_end = slash + 1; } if (replaces_managed_ancestor) continue; const existing = try statExisting(io, worktree, entry.path); switch (existing) { .missing => {}, .stat => |stat| switch (stat.kind) { .file, .sym_link => if (!current_managed.contains(entry.path)) return error.UnmanagedCollision, .directory => if (!try directoryContainsOnlyManaged(gpa, io, worktree, entry.path, current_managed)) return error.UnmanagedCollision, else => return error.UnmanagedCollision, }, } } } fn commitMaterialization( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, next: std.Io.Dir, previous: std.Io.Dir, target: *const Plan, old: *const Plan, desired_managed: *const managed.ManagedSet, old_managed: *const managed.ManagedSet, created_directories: *CreatedDirectories, next_checkout: checkout.CheckoutState, manifest: ManifestSource, ) ApplyError!void { _ = previous; _ = old_managed; // Preconditions of the commit seam — the managed-set / record / staged-tree // agreement this transition rests on, self-guarded here rather than trusted // from the caller: // (iv) the record about to be written names EXACTLY the check-in whose // payload `next` was staged from, so a completed commit never leaves // a record pointing at a materialization that did not run; and // the managed set to be written is that check-in's membership (one entry // per target plan path). std.debug.assert(std.mem.eql(u8, next_checkout.version, manifest.target_id)); std.debug.assert(desired_managed.iterate().len == target.entries.items.len); try removePlanFiles(io, worktree, old, true); try materializePlan(gpa, io, next, worktree, target, created_directories); if (shouldInjectTestFault(.after_materialize)) return error.Storage; // Before the metadata writes, so a failure here still takes the ordinary // rollback rather than stranding a committed record beside a half-written // convenience file. try writeManifestFiles(gpa, io, worktree, next_checkout, desired_managed, manifest); desired_managed.write(io, worktree) catch |err| return mapManagedError(err); checkout.write(io, worktree, next_checkout) catch |err| return mapCheckoutError(err); } /// Where `writeManifestFiles` reads its payloads from: the log holding the /// check-in artifact, the check-in's own name, and the tag listing already /// derived for this target (empty unless `t` was selected). const ManifestSource = struct { log: *logstore.LogStore, target_id: []const u8, tags: []const u8, }; /// Write the working-tree manifest files `next_checkout.manifest_flags` selects. /// No RFC section behind this: it is fossil's `manifest` setting re-expressed as /// an explicit per-checkout opt-in, and the file contents match fossil's byte /// for byte so a build that reads them cannot tell the two apart. /// /// `r` is the check-in artifact followed by fossil's sterilizing line, `u` is /// the check-in name plus a newline, `t` is the branch line and one line per /// symbolic tag. A selector whose file name is TRACKED by the target check-in /// is skipped: fossil has that guard and the reason is the same — a versioned /// file the user committed outranks a convenience file, and silently replacing /// it would lose data the repository is responsible for. No selector means no /// write at all, which is the default. fn writeManifestFiles( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, next_checkout: checkout.CheckoutState, tracked: *const managed.ManagedSet, src: ManifestSource, ) ApplyError!void { const flags = next_checkout.manifest_flags; if (flags.len == 0) return; std.debug.assert(flags.len <= checkout.manifest_selectors.len); for (flags) |selector| { const index = std.mem.indexOfScalar(u8, checkout.manifest_selectors, selector) orelse return error.CurrentStateInvalid; // a record carrying a selector no writer knows const path = manifest_paths[index]; if (tracked.contains(path)) continue; switch (selector) { 'r' => { var digest: [logstore.max_hash_len]u8 = undefined; const raw = try decodeValidatedName(src.target_id, &digest); const rid = src.log.lookup(raw) orelse return error.CheckinNotFound; const bytes = src.log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return error.CheckinContentMissing; defer gpa.free(bytes.?); // The file is the check-in artifact verbatim, so it must still // hash to its own name before anything is written. if (!artifact.verify(src.target_id, bytes.?)) return error.CheckinNameMismatch; // ...and then the sterilizing line, byte-for-byte fossil's. // Without it the file on disk IS a valid artifact, so adding it // to a check-in would commit a second copy of that check-in // under a name that is not its own. Fossil defends against that // and so must zit; a build reading the D or C card is unaffected. var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); out.appendSlice(gpa, bytes.?) catch return error.OutOfMemory; out.appendSlice(gpa, sterilizer) catch return error.OutOfMemory; try writePayload(io, worktree, path, out.items, .regular, false, null); }, 'u' => { // 64 hex digits is the longest canonical name (§3.2); the // `buildPlan` gate has already proven `target_id` is one. var line: [2 * logstore.max_hash_len + 1]u8 = undefined; std.debug.assert(artifact.validateName(src.target_id)); @memcpy(line[0..src.target_id.len], src.target_id); line[src.target_id.len] = '\n'; try writePayload(io, worktree, path, line[0 .. src.target_id.len + 1], .regular, false, null); }, 't' => try writePayload(io, worktree, path, src.tags, .regular, false, null), else => unreachable, // the selector table above is exhaustive } } } fn rollbackMaterialization( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, previous: std.Io.Dir, target: *const Plan, old: *const Plan, old_managed: *const managed.ManagedSet, created_directories: *const CreatedDirectories, ) ApplyError!void { try removePlanFiles(io, worktree, target, false); try created_directories.remove(io, worktree); try materializePlan(gpa, io, previous, worktree, old, null); old_managed.write(io, worktree) catch |err| return mapManagedError(err); } fn removePlanFiles(io: std.Io, worktree: std.Io.Dir, plan: *const Plan, prune_empty_ancestors: bool) ApplyError!void { for (plan.entries.items) |entry| { if (entry.unchanged) continue; const removed = blk: { const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) { error.PathMissing => break :blk false, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => break :blk false, else => return error.Storage, }; break :blk true; }; if (prune_empty_ancestors and removed) try pruneEmptyPlanAncestors(io, worktree, entry.path); } } /// Deletes only empty ancestors of a removed managed file. A non-empty /// directory is never recursed into, so unmanaged entries remain untouched. fn pruneEmptyPlanAncestors(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void { var index = path.len; while (index > 0) { index -= 1; if (path[index] != '/') continue; const ancestor = path[0..index]; const parent = openParentNoFollow(io, worktree, ancestor, false, null) catch |err| switch (err) { error.PathMissing => continue, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.FileNotFound, error.DirNotEmpty => {}, else => return error.Storage, }; } } fn materializePlan( gpa: std.mem.Allocator, io: std.Io, stage: std.Io.Dir, worktree: std.Io.Dir, plan: *const Plan, created_directories: ?*CreatedDirectories, ) ApplyError!void { for (plan.entries.items) |entry| { if (entry.unchanged) continue; try prepareDestination(io, worktree, entry.path); const source_parent = openParentNoFollow(io, stage, entry.path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer source_parent.close(io); switch (entry.mode) { .symlink => { const stat = source_parent.dir.statFile(io, source_parent.name, .{ .follow_symlinks = false }) catch return error.Storage; if (stat.kind != .sym_link) return error.Storage; const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory; defer gpa.free(bytes); const n = source_parent.dir.readLink(io, source_parent.name, bytes) catch return error.Storage; if (n != bytes.len) return error.Storage; try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories); }, .regular, .executable => { var source = source_parent.dir.openFile(io, source_parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return error.Storage; defer source.close(io); const stat = source.stat(io) catch return error.Storage; const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory; defer gpa.free(bytes); const n = source.readPositionalAll(io, bytes, 0) catch return error.Storage; if (n != bytes.len) return error.Storage; try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories); }, } } } fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void { const existing = try statExisting(io, worktree, path); switch (existing) { .missing => {}, .stat => |stat| switch (stat.kind) { .directory => { const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.DirNotEmpty => return error.UnmanagedCollision, else => return error.Storage, }; }, else => return error.UnmanagedCollision, }, } } fn writePayload( io: std.Io, root: std.Io.Dir, path: []const u8, bytes: []const u8, mode: artifact.FileMode, exclusive: bool, created_directories: ?*CreatedDirectories, ) ApplyError!void { const parent = openParentNoFollow(io, root, path, true, created_directories) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer parent.close(io); switch (mode) { .symlink => { if (!exclusive) { parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => {}, else => return error.Storage, }; } parent.dir.symLink(io, bytes, parent.name, .{}) catch return error.Storage; }, .regular, .executable => { var file = parent.dir.createFile(io, parent.name, .{ .read = false, .exclusive = exclusive, .truncate = true, .permissions = switch (mode) { .regular => .default_file, .executable => .executable_file, .symlink => unreachable, }, .resolve_beneath = true, }) catch return error.Storage; defer file.close(io); file.writePositionalAll(io, bytes, 0) catch return error.Storage; file.sync(io) catch return error.Storage; }, } } /// The closed error set `restoreFile` surfaces: a storage failure while /// materializing the requested working-tree entry. `SymlinkUnsupported` stays /// in the public set for compatibility but is no longer returned for F-cards. pub const RestoreError = error{ Storage, SymlinkUnsupported }; /// Overwrite the single worktree file `path` under `root` with `bytes` at /// `mode` (artifact §8.4 effective-file restore), creating any missing parent /// directories. Unlike the transactional `applyCheckin`, this is a targeted, /// non-exclusive write of ONE already-resolved file: `zit revert` reconstructs /// the check-in's blob and hands it here, leaving the managed set untouched. /// Symlink targets are created from `bytes` exactly; all materialization /// failures surface as `Storage`. Regular-file writes are fsync'd before /// returning. pub fn restoreFile( io: std.Io, root: std.Io.Dir, path: []const u8, bytes: []const u8, mode: artifact.FileMode, ) RestoreError!void { writePayload(io, root, path, bytes, mode, false, null) catch |e| switch (e) { error.SymlinkUnsupported => return error.SymlinkUnsupported, else => return error.Storage, }; } /// Delete the single worktree file `path` under `root`, pruning any parent /// directories the delete leaves empty — the symmetric inverse of `restoreFile` /// (`zit undo` uses it to remove a file the undone mutation created). Reuses the /// same no-follow parent walk and empty-ancestor prune the transactional path /// uses; an already-absent path is success, not an error. Fsync of the /// containing directory is the OS's to schedule; the delete is durable-ordered /// after the journal swap that authorized it. pub fn removeFile(io: std.Io, root: std.Io.Dir, path: []const u8) RestoreError!void { const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) { error.PathMissing => return, // parent gone => nothing to delete else => return error.Storage, }; defer parent.close(io); parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => return, // already absent else => return error.Storage, }; pruneEmptyPlanAncestors(io, root, path) catch return error.Storage; } // --------------------------------------------------------------------------- // Merge materialization (checkout-layer seam for `zit merge`). // // The CLI computes a three-way merge purely, then hands the outcome set here as // `MergeEntry` values. This layer owns the transactional filesystem effects, // reusing the same journal + write-temp+fsync + rollback primitives // `applyCheckin` uses. It never inspects bytes for conflict markers — surfacing // conflicts is the CLI's job. // --------------------------------------------------------------------------- /// Verify the working tree is clean against its current checkout — the same /// `requireClean` gate `applyCheckin` runs (every managed file matches its /// checked-in content and the membership has not drifted), discarding the Plan. /// `zit merge`'s refusal gate calls this to reject a dirty tree (B-merge-14) /// before any mutation; a clean tree returns void, a dirty one `WorktreeNotClean`. /// No mutation. pub fn requireCleanTree( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, ) ApplyError!void { var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer current_managed.deinit(); var plan = try requireClean(gpa, io, worktree, log, ¤t_managed); plan.deinit(gpa); } /// One per-path effect of a computed merge. `write` carries the merged bytes and /// the CURRENT-side attribute (`perm`: null/"" = regular, "x" = executable, /// "l" = symlink — B-merge-23, the working file keeps its side's mode); `delete` /// removes the path. pub const MergeAction = union(enum) { write: struct { bytes: []const u8, perm: ?[]const u8 }, delete, }; /// A single path plus the action to apply to it. pub const MergeEntry = struct { path: []const u8, action: MergeAction, }; /// Journal-armed, transactional materialization of a computed merge outcome set, /// then the new `.zit-managed` and `.zit-checkout`. Order (B-merge-3): (1) /// journal the pre-images of every affected path ∪ the current checkout ∪ the /// current managed set, fsynced before the first byte moves; (2) write the new /// checkout (the caller sets `merge_parent` to the other side's hash) and the /// new managed set — the pending merge and its membership are recorded FIRST so /// a crash mid-apply still names them for `zit undo`; (3) materialize each /// entry. Any failure during (3) rolls files, the managed set AND the checkout /// back to the pre-merge state and drops the journal, exactly as `applyCheckin` /// rolls back. /// /// The managed-set mutation is DERIVED here from the same `entries` stream that /// drives the filesystem effects, never passed in alongside it: a `.write` /// registers its path (insert is idempotent, so an ordinary edit-merge is a /// no-op) and a `.delete` deregisters it. One stream, one truth — a merge that /// adds a file cannot leave it untracked (blocker B5) and a merge that deletes /// one cannot leave a phantom "missing" entry behind. pub fn applyMerge( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry, new_checkout: checkout.CheckoutState, ) ApplyError!void { for (entries) |entry| try validateWorktreePath(entry.path); // (1) Arm the undo journal before any working byte moves (B-merge-3): the // pre-mutation bytes of every affected path plus the current // `.zit-checkout`/`.zit-managed`, fsynced HERE. A device fault aborts // before mutating — no recoverable pre-image, no mutation. try recordMergeUndoImage(gpa, io, worktree, entries); // In-process rollback images: the pre-merge bytes/mode/presence of each // affected path, plus the pre-merge checkout and managed set, so a mid-apply // failure restores the tree exactly. The journal remains the crash-recovery // image. var images = try captureMergeImages(gpa, io, worktree, entries); defer images.deinit(gpa); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const old_checkout = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err); // Two independent reads rather than one rebuilt copy: the on-disk record // carries more than the membership (a pending `mv` rename), and rebuilding // `new` from `old.iterate()` would silently drop it. var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer old_managed.deinit(); var new_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer new_managed.deinit(); // Both sets are a fresh read of the SAME on-disk `.zit-managed`, so before // the outcome stream is folded into `new_managed` they hold identical // membership — `old_managed` is the untouched pre-merge baseline the // rollback restores to. std.debug.assert(new_managed.iterate().len == old_managed.iterate().len); try applyMergeMembership(&new_managed, entries); // (2) Record the pending merge FIRST (checkout carries merge_parent = other), // then the membership it implies. A failure here has moved no working // byte, but the checkout may already name the merge, so roll both back. checkout.write(io, worktree, new_checkout) catch |err| return mapCheckoutError(err); new_managed.write(io, worktree) catch |err| { rollbackMerge(io, worktree, &.{}, old_checkout, &old_managed) catch return error.RollbackFailed; journal.clear(io, worktree); return mapManagedError(err); }; // (3) Materialize the outcome set; roll files + managed + checkout back on // any failure. materializeMergeEntries(io, worktree, entries) catch |err| { rollbackMerge(io, worktree, images.items, old_checkout, &old_managed) catch { // The tree is half-restored; the undo journal is the only complete // recovery image, so retain it and report the failed inverse. return error.RollbackFailed; }; // Clean rollback: the tree is back to pre-merge, so drop the journal — // a cleanly-abandoned merge leaves no spurious undo level (B-merge-3). journal.clear(io, worktree); return err; }; } /// Folds the merge outcome set into `set`'s membership: every `.write` path is /// inserted (idempotent — an ordinary edit-merge changes nothing) and every /// `.delete` path removed (absent is a no-op). No I/O. fn applyMergeMembership(set: *managed.ManagedSet, entries: []const MergeEntry) ApplyError!void { for (entries) |entry| switch (entry.action) { .write => _ = set.insert(entry.path) catch |err| return mapManagedError(err), .delete => _ = set.remove(entry.path), }; } /// Build the affected-path set (deduplicated) and hand it to the undo journal. fn recordMergeUndoImage( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry, ) ApplyError!void { var affected = managed.ManagedSet.init(gpa); defer affected.deinit(); for (entries) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err); journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Storage, error.Corrupt, error.Empty => return error.Storage, }; } const MergeImage = struct { /// Borrows the caller's `entry.path`, which outlives this call. path: []const u8, present: bool, mode: artifact.FileMode, /// Pre-merge bytes when `present`; `gpa`-owned. Empty otherwise. blob: []const u8, }; const MergeImages = struct { items: []MergeImage, fn deinit(self: *MergeImages, gpa: std.mem.Allocator) void { for (self.items) |img| if (img.present) gpa.free(img.blob); gpa.free(self.items); self.* = undefined; } }; fn captureMergeImages( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry, ) ApplyError!MergeImages { const items = gpa.alloc(MergeImage, entries.len) catch return error.OutOfMemory; var filled: usize = 0; errdefer { for (items[0..filled]) |img| if (img.present) gpa.free(img.blob); gpa.free(items); } for (entries) |entry| { items[filled] = try captureMergeImage(gpa, io, worktree, entry.path); filled += 1; } // Exactly one rollback pre-image per affected path: the roll-back walk below // must be able to restore every path the merge is about to touch, so the // image count and the outcome-set length can never diverge. std.debug.assert(filled == entries.len); return .{ .items = items }; } fn captureMergeImage(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!MergeImage { const absent = MergeImage{ .path = path, .present = false, .mode = .regular, .blob = "" }; const existing = try statExisting(io, worktree, path); const stat = switch (existing) { .missing => return absent, .stat => |value| value, }; if (stat.kind != .file) return absent; const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) { error.PathMissing => return absent, else => return @errorCast(err), }; defer parent.close(io); var file = parent.dir.openFile(io, parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return error.Storage; defer file.close(io); const blob = gpa.alloc(u8, stat.size) catch return error.OutOfMemory; errdefer gpa.free(blob); const n = file.readPositionalAll(io, blob, 0) catch return error.Storage; if (n != blob.len) return error.Storage; const mode: artifact.FileMode = if (statIsExecutable(stat)) .executable else .regular; return .{ .path = path, .present = true, .mode = mode, .blob = blob }; } fn materializeMergeEntries(io: std.Io, worktree: std.Io.Dir, entries: []const MergeEntry) ApplyError!void { for (entries, 0..) |entry, i| { // Deterministic mid-apply fault (tests only): the first entry is applied, // then this fires, so rollback must restore a partially-mutated tree. if (i == 1 and shouldInjectTestFault(.merge_mid_apply)) return error.Storage; switch (entry.action) { .write => |w| { const mode = try modeFromPerm(w.perm); try writePayload(io, worktree, entry.path, w.bytes, mode, false, null); }, .delete => removeFile(io, worktree, entry.path) catch |err| switch (err) { error.SymlinkUnsupported => return error.SymlinkUnsupported, error.Storage => return error.Storage, }, } } } fn rollbackMerge( io: std.Io, worktree: std.Io.Dir, images: []const MergeImage, old_checkout: ?checkout.CheckoutState, old_managed: *const managed.ManagedSet, ) ApplyError!void { for (images) |img| { if (img.present) { restoreFile(io, worktree, img.path, img.blob, img.mode) catch return error.Storage; } else { removeFile(io, worktree, img.path) catch return error.Storage; } } // The membership mutation is part of the merge, so its inverse is part of // the rollback: a rolled-back merge must leave nothing registered that it // registered, and nothing deregistered that it removed. old_managed.write(io, worktree) catch |err| return mapManagedError(err); if (old_checkout) |ck| { checkout.write(io, worktree, ck) catch |err| return mapCheckoutError(err); } else { worktree.deleteFile(io, checkout.filename) catch |err| switch (err) { error.FileNotFound => {}, else => return error.Storage, }; } } /// Maps a fossil-style perm token (the current side's attribute) to a file mode: /// null/"" = regular, "x" = executable, "l" = symlink (rejected downstream as /// `SymlinkUnsupported`). Any other token is a malformed outcome (`Corrupt`). fn modeFromPerm(perm: ?[]const u8) ApplyError!artifact.FileMode { const token = perm orelse return .regular; if (token.len == 0) return .regular; if (std.mem.eql(u8, token, "x")) return .executable; if (std.mem.eql(u8, token, "l")) return .symlink; return error.Corrupt; } fn statIsExecutable(stat: std.Io.File.Stat) bool { if (comptime std.Io.File.Permissions.has_executable_bit) { return stat.permissions.toMode() & 0o111 != 0; } return false; } fn statExisting(io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath { const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) { error.PathMissing => return .missing, else => return @errorCast(err), }; defer parent.close(io); const stat = parent.dir.statFile(io, parent.name, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => return .missing, else => return error.Storage, }; return .{ .stat = stat }; } fn openParentNoFollow( io: std.Io, root: std.Io.Dir, path: []const u8, create: bool, created_directories: ?*CreatedDirectories, ) ParentError!OpenedParent { var current = root; var owns_current = false; errdefer if (owns_current) current.close(io); var start: usize = 0; while (std.mem.indexOfScalarPos(u8, path, start, '/')) |slash| { const component = path[start..slash]; const next = current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => if (create) blk: { var created = true; current.createDir(io, component, .default_dir) catch |create_err| switch (create_err) { error.PathAlreadyExists => created = false, else => return error.Storage, }; if (created) if (created_directories) |directories| directories.record(path[0..slash]); break :blk current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage; } else return error.PathMissing, else => return error.Storage, }; if (owns_current) current.close(io); current = next; owns_current = true; start = slash + 1; } return .{ .dir = current, .name = path[start..], .owned = owns_current }; } fn directoryContainsOnlyManaged( gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, path: []const u8, current_managed: *const managed.ManagedSet, ) ApplyError!bool { const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; defer parent.close(io); var dir = parent.dir.openDir(io, parent.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage; defer dir.close(io); return directoryEntriesAreManaged(gpa, io, dir, path, current_managed); } fn directoryEntriesAreManaged( gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, prefix: []const u8, current_managed: *const managed.ManagedSet, ) ApplyError!bool { var iterator = dir.iterate(); while (iterator.next(io) catch return error.Storage) |entry| { const full = joinPath(gpa, prefix, entry.name) catch return error.OutOfMemory; defer gpa.free(full); switch (entry.kind) { .file, .sym_link => if (!current_managed.contains(full)) return false, .directory => { var child = dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage; defer child.close(io); if (!try directoryEntriesAreManaged(gpa, io, child, full, current_managed)) return false; }, else => return false, } } return true; } fn joinPath(gpa: std.mem.Allocator, prefix: []const u8, name: []const u8) ![]u8 { const out = try gpa.alloc(u8, prefix.len + 1 + name.len); @memcpy(out[0..prefix.len], prefix); out[prefix.len] = '/'; @memcpy(out[prefix.len + 1 ..], name); return out; } fn createTransaction(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError![]const u8 { var random: [12]u8 = undefined; var encoded: [64]u8 = undefined; var attempt: usize = 0; while (attempt < 32) : (attempt += 1) { io.randomSecure(&random) catch return error.Storage; const suffix = encodeName(&random, &encoded); var name: [41]u8 = undefined; const prefix = ".zit-update-txn-"; @memcpy(name[0..prefix.len], prefix); @memcpy(name[prefix.len .. prefix.len + suffix.len], suffix); // Allocate the caller-owned name before the filesystem side effect: // an ordinary allocation failure must leave no transaction directory. const owned_name = gpa.dupe(u8, name[0 .. prefix.len + suffix.len]) catch return error.OutOfMemory; worktree.createDir(io, owned_name, .default_dir) catch |err| { gpa.free(owned_name); switch (err) { error.PathAlreadyExists => continue, else => return error.Storage, } }; return owned_name; } return error.TransactionUnavailable; } pub fn validateWorktreePath(path: []const u8) ApplyError!void { return control_paths.validateWorktreePath(path); } fn isPathPrefix(prefix: []const u8, path: []const u8) bool { return path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/'; } fn modeMatches(stat: std.Io.File.Stat, mode: artifact.FileMode) bool { if (comptime std.Io.File.Permissions.has_executable_bit) { const executable = stat.permissions.toMode() & 0o111 != 0; return switch (mode) { .regular => !executable, .executable => executable, .symlink => false, }; } return mode != .symlink; } fn decodeValidatedName(name: []const u8, out: *[logstore.max_hash_len]u8) ApplyError![]const u8 { if (!artifact.validateName(name)) return error.InvalidCheckinId; const len = name.len / 2; var i: usize = 0; while (i < len) : (i += 1) { out[i] = (hexNibble(name[i * 2]) << 4) | hexNibble(name[i * 2 + 1]); } return out[0..len]; } fn hexNibble(c: u8) u8 { return if (c >= '0' and c <= '9') c - '0' else c - 'a' + 10; } fn encodeName(raw: []const u8, out: *[64]u8) []const u8 { const alphabet = "0123456789abcdef"; for (raw, 0..) |byte, i| { out[i * 2] = alphabet[byte >> 4]; out[i * 2 + 1] = alphabet[byte & 0x0f]; } return out[0 .. raw.len * 2]; } fn mapContentError(err: logstore.ContentError) ApplyError { return switch (err) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } fn mapManagedError(err: managed.ManagedError) ApplyError { return switch (err) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt, error.TooLong, error.TooMany => error.CurrentStateInvalid, }; } fn mapCheckoutError(err: checkout.CheckoutError) ApplyError { return switch (err) { error.Storage => error.Storage, error.Corrupt => error.CurrentStateInvalid, }; } fn mapTargetParseError(err: artifact.ParseError) ApplyError { return switch (err) { error.OutOfMemory => error.OutOfMemory, else => error.TargetNotManifest, }; } fn mapBaselineParseError(err: artifact.ParseError) ApplyError { return switch (err) { error.OutOfMemory => error.OutOfMemory, else => error.BaselineNotManifest, }; } const TestBaseline = struct { files: []const artifact.FCard, fn resolve(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles { const self: *const TestBaseline = @ptrCast(@alignCast(context)); if (!std.mem.eql(u8, hash, "b" ** 40)) return null; return .{ .baseline_hash = null, .files = self.files }; } }; fn completeTestArtifact(gpa: std.mem.Allocator, body: []const u8) ![]u8 { var checksum_buf: [32]u8 = undefined; const checksum = artifact.selfChecksum(body, &checksum_buf); return std.fmt.allocPrint(gpa, "{s}Z {s}\n", .{ body, checksum }); } fn putTestArtifact(log: *logstore.LogStore, name: []const u8, bytes: []const u8) !void { var digest: [logstore.max_hash_len]u8 = undefined; const raw = try decodeValidatedName(name, &digest); _ = try log.put(raw, bytes); } fn putCompleteTestArtifact( gpa: std.mem.Allocator, log: *logstore.LogStore, body: []const u8, id_buf: *[64]u8, ) ![]const u8 { const bytes = try completeTestArtifact(gpa, body); defer gpa.free(bytes); const id = artifact.artifactName(.sha1, bytes, id_buf); try putTestArtifact(log, id, bytes); return id; } fn putTestPayload( log: *logstore.LogStore, bytes: []const u8, id_buf: *[64]u8, ) ![]const u8 { const id = artifact.artifactName(.sha1, bytes, id_buf); try putTestArtifact(log, id, bytes); return id; } fn expectFileState( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, path: []const u8, expected_bytes: []const u8, expected_mode: artifact.FileMode, ) !void { const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false }); try std.testing.expectEqual(.file, stat.kind); try std.testing.expect(modeMatches(stat, expected_mode)); var file = try worktree.openFile(io, path, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }); defer file.close(io); const bytes = try gpa.alloc(u8, stat.size); defer gpa.free(bytes); const n = try file.readPositionalAll(io, bytes, 0); try std.testing.expectEqual(expected_bytes.len, n); try std.testing.expectEqualStrings(expected_bytes, bytes); } fn expectSymlinkTarget(io: std.Io, worktree: std.Io.Dir, path: []const u8, expected_target: []const u8) !void { const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false }); try std.testing.expectEqual(.sym_link, stat.kind); var target: [1024]u8 = undefined; const n = try worktree.readLink(io, path, &target); try std.testing.expectEqualStrings(expected_target, target[0..n]); } const RootEntrySet = struct { names: std.ArrayList([]u8) = .empty, fn deinit(self: *RootEntrySet, gpa: std.mem.Allocator) void { for (self.names.items) |name| gpa.free(name); self.names.deinit(gpa); self.* = undefined; } fn contains(self: *const RootEntrySet, name: []const u8) bool { for (self.names.items) |candidate| { if (std.mem.eql(u8, candidate, name)) return true; } return false; } }; fn snapshotRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) !RootEntrySet { var entries = RootEntrySet{}; errdefer entries.deinit(gpa); var root = try worktree.openDir(io, ".", .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); defer root.close(io); var iterator = root.iterate(); while (try iterator.next(io)) |entry| { const name = try gpa.dupe(u8, entry.name); entries.names.append(gpa, name) catch |err| { gpa.free(name); return err; }; } return entries; } fn expectSameRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, expected: *const RootEntrySet) !void { var actual = try snapshotRootEntries(gpa, io, worktree); defer actual.deinit(gpa); try std.testing.expectEqual(expected.names.items.len, actual.names.items.len); for (expected.names.items) |name| try std.testing.expect(actual.contains(name)); } fn setupMutatingFailureFixture( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, target_id_buf: *[64]u8, old_id_buf: *[64]u8, ) ![]const u8 { var old_payload_id_buf: [64]u8 = undefined; const old_payload_id = try putTestPayload(log, "old executable bytes", &old_payload_id_buf); var target_payload_id_buf: [64]u8 = undefined; const target_payload_id = try putTestPayload(log, "new regular bytes", &target_payload_id_buf); const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF old-file {s} x\n", .{old_payload_id}); defer gpa.free(old_body); const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf); const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF new-file {s}\n", .{target_payload_id}); defer gpa.free(target_body); const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf); try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var old_managed = managed.ManagedSet.init(gpa); defer old_managed.deinit(); _ = try old_managed.insert("old-file"); try old_managed.write(io, worktree); try writePayload(io, worktree, "old-file", "old executable bytes", .executable, true, null); return target_id; } fn setupPathTransitionFixture( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, old_path: []const u8, target_path: []const u8, target_id_buf: *[64]u8, old_id_buf: *[64]u8, ) ![]const u8 { var old_payload_id_buf: [64]u8 = undefined; const old_payload_id = try putTestPayload(log, "old bytes", &old_payload_id_buf); var target_payload_id_buf: [64]u8 = undefined; const target_payload_id = try putTestPayload(log, "target bytes", &target_payload_id_buf); const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF {s} {s}\n", .{ old_path, old_payload_id }); defer gpa.free(old_body); const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf); const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF {s} {s}\n", .{ target_path, target_payload_id }); defer gpa.free(target_body); const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf); try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var old_managed = managed.ManagedSet.init(gpa); defer old_managed.deinit(); _ = try old_managed.insert(old_path); try old_managed.write(io, worktree); try writePayload(io, worktree, old_path, "old bytes", .regular, true, null); return target_id; } test "update transaction creation leaves no directory on name allocation failure" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir); defer entries_before.deinit(t.allocator); var failing_allocator = std.testing.FailingAllocator.init(t.allocator, .{ .fail_index = 0 }); try t.expectError(error.OutOfMemory, createTransaction(failing_allocator.allocator(), io, tmp.dir)); try t.expect(failing_allocator.has_induced_failure); try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before); } test "update rollback removes created ancestors before restoring an old file" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupPathTransitionFixture( t.allocator, io, tmp.dir, &log, "a", "a/b/c", &target_id_buf, &old_id_buf, ); var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir); defer entries_before.deinit(t.allocator); test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "a", "old bytes", .regular); try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("a")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(old_id_buf[0..40], state.version); try t.expectEqualStrings("old-branch", state.branch); } test "update replaces managed directory hierarchy with a file" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupPathTransitionFixture( t.allocator, io, tmp.dir, &log, "a/b/c", "a", &target_id_buf, &old_id_buf, ); try applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit); try expectFileState(t.allocator, io, tmp.dir, "a", "target bytes", .regular); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("a")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(target_id, state.version); try t.expectEqualStrings("trunk", state.branch); } test "update restores mutated bytes mode managed set and checkout after pre-commit failure" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf); test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "old-file", "old executable bytes", .executable); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "new-file", .{ .follow_symlinks = false })); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("old-file")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(old_id_buf[0..40], state.version); try t.expectEqualStrings("old-branch", state.branch); } test "update rolls a materialized symlink back after a later failure" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var old_payload_id_buf: [64]u8 = undefined; const old_payload_id = try putTestPayload(&log, "old regular bytes\n", &old_payload_id_buf); var target_payload_id_buf: [64]u8 = undefined; const target_payload_id = try putTestPayload(&log, "../new-target", &target_payload_id_buf); const old_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF entry {s}\n", .{old_payload_id}); defer t.allocator.free(old_body); var old_id_buf: [64]u8 = undefined; const old_id = try putCompleteTestArtifact(t.allocator, &log, old_body, &old_id_buf); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF entry {s} l\n", .{target_payload_id}); defer t.allocator.free(target_body); var target_id_buf: [64]u8 = undefined; const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf); try checkout.write(io, tmp.dir, .{ .version = old_id, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var old_managed = managed.ManagedSet.init(t.allocator); defer old_managed.deinit(); _ = try old_managed.insert("entry"); try old_managed.write(io, tmp.dir); try writePayload(io, tmp.dir, "entry", "old regular bytes\n", .regular, true, null); var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir); defer entries_before.deinit(t.allocator); test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "entry", "old regular bytes\n", .regular); try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("entry")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(old_id, state.version); } test "update retains the committed checkout after post-checkout cleanup failure" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var target_id_buf: [64]u8 = undefined; var old_id_buf: [64]u8 = undefined; const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf); test_fault = .before_cleanup; defer test_fault = null; try t.expectError(error.CleanupFailed, applyCheckin(t.allocator, io, tmp.dir, &log, target_id, .inherit)); try expectFileState(t.allocator, io, tmp.dir, "new-file", "new regular bytes", .regular); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "old-file", .{ .follow_symlinks = false })); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expectEqual(@as(usize, 1), current_managed.iterate().len); try t.expect(current_managed.contains("new-file")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(target_id, state.version); try t.expectEqualStrings("trunk", state.branch); } test "update delta expansion skips unmatched delete markers" { const files = [_]artifact.FCard{ .{ .filename = "gone", .hash = null, .perm = null, .old_name = null, .mode = .regular }, .{ .filename = "kept", .hash = "a" ** 40, .perm = null, .old_name = null, .mode = .regular }, }; var baseline = TestBaseline{ .files = &.{} }; var walk = try artifact.enumerate(.{ .baseline_hash = "b" ** 40, .files = &files }, .{ .ctx = @ptrCast(&baseline), .resolveFn = TestBaseline.resolve, }); var materialized: usize = 0; while (walk.next()) |file| { if (file.hash == null) continue; materialized += 1; try std.testing.expectEqualStrings("kept", file.filename); } try std.testing.expectEqual(@as(usize, 1), materialized); } // --------------------------------------------------------------------------- // draft-fossil-submanifests-00 §5 — `update` resolves through commit's resolver // --------------------------------------------------------------------------- // // The Appendix A.2/A.3 vectors, byte-for-byte, stored as real artifacts so the // shared §5.2 resolver does the resolving over the LogStore this module holds. // `buildPlan` is the seam: it is the whole read side of `applyCheckin`, and it // needs no worktree, so these pin the walk without a materialization fixture. const sm_a2_1_leaf = @embedFile("rfc_vectors/submanifest_a2_1_leaf.txt"); const sm_a2_2_leaf = @embedFile("rfc_vectors/submanifest_a2_2_leaf.txt"); const sm_a3_manifest = @embedFile("rfc_vectors/submanifest_a3_tree_manifest.txt"); /// §A.3: "The tree walk of Section 5.1 enumerates README.md, src/main.c, /// src/node11.c, src/util.c: the same list a flat manifest would carry." const sm_a1_filenames = [_][]const u8{ "README.md", "src/main.c", "src/node11.c", "src/util.c" }; /// Store `bytes` under the name they actually hash to, in the 64-hex form the /// Appendix A vectors' S cards use. fn putVectorArtifact(log: *logstore.LogStore, bytes: []const u8, id_buf: *[64]u8) ![]const u8 { const id = artifact.artifactName(.sha3_256, bytes, id_buf); try putTestArtifact(log, id, bytes); return id; } /// Respell every S card's hash token in UPPERCASE and reseal the Z card. §3.5 /// admits both spellings — "exactly 40 or 64 case-insensitive hexadecimal /// characters" — so the result is a legal tree manifest naming the very same /// two sub-manifests. fn upperCaseSCardHashes(gpa: std.mem.Allocator, manifest_bytes: []const u8) ![]u8 { var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); var lines = std.mem.splitScalar(u8, manifest_bytes, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; if (line[0] == 'Z') continue; // resealed below over the rewritten body if (line[0] == 'S') { const gap = std.mem.lastIndexOfScalar(u8, line, ' ').?; try body.appendSlice(gpa, line[0 .. gap + 1]); for (line[gap + 1 ..]) |c| try body.append(gpa, std.ascii.toUpper(c)); } else { try body.appendSlice(gpa, line); } try body.append(gpa, '\n'); } return completeTestArtifact(gpa, body.items); } // §3.5 makes an S card's hash case-insensitive, and `parseSCard` admits both // spellings on purpose. `update` used to carry its own resolver, which decoded // the token through `validateName` — canonical lowercase only — and so refused // a tree that `commit`'s resolver walks without complaint: a legal artifact // turned into a spurious §5.2 hard failure. Routing both through one resolver // is what removed that; this pins it so a second copy cannot reintroduce it. test "update submanifests §3.5: an uppercase S-card hash resolves, as commit's resolver already does" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var leaf_1_buf: [64]u8 = undefined; _ = try putVectorArtifact(&log, sm_a2_1_leaf, &leaf_1_buf); var leaf_2_buf: [64]u8 = undefined; _ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf); const upper = try upperCaseSCardHashes(t.allocator, sm_a3_manifest); defer t.allocator.free(upper); // The rewrite really did change the spelling and nothing else about the run. try t.expect(!std.mem.eql(u8, upper, sm_a3_manifest)); try t.expect(std.mem.indexOf(u8, upper, "S README.md AA9D404C") != null); var tree_buf: [64]u8 = undefined; const tree_id = try putVectorArtifact(&log, upper, &tree_buf); var plan = try buildPlan(t.allocator, &log, tree_id); defer plan.deinit(t.allocator); try t.expectEqual(sm_a1_filenames.len, plan.entries.items.len); for (sm_a1_filenames, plan.entries.items) |want, entry| { try t.expectEqualStrings(want, entry.path); } } // The reverse guarantee of the same unification: routing through the shared // resolver must not blur a real store fault into §5.2's resolution gap. The // log source renames `Storage` to `CannotRead` on the way out and `flattenIfTree` // renames it back, so every code an `ApplyError` consumer could branch on // survives the trip. Corrupt is the one with an observable trigger: bytes // stored under a name they do not hash to. test "update submanifests §5.2: a sub-manifest stored under another name is Corrupt, not a resolution gap" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); // The first S card's target is present and parses — but the bytes filed // under its name are the *other* leaf's, so the name does not describe them. var leaf_1_buf: [64]u8 = undefined; const leaf_1_id = artifact.artifactName(.sha3_256, sm_a2_1_leaf, &leaf_1_buf); try putTestArtifact(&log, leaf_1_id, sm_a2_2_leaf); var leaf_2_buf: [64]u8 = undefined; _ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf); var tree_buf: [64]u8 = undefined; const tree_id = try putVectorArtifact(&log, sm_a3_manifest, &tree_buf); // Corrupt, not BaselineNotFound: §5.2's hard failure is about resolution, // and this repository's integrity is the actual claim being made. try t.expectError(error.Corrupt, buildPlan(t.allocator, &log, tree_id)); } test "update bootstrap clean state leaves checkout metadata absent" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); var plan = try requireClean(t.allocator, io, tmp.dir, &log, ¤t_managed); defer plan.deinit(t.allocator); try t.expectEqual(@as(usize, 0), plan.entries.items.len); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; try t.expectEqual(@as(?checkout.CheckoutState, null), try checkout.read(io, tmp.dir, &checkout_buf)); try t.expectEqual(@as(usize, 0), current_managed.iterate().len); } test "update applies the shared reserved root policy before staging" { const Case = struct { path: []const u8, reserved: bool }; const cases = [_]Case{ .{ .path = ".zit", .reserved = true }, .{ .path = ".ZiT-Checkout", .reserved = true }, .{ .path = ".zIT-Managed/entries", .reserved = true }, .{ .path = ".zit-import-map.jsonl/sidecar", .reserved = true }, .{ .path = "ReFs/heads/main", .reserved = true }, .{ .path = ".zit-update-txn-user/file", .reserved = true }, .{ .path = ".ZiT-UpDaTe-TxN-user/file", .reserved = true }, .{ .path = ".zit-checkout-user/file", .reserved = false }, .{ .path = ".zit-update-txn", .reserved = false }, .{ .path = ".zit-update-txns/user", .reserved = false }, .{ .path = "sub/.zit/object", .reserved = false }, }; for (cases) |case_| { if (case_.reserved) { try std.testing.expectError(error.ReservedPath, validateWorktreePath(case_.path)); } else { try validateWorktreePath(case_.path); } } } test "update reserved paths leave the plan unchanged" { var plan: Plan = .{}; defer plan.deinit(std.testing.allocator); const paths = [_][]const u8{ ".ZiT-Checkout", ".zit-update-txn-user/file" }; for (paths) |path| { const file = artifact.FCard{ .filename = path, .hash = "a" ** 40, .perm = null, .old_name = null, .mode = .regular, }; try std.testing.expectError(error.ReservedPath, plan.add(std.testing.allocator, file)); try std.testing.expectEqual(@as(usize, 0), plan.entries.items.len); } } test "update rejects malformed worktree paths before staging" { try std.testing.expectError(error.InvalidPath, validateWorktreePath("../outside")); try std.testing.expectError(error.InvalidPath, validateWorktreePath("a//b")); } test "update state assembly rejects a manifest stored under another name" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); const target_bytes = try completeTestArtifact(t.allocator, target_body); defer t.allocator.free(target_bytes); var target_id_buf: [64]u8 = undefined; const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf); try putTestArtifact(&log, target_id, target_bytes); const misnamed_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF other {s}\n", .{"b" ** 40}); defer t.allocator.free(misnamed_body); const misnamed_bytes = try completeTestArtifact(t.allocator, misnamed_body); defer t.allocator.free(misnamed_bytes); var wrong_name_buf: [64]u8 = undefined; const wrong_name = artifact.artifactName(.sha1, "a different artifact", &wrong_name_buf); try putTestArtifact(&log, wrong_name, misnamed_bytes); try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id)); } test "update state assembly rejects a control stored under another name" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); const target_bytes = try completeTestArtifact(t.allocator, target_body); defer t.allocator.free(target_bytes); var target_id_buf: [64]u8 = undefined; const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf); try putTestArtifact(&log, target_id, target_bytes); const control_bytes = try completeTestArtifact(t.allocator, "D 2007-07-21T14:10:00\nT +branch cccccccccccccccccccccccccccccccccccccccc trunk\nU alice\n"); defer t.allocator.free(control_bytes); var wrong_name_buf: [64]u8 = undefined; const wrong_name = artifact.artifactName(.sha1, "a different control artifact", &wrong_name_buf); try putTestArtifact(&log, wrong_name, control_bytes); try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id)); } // The shape that made `zit update` accuse an intact fossil clone of corruption: // an anonymous wiki page. It is well-framed and its cards parse, but a wiki // artifact requires a `U` card and an anonymous edit has none, so classification // fails with `MissingRequiredCard`. Its bytes still hash to the name it is // stored under, so the repository is undamaged and the artifact is opaque to // state: assembly skips it and completes. // // This is the non-corpus stand-in for the 67,499-artifact fossil clone that // surfaced the defect, where the live instance is artifact // 14dc8dab9792f8f0139dc05d49cd0f5cdc55503f, a 2008 edit of the page // "RockShoxBugs". Nothing here depends on that corpus or on any wall clock — // the trigger is a card set, so a generator can emit it. test "update state assembly skips a stored artifact it cannot classify" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); var target_id_buf: [64]u8 = undefined; const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf); // D, L, W, Z — no U card, exactly as fossil stores an anonymous wiki edit. var wiki_id_buf: [64]u8 = undefined; _ = try putCompleteTestArtifact( t.allocator, &log, "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n", &wiki_id_buf, ); var assembly = try assembleState(t.allocator, &log, target_id); defer assembly.deinit(); // Skipped, not absorbed: the wiki artifact contributed no state. Assembly // resolved the target it was asked for, and the unclassifiable artifact is // not among the crosslinked records. try t.expect(assembly.state.store.lookup(wiki_id_buf[0..40]) == null); } // The other half of the rule, and the reason the arm verifies before it skips. // Tolerating an artifact this reader declines must not become tolerating one // whose bytes do not match the name they are stored under. Same wiki card set, // same classification failure — but stored under a name that is not its hash. // That is an integrity failure, and it stays fatal. // // Without this test the skip above is vacuous: a blanket `continue` on every // classification failure would pass it and lose the corruption signal entirely. test "update state assembly still rejects an unclassifiable artifact whose name does not verify" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); var target_id_buf: [64]u8 = undefined; const target_id = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_id_buf); const wiki_bytes = try completeTestArtifact( t.allocator, "D 2008-11-24T02:18:26\nL RockShoxBugs\nW 12\nhello, world\n", ); defer t.allocator.free(wiki_bytes); var wrong_name_buf: [64]u8 = undefined; const wrong_name = artifact.artifactName(.sha1, "not these bytes", &wrong_name_buf); try putTestArtifact(&log, wrong_name, wiki_bytes); try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id)); } test "update: the staging fault survives the plan it came from (end to end)" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); // The tree and its leaves, but NOT the file blobs their F cards name — so // the plan builds and staging is the first thing that cannot proceed. var leaf_1_buf: [64]u8 = undefined; _ = try putVectorArtifact(&log, sm_a2_1_leaf, &leaf_1_buf); var leaf_2_buf: [64]u8 = undefined; _ = try putVectorArtifact(&log, sm_a2_2_leaf, &leaf_2_buf); var root_buf: [64]u8 = undefined; const root_id = try putVectorArtifact(&log, sm_a3_manifest, &root_buf); const prior = last_stage_fault; defer last_stage_fault = prior; last_stage_fault = null; const err = applyCheckin(t.allocator, io, tmp.dir, &log, root_id, .{ .set = "" }); try t.expectError(error.FileContentMissing, err); // The whole point, and what a hand-built fault cannot check: `applyCheckin` // frees the plan from its own `defer` on the way out, so a fault that // BORROWED the plan's path would be read here as freed heap. It printed // exactly that — ten bytes of garbage where a filename belonged — until the // path was copied instead. const f = last_stage_fault orelse return error.TestExpectedFault; const path = f.filePath(); var known = false; for (sm_a1_filenames) |name| { if (std.mem.eql(u8, path, name)) known = true; } try t.expect(known); // And the artifact name is a real 64-hex id, not whatever was on the stack. try t.expectEqual(@as(usize, 64), f.artifactName().len); for (f.artifactName()) |c| try t.expect(std.ascii.isHex(c)); } test "update: a staging fault names the file and the artifact, not just 'corrupt'" { const t = std.testing; // Narrow by design: this pins the RENDERING and the reason mapping. That // `stagePlan` reaches it is enforced by the compiler — `FileContentMissing` // is in the closed `ApplyError` set, so every one of the four verb mappers // had to grow an arm for it or fail to build. const prior = last_stage_fault; defer last_stage_fault = prior; var f: StageFault = .{ .reason = .not_in_repository }; const name = "a" ** 64; @memcpy(f.name[0..64], name); f.name_len = 64; // Copied, never borrowed: the plan that owns the real path is freed by // `applyCheckin`'s defer before any caller renders this. @memcpy(f.path_buf[0.."src/main.c".len], "src/main.c"); f.path_len = "src/main.c".len; last_stage_fault = f; var buf: [512]u8 = undefined; const msg = stageFaultReason(&buf); // The path answers "which file", the hash answers "which artifact to go // look for in the source repository". A bare corruption claim answers // neither, which is what made this take minutes to even localise. try t.expect(std.mem.indexOf(u8, msg, "src/main.c") != null); try t.expect(std.mem.indexOf(u8, msg, name) != null); try t.expect(std.mem.indexOf(u8, msg, "is not in this repository") != null); last_stage_fault = null; const neutral = stageFaultReason(&buf); try t.expect(std.mem.indexOf(u8, neutral, "missing") != null); } test "update state assembly retains the stable MemStore backing RepoState" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40}); defer t.allocator.free(target_body); const target_bytes = try completeTestArtifact(t.allocator, target_body); defer t.allocator.free(target_bytes); var target_id_buf: [64]u8 = undefined; const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf); try putTestArtifact(&log, target_id, target_bytes); var assembly = try assembleState(t.allocator, &log, target_id); defer assembly.deinit(); try t.expectEqual(@intFromPtr(assembly.mem), @intFromPtr(assembly.state.store.context)); const added = try assembly.state.crosslink(.{ .hash = "b" ** 40, .mtime = 1, }); try t.expectEqual(added, assembly.mem.store().lookup("b" ** 40).?); } fn setupPreMergeWorktree(io: std.Io, worktree: std.Io.Dir) !void { // A clean checkout with no pending merge, plus two managed files. try checkout.write(io, worktree, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); try writePayload(io, worktree, "keep-me", "current\n", .regular, true, null); try writePayload(io, worktree, "remove-me", "gone\n", .regular, true, null); } test "merge applyMerge materializes writes and deletes and records the merge parent" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; try setupPreMergeWorktree(io, tmp.dir); const other = "2" ** 64; const entries = [_]MergeEntry{ .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } }, .{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } }, .{ .path = "remove-me", .action = .delete }, }; try applyMerge(t.allocator, io, tmp.dir, &entries, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = other, .special_op = "", .manifest_flags = "" }); try expectFileState(t.allocator, io, tmp.dir, "keep-me", "merged\n", .regular); // B-merge-23: the current-side "x" attribute is preserved on the merged file. try expectFileState(t.allocator, io, tmp.dir, "tool.sh", "#!/bin/sh\n", .executable); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "remove-me", .{ .follow_symlinks = false })); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?; try t.expectEqualStrings("1" ** 64, ck.version); try t.expectEqualStrings("trunk", ck.branch); try t.expectEqualStrings(other, ck.merge_parent); // Journal armed before mutating: the undo level is present (B-merge-3). _ = try tmp.dir.statFile(io, journal.filename, .{ .follow_symlinks = false }); } test "merge applyMerge journals the pre-merge image so undo clears the pending merge" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; try setupPreMergeWorktree(io, tmp.dir); const entries = [_]MergeEntry{ .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } }, .{ .path = "tool.sh", .action = .{ .write = .{ .bytes = "#!/bin/sh\n", .perm = "x" } } }, .{ .path = "remove-me", .action = .delete }, }; try applyMerge(t.allocator, io, tmp.dir, &entries, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "2" ** 64, .special_op = "", .manifest_flags = "" }); // The journal captured the pre-merge tree (files + merge_parent == ""), so // undo restores it byte-for-byte and the pending merge disappears (B-merge-13). try journal.undo(t.allocator, io, tmp.dir); try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular); try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular); try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "tool.sh", .{ .follow_symlinks = false })); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?; try t.expectEqualStrings("1" ** 64, ck.version); try t.expectEqualStrings("", ck.merge_parent); } test "merge applyMerge rolls files and checkout back on an injected mid-apply failure" { const t = std.testing; var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; try setupPreMergeWorktree(io, tmp.dir); // Entry 0 (write) is applied; the fault fires before entry 1 (delete), so the // tree is partially mutated and rollback must fully restore it. const entries = [_]MergeEntry{ .{ .path = "keep-me", .action = .{ .write = .{ .bytes = "merged\n", .perm = null } } }, .{ .path = "remove-me", .action = .delete }, }; test_fault = .merge_mid_apply; defer test_fault = null; try t.expectError(error.Storage, applyMerge(t.allocator, io, tmp.dir, &entries, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "2" ** 64, .special_op = "", .manifest_flags = "" })); // Files restored, no delete applied, checkout back to no-pending-merge. try expectFileState(t.allocator, io, tmp.dir, "keep-me", "current\n", .regular); try expectFileState(t.allocator, io, tmp.dir, "remove-me", "gone\n", .regular); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ck_buf)).?; try t.expectEqualStrings("1" ** 64, ck.version); try t.expectEqualStrings("", ck.merge_parent); // A clean rollback drops the journal — no spurious undo level survives. try t.expectError(error.FileNotFound, tmp.dir.statFile(io, journal.filename, .{ .follow_symlinks = false })); } // Pull the undo/redo journal's inline unit tests (contracts/undo.md) into // `zig build test`. `undo.zig` and this file import each other; referencing it // here in the same module keeps a single instance and no duplicate analysis. test { _ = journal; } test "update: a legacy trailing-separator F card plans the hash it names" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); // The blob the check-in names, stored under its real sha1 name. const blob = "int main(void){return 0;}\n"; var blob_id_buf: [64]u8 = undefined; const blob_id = artifact.artifactName(.sha1, blob, &blob_id_buf); try putTestArtifact(&log, blob_id, blob); // A manifest whose F card carries the LEGACY TRAILING SEPARATOR — one SP // after the hash. This is what fossil-origin history looks like, and it is // the only shape that makes `parseForImport` normalize into a fresh buffer: // the manifest then borrows THAT copy, not the stored bytes, and every // F-card hash the plan reads points into it. // // Nothing else in this suite exercises that. Every synthetic corpus is // git-native and never carries the separator, so `normalized` is null // throughout — which left the one path a real fossil-origin import always // takes covered only by repositories nobody runs the tests against. const body = try std.fmt.allocPrint( t.allocator, "D 2007-07-21T14:09:59\nF src/expr.c {s} \n", .{blob_id}, ); defer t.allocator.free(body); const bytes = try completeTestArtifact(t.allocator, body); defer t.allocator.free(bytes); var id_buf: [64]u8 = undefined; const id = artifact.artifactName(.sha1, bytes, &id_buf); try putTestArtifact(&log, id, bytes); var plan = try buildPlan(t.allocator, &log, id); defer plan.deinit(t.allocator); try t.expectEqual(@as(usize, 1), plan.entries.items.len); const entry = plan.entries.items[0]; try t.expectEqualStrings("src/expr.c", entry.path); // The whole question: does the plan carry the name the F card wrote? try t.expectEqualStrings(blob_id, entry.name); } // --- Foreign check-ins (issue #42) ------------------------------------------ // The format types artifacts by STRUCTURE (artifact-format §5.1 L1483), and // repo-state §4.4 L634-642 exempts only what a parser DECLINES to classify. So // a stored blob whose bytes are a valid check-in manifest IS a check-in of this // repository's DAG, even though its content came from somewhere else — a // Fossil→Git mirror's tracked `manifest` file being the case that found this. // // Both tests below assert CONFORMANCE, not a defect: the derivation is required // to behave this way. What must never regress is what the CONSUMERS do about it. test "update #42: a foreign check-in that carries its own branch tag is refused, not materialized" { const t = std.testing; const test_storage = @import("storage.zig"); var tmp = t.tmpDir(.{}); defer tmp.cleanup(); const io = t.io; var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true }); defer raw_file.close(io); var file_storage = test_storage.FileStorage.init(io, raw_file, 0); var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0); defer log.deinit(t.allocator); // A check-in naming a file whose blob this repository never held — the // shape a mirror's `manifest` has, since its F cards name the FOREIGN // repository's artifacts. `T *branch * trunk` is a self-targeted, // value-carrying propagating tag (§4.1: the UUID field is an artifact name // or the literal `*`; `T *branch trunk` puts a branch name in the UUID // field and the whole manifest is rejected instead). const body = try std.fmt.allocPrint( t.allocator, "C foreign\nD 2099-01-01T00:00:00\nF src/expr.c {s}\nT *branch * trunk\nT *sym-trunk *\nU drh\n", .{"7e" ** 20}, ); defer t.allocator.free(body); const bytes = try completeTestArtifact(t.allocator, body); defer t.allocator.free(bytes); var id_buf: [64]u8 = undefined; const id = artifact.artifactName(.sha1, bytes, &id_buf); try putTestArtifact(&log, id, bytes); const prior = last_stage_fault; defer last_stage_fault = prior; last_stage_fault = null; // Materializing it must FAIL, and fail before touching the working tree. // `stagePlan` verifies and stages every byte into the transaction directory // before any worktree mutation, which is why a plan naming absent content // cannot leave a half-written tree behind. try t.expectError( error.FileContentMissing, applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }), ); const f = last_stage_fault orelse return error.TestExpectedFault; try t.expectEqualStrings("src/expr.c", f.filePath()); // FAIL-CLOSED, stated as an assertion so it cannot be traded away later: // nothing the check-in named was written. try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "src/expr.c", .{})); } test "update #42: the refusal explains the class, not just the missing path" { const t = std.testing; // A path and a hash alone send someone hunting for a corrupt artifact. The // cause is that the repository holds a check-in belonging to a different // repository, which is not guessable from either. try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "ANOTHER repository") != null); try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "mirror") != null); // And it must not allege damage: the store is intact and `verify` will say so. try t.expect(std.mem.indexOf(u8, foreign_checkin_note, "corrupt") == null); }