Artifact 592329d7265754cee55dec769a3d73227eee48e16abc4b500e5c4b99a16dedd9:
- File
src/update.zig
— part of check-in
[0369e62f19]
at
2026-08-07 21:50:10
on branch sub-manifest
— update: pin the fail-closed refusal of a foreign check-in
Regression tests for issue 42. A blob whose bytes are a valid check-in manifest IS a check-in -- structure types artifacts, and repo-state 4.4 exempts only what a parser declines to classify -- so a repository that tracks such a file holds check-ins of another repository. That derivation is required; what must not regress is the refusal.
Asserts materialization fails with FileContentMissing, names the file, and leaves nothing written -- the fail-closed property stated as a test so it cannot be quietly traded later. Also pins that the diagnostic explains the class and never alleges corruption, since the store is intact and verify says so.
Records the T-card grammar that makes such a manifest resolvable: 'T *branch * trunk' self-targets, while 'T *branch trunk' puts a branch name in the UUID field and the manifest is rejected entirely. (#42) (user: dmestas, size: 132553) [annotate] [blame] [check-ins using] [more...]
//! 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 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, 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); var facts = try deriveTargetFacts(gpa, log, target_id, std.mem.indexOfScalar(u8, manifest_flags, 't') != null); defer facts.deinit(gpa); const branch = facts.branch; var desired_managed = managed.ManagedSet.init(gpa); defer desired_managed.deinit(); for (target.entries.items) |entry| { _ = desired_managed.insert(entry.path) catch |err| return mapManagedError(err); } // 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); 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_managed, &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; }; // `.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_managed: *const managed.ManagedSet, target: *const Plan, ) ApplyError!void { var affected = managed.ManagedSet.init(gpa); defer affected.deinit(); for (old_managed.iterate()) |path| _ = affected.insert(path) catch |err| return mapManagedError(err); for (target.entries.items) |entry| _ = affected.insert(entry.path) catch |err| return mapManagedError(err); // The affected set is `old_managed ∪ target`, so it can never be smaller // than either operand: every path this update writes or deletes is journaled // before the first working byte moves (B-undo-3/14). std.debug.assert(affected.iterate().len >= target.entries.items.len); std.debug.assert(affected.iterate().len >= old_managed.iterate().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 plan = 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| { 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; } 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; } }; 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); var tags: []u8 = &.{}; if (want_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. const sym_prefix = "sym-"; const entries = assembly.state.tagsOf(gpa, assembly.target_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; } tags = out.toOwnedSlice(gpa) catch return error.OutOfMemory; } return .{ .branch = owned_branch, .tags = tags }; } /// 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); var raw_name: [64]u8 = undefined; for (persisted_rids) |rid| { const name = encodeName(log.hashOf(rid), &raw_name); const bytes = log.content(gpa, rid) 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; } 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| { 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| { 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| { 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| { 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); }