Artifact 7553bc40be6d20a31c45f0fdb3a541285cf766c69ae0a1e984b22696ba7f1515:
- File
src/update.zig
— part of check-in
[f4736670a8]
at
2026-07-23 16:06:31
on branch fix10-tolerance
— Tolerate a stored artifact state assembly cannot classify
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", stores the result and syncs it. A clone of the canonical fossil repository therefore carries such artifacts, and assembly rejected the whole repository over one of them while timeline, ls, info and branch read the same store correctly.
The two conditions are separated 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; the artifact is opaque to state and is skipped, exactly as a non-participating type already is. Bytes that do not hash to their name are an integrity failure and stay fatal. Verification runs before the skip, which is what keeps the second from being swallowed by the first.
On a clone of the fossil repository, 67499 artifacts: update went from exit 1 having materialised nothing, to materialising all 983 files byte-identical to fossil's own checkout of the same check-in. (user: dmestas, size: 100751) [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 repostate = @import("repostate.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. 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. 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{ 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; if (file.mode == .symlink) return error.SymlinkUnsupported; 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, }; const StateAssembly = struct { gpa: std.mem.Allocator, mem: *repostate.MemStore, state: repostate.RepoState, target_rid: repostate.Rid, 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; 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; baseline_context.expected_hash = b.hash; baseline_context.manifest = &baseline.?.manifest; } const target_files = manifestFiles(&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; } fn manifestFiles(manifest: *const artifact.Manifest) artifact.ManifestFiles { return .{ .baseline_hash = if (manifest.b) |b| b.hash else null, .files = manifest.f, }; } 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 manifestFiles(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, }; 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; if (stat.size != expected.?.len) return false; 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); 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. const main_branch = if (config) |owned| owned.config.default_branch else "trunk"; 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. 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 }; } 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 error.Corrupt; const bytes = log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return error.Corrupt; defer gpa.free(bytes.?); if (!artifact.verify(entry.name, bytes.?)) return error.Corrupt; 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 => 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 => 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); 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); var file = parent.dir.createFile(io, parent.name, .{ .read = false, .exclusive = exclusive, .truncate = true, .permissions = switch (mode) { .regular => .default_file, .executable => .executable_file, .symlink => return error.SymlinkUnsupported, }, .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: an I/O failure writing the /// payload, or a mode the working tree cannot represent as a plain file. 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. /// A `.symlink` F card the writer cannot materialize is `SymlinkUnsupported`; /// every other failure is `Storage`. The write is 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, // `writePayload` with `exclusive=false`, `created_directories=null` // reaches only `Storage`/`SymlinkUnsupported` (it neither allocates nor // resolves check-in ids); every residual ApplyError variant collapses // to the honest "could not write it" report. 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 => 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; } fn validateWorktreePath(path: []const u8) ApplyError!void { if (path.len == 0 or std.fs.path.isAbsolute(path)) return error.InvalidPath; if (std.mem.indexOfScalar(u8, path, '\\') != null) return error.InvalidPath; var it = std.mem.splitScalar(u8, path, '/'); const first = it.next() orelse return error.InvalidPath; if (first.len == 0 or isReservedRootName(first)) return error.ReservedPath; while (it.next()) |component| { if (component.len == 0 or std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidPath; } if (std.mem.eql(u8, first, ".") or std.mem.eql(u8, first, "..")) return error.InvalidPath; } fn isReservedRootName(name: []const u8) bool { return asciiEqlIgnoreCase(name, checkout.filename) or asciiEqlIgnoreCase(name, managed.filename) or asciiStartsWithIgnoreCase(name, ".zit-update-txn-"); } fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (a, b) |left, right| { if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false; } return true; } fn asciiStartsWithIgnoreCase(value: []const u8, prefix: []const u8) bool { if (value.len < prefix.len) return false; return asciiEqlIgnoreCase(value[0..prefix.len], prefix); } 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); } 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 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); } 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 path admission before staging" { try std.testing.expectError(error.ReservedPath, validateWorktreePath(".ZiT-Managed")); try std.testing.expectError(error.ReservedPath, validateWorktreePath(".ZIT-UPDATE-TXN-deadbeef/file")); 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 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; }