Artifact 690e1a54c842a0e9aa4156860548ca9a6849196a5a4fdcd0b28cbe3224ead350:
- File
src/update.zig
— part of check-in
[32ef56aaf4]
at
2026-08-25 21:09:31
on branch rfc-vector-guard
— rfc vectors: keep them inert on disk
An unguarded vector parses as a real artifact, so Fossil crosslinks it into every clone -- two check-ins, a technote, twelve sub-manifest slices and a phantom pair that sync chases forever. Each vector now carries a leading # block; rfc_vectors.zig strips it at comptime so callers still see the exact RFC bytes.
Background and the cleanup that follows: see wiki page 'rfc-vector-ledger'. (user: dmestas, size: 203678) [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 rfc_vectors = @import("rfc_vectors.zig"); const artifact = @import("artifact.zig"); const checkout = @import("checkout.zig"); const logstore = @import("logstore.zig"); const crc32 = @import("crc32.zig"); const managed = @import("managed.zig"); const repository = @import("repository.zig"); const repocache = @import("repocache.zig"); const repostate = @import("repostate.zig"); const control_paths = @import("control_paths.zig"); const cone_mod = @import("cone.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) { /// Use the checkout's own remembered selection. inherit, /// Use exactly these selectors, and remember them. set: []const u8, /// #83 — use the checkout's remembered selection, falling back to the /// PROJECT's `manifest` setting when it has none. /// /// fossil makes `manifest` a project setting so a clone gets build stamps /// without every developer opting in by hand; zit had it only as a /// per-checkout flag. Passed in rather than read here because resolving a /// setting spans the config frame, a tracked file and the operator's global /// file — none of which this module should know about. The caller resolves; /// this module only applies. inherit_or: []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{ /// The checkout carries a sparse cone and the operation would apply /// changes it cannot represent yet -- sparse-cone RFC section 4.4's /// record-level application is not implemented in this wave, and /// proceeding would silently lose the incoming out-of-cone edits at the /// next commit (they end up in neither the baseline nor the worktree). /// Refusal is the fail-closed interim; the diagnostic names the class. SparseMergeUnsupported, /// 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, /// §6.3: the target's R aggregate content checksum disagrees with the /// content its own F run names — the check-in was authored inconsistently /// (or the store's blobs are not what the manifest attests). Fail-closed /// exactly as Fossil's default repo-cksum checkout does; materializing a /// tree the check-in itself disowns helps no one. AggregateChecksumMismatch, CheckinNameMismatch, TargetNotManifest, BaselineNotFound, BaselineContentMissing, BaselineNameMismatch, BaselineNotManifest, BaselineIsDelta, /// A stored artifact could not be turned into a check-in state record. /// This is a statement about one artifact's shape, not about repository /// integrity — see `Corrupt` for that. StateRecordInvalid, InvalidPath, ReservedPath, SymlinkUnsupported, CurrentStateInvalid, WorktreeNotClean, UnmanagedCollision, Storage, Corrupt, OutOfMemory, TransactionUnavailable, RollbackFailed, CleanupFailed, }; const ParentError = ApplyError || error{PathMissing}; const TestFault = enum { after_materialize, before_cleanup, merge_mid_apply, }; // Tests use these boundaries to deterministically exercise the transactional // branches that ordinary filesystem failures cannot reliably reach. Production // builds always return false and therefore retain their normal behavior. var test_fault: ?TestFault = null; fn shouldInjectTestFault(point: TestFault) bool { if (comptime @import("builtin").is_test) return test_fault == point; return false; } const PlanEntry = struct { path: []const u8, name: []const u8, digest: [logstore.max_hash_len]u8, digest_len: u8, mode: artifact.FileMode, /// This path is byte-identical (same content digest, same mode) in the /// BASELINE plan, and `requireClean` has already proven the file on disk /// matches the baseline -- so the correct on-disk state for it under the /// target is the file that is already there. Every mutation-phase loop /// skips it: it is not staged in either direction, not journaled, not /// deleted, not rewritten. Set by `markUnchanged` on `update`'s plans; /// never set on `open`'s (whose baseline is empty) or on any other path, /// so their behavior is exactly what it was. unchanged: bool = false, /// Bytes to materialize for this path INSTEAD of the check-in content /// `digest` names — the working tree's own version, carried across a /// materialization that would otherwise overwrite it (`CarrySet`). Borrowed /// from the caller's arena, which outlives the call. Null on every plan /// built for a clean tree, so nothing about those changes. /// /// A carried entry is a deliberate disagreement between the plan's digest /// and the bytes that land on disk, which is why `stagePlan` writes these /// verbatim (no `artifact.verify`, there being nothing to verify against) /// and why `VStat.writeForPlan` must skip the entry: a sidecar row pairing /// this path's stat with the check-in's digest would assert the file is /// unmodified, and the next `scanTree` would believe it. carry: ?[]const u8 = null, 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, /// The target manifest's R aggregate checksum (32 lowercase hex), when it /// carries one — held so materialization can verify the plan's content /// against the check-in's own attestation (§6.3). Null for a manifest /// without R (legal: Fossil's repo-cksum can be off). r_checksum: ?[32]u8 = null, fn deinit(self: *Plan, gpa: std.mem.Allocator) void { for (self.entries.items) |*entry| entry.deinit(gpa); self.entries.deinit(gpa); self.* = undefined; } /// Binary search for `path`, exploiting the ascending-byte-order invariant /// `validateTopology` establishes. Returns the index when present, and /// otherwise the index the path would be inserted at. fn locate(self: *const Plan, path: []const u8) struct { idx: usize, found: bool } { var lo: usize = 0; var hi: usize = self.entries.items.len; while (lo < hi) { const mid = lo + (hi - lo) / 2; switch (std.mem.order(u8, self.entries.items[mid].path, path)) { .lt => lo = mid + 1, .gt => hi = mid, .eq => return .{ .idx = mid, .found = true }, } } return .{ .idx = lo, .found = false }; } /// Make `path` materialize `content` verbatim, inserting the entry when the /// plan does not already have it. A carried entry keeps no meaningful /// digest: its bytes are the working tree's and name no artifact, so the /// digest is zeroed rather than left describing content that will not be /// written. `stagePlan` never looks it up (it writes `carry` directly) and /// `VStat.writeForPlan` abandons the sidecar entirely for such a plan, so /// there is no reader left to mislead. fn setCarried(self: *Plan, gpa: std.mem.Allocator, path: []const u8, content: Carry.Content) ApplyError!void { const at = self.locate(path); if (at.found) { const entry = &self.entries.items[at.idx]; entry.carry = content.bytes; entry.mode = content.mode; entry.unchanged = false; return; } const owned_path = gpa.dupe(u8, path) catch return error.OutOfMemory; errdefer gpa.free(owned_path); const owned_name = gpa.dupe(u8, "") catch return error.OutOfMemory; errdefer gpa.free(owned_name); self.entries.insert(gpa, at.idx, .{ .path = owned_path, .name = owned_name, .digest = std.mem.zeroes([logstore.max_hash_len]u8), .digest_len = 0, .mode = content.mode, .carry = content.bytes, }) catch return error.OutOfMemory; } /// Drop `path` from the plan if it is there. Absent is success: a carry may /// name a path neither check-in has (locally added, then locally deleted). fn removePath(self: *Plan, gpa: std.mem.Allocator, path: []const u8) void { const at = self.locate(path); if (!at.found) return; var entry = self.entries.orderedRemove(at.idx); entry.deinit(gpa); } 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); } }; /// The last parent directory a plan walk opened, kept open across entries. /// A plan is path-sorted, so consecutive entries overwhelmingly share a parent; /// without this every entry re-walked its directory chain component by /// component -- one openat+close per level, several times per file -- which /// was the single largest cost of materializing a tree (#114, ~31% of wall). /// `prefix` borrows the plan entry's own path bytes, which outlive the walk. const ParentCache = struct { dir: ?std.Io.Dir = null, prefix: []const u8 = "", fn deinit(self: *ParentCache, io: std.Io) void { if (self.dir) |*d| d.close(io); self.dir = null; self.prefix = ""; } }; /// `openParentNoFollow` through a `ParentCache`: a hit hands back the cached /// handle (never owned by the caller), a miss walks, then caches the result. fn openParentCached( cache: *ParentCache, io: std.Io, root: std.Io.Dir, path: []const u8, create: bool, created_directories: ?*CreatedDirectories, ) ParentError!OpenedParent { const slash = std.mem.lastIndexOfScalar(u8, path, '/'); const prefix = if (slash) |i| path[0..i] else ""; const name = if (slash) |i| path[i + 1 ..] else path; if (cache.dir) |d| { if (std.mem.eql(u8, cache.prefix, prefix)) return .{ .dir = d, .name = name, .owned = false }; } const opened = try openParentNoFollow(io, root, path, create, created_directories); cache.deinit(io); if (opened.owned) { cache.dir = opened.dir; cache.prefix = prefix; } return .{ .dir = opened.dir, .name = opened.name, .owned = false }; } 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. /// The checkout's stored sparse cone (container section 5), or null when /// dense. A present-but-unparseable cone is authoritative state that fails /// closed as Corrupt -- never silently treated as dense, which would make a /// damaged byte quietly rematerialize an 80k-file tree. pub fn readStoredCone(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError!?cone_mod.Cone { const payload = checkout.readSectionAlloc(gpa, io, worktree, .cone) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, }; const bytes = payload orelse return null; defer gpa.free(bytes); const parsed = cone_mod.Cone.parse(gpa, bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.InvalidMember, error.ReservedMember, error.Bounds => return error.Corrupt, error.DeadExclusion, error.Contradiction => return error.Corrupt, }; return parsed; } /// The cone this materialization applies -- the `ManifestOpt` pattern. /// `.inherit` uses the checkout's stored cone (dense when none); `.set` /// replaces it (null payload = return to dense). The record and cone commit /// in the same container splice as the managed set. pub const SparseOpt = union(enum) { inherit, set: ?*const cone_mod.Cone, }; /// One path the working tree keeps across a materialization that would /// otherwise overwrite it with the target's version (#135). /// /// Both endpoints are named because both plans are staged: `after` is what must /// be on disk when the update commits — for a dirty `update`, the three-way /// merge of (current check-in, working file, target) — and `before` is what is /// on disk right now, which is what a failed update has to put back. Supplying /// only `after` would leave the rollback image holding the CHECK-IN's content /// for these paths, so an update that failed halfway would restore committed /// bytes over the operator's edits: a rollback that loses work. /// /// Either side may be null, meaning the path does not exist at that endpoint — /// `after == null` is how a local delete survives a target that still carries /// the file, and `before == null` is a path the merge adds. /// /// A carry always wins over the target plan, because it was computed FROM the /// target: the merge already decided what the target's contribution to this /// path is, so re-applying the plan's own content on top would undo it. pub const Carry = struct { path: []const u8, after: ?Content, before: ?Content, pub const Content = struct { bytes: []const u8, mode: artifact.FileMode = .regular, }; }; pub fn applyCheckin( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, target_id: []const u8, manifest: ManifestOpt, sparse: SparseOpt, ) ApplyError!void { return applyCheckinWith(gpa, io, worktree, log, target_id, manifest, sparse, null, null); } /// `applyCheckin` with the caller's already-open `Repository`, when it has /// one. The target's branch and tags are read from it instead of opening a /// second repository for the lookup -- a full repo-state derive, which was a /// fifth of `zit open`'s wall time on a 146k-artifact store (#114). A caller /// without one (the unit tests, the post-fetch retry) passes null and pays /// the derive. pub fn applyCheckinWith( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, target_id: []const u8, manifest: ManifestOpt, sparse: SparseOpt, repo: ?*repository.Repository, /// How to keep working-tree content across the move (#135). Null is the /// plain materialization every caller but `update` wants: the tree must be /// clean or the update refuses, exactly as before this parameter existed. carry_resolver: ?CarryResolver, ) ApplyError!void { // Resolve the cone this materialization runs under. Read before any plan // is built; the stored cone (when inheriting) must outlive both plans. var inherited_cone: ?cone_mod.Cone = null; defer if (inherited_cone) |*c| c.deinit(gpa); const effective_cone: ?*const cone_mod.Cone = switch (sparse) { .set => |c| c, .inherit => blk: { inherited_cone = try readStoredCone(gpa, io, worktree); break :blk if (inherited_cone) |*c| c else null; }, }; // The section update the commit splice carries: only `.set` changes the // stored cone; `.inherit` carries it forward untouched (null here). var cone_payload: ?[]u8 = null; defer if (cone_payload) |cp| gpa.free(cp); const cone_section_update: ?checkout.SectionUpdate = switch (sparse) { .inherit => null, .set => |c| blk: { if (c) |real| { cone_payload = real.serialize(gpa) catch return error.OutOfMemory; break :blk .{ .tag = .cone, .payload = cone_payload.? }; } break :blk .{ .tag = .cone, .payload = null }; // return to dense }, }; var target = try buildPlan(gpa, log, target_id, effective_cone); defer target.deinit(gpa); if (effective_cone == null) { try verifyPlanAggregate(gpa, log, &target); } else { // Sparse-cone RFC section 4.3 (verification side): a present R card // is a WHOLE-TREE aggregate, so a coned plan can never satisfy it. // The aggregate pass reads blobs from the LOG, not the worktree, and // the store is dense in Wave 1 -- so verify over a dense plan built // for exactly this check. The cone narrows materialization, never R. var dense_probe = try buildPlan(gpa, log, target_id, null); defer dense_probe.deinit(gpa); try verifyPlanAggregate(gpa, log, &dense_probe); } // 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 ""; }, .inherit_or => |project| blk: { const cur = checkout.read(io, worktree, &ck_buf) catch |err| return mapCheckoutError(err); const own = if (cur) |c| c.manifest_flags else ""; // The checkout's own choice wins: an operator who ran // `open --manifest=` to turn stamps off must not have the project // setting turn them back on at the next update. break :blk if (own.len != 0) own else project; }, }; var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer old_managed.deinit(); // The ONE scan of the working tree. Without a resolver it is the historical // refusing scan; with one it collects instead, and the resolver decides what // to do about each divergence. A clean tree yields no divergences and so // never reaches the resolver, which is why carrying costs a clean update // nothing at all. var divergences: std.ArrayList(Divergence) = .empty; defer divergences.deinit(gpa); var old = if (carry_resolver == null) try requireClean(gpa, io, worktree, log, &old_managed) else try scanTree(gpa, io, worktree, log, &old_managed, &divergences); defer old.deinit(gpa); // The divergence paths borrow `old` and `old_managed`, both of which // outlive this call. `Refused` reads as WorktreeNotClean because that is // precisely what it means — the tree diverges in a way this move cannot // reconcile — and the resolver has already named the paths. const carry: []const Carry = if (divergences.items.len == 0) &.{} else blk: { const r = carry_resolver.?; break :blk r.resolve(r.ctx, divergences.items) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Refused => return error.WorktreeNotClean, }; }; const want_tags = std.mem.indexOfScalar(u8, manifest_flags, 't') != null; var facts = cachedTargetFacts(gpa, io, worktree, target_id, want_tags, repo) orelse try deriveTargetFacts(gpa, log, target_id, want_tags); defer facts.deinit(gpa); const branch = facts.branch; // Bulk-built from the plan's own (sorted, unique) order: the one-at-a-time // `insert` loop this replaces was quadratic and measured at 23.9s of a // 68k-file update -- larger than every real phase of the verb combined. const plan_paths = gpa.alloc([]const u8, target.entries.items.len) catch return error.OutOfMemory; defer gpa.free(plan_paths); for (target.entries.items, plan_paths) |entry, *slot| slot.* = entry.path; var desired_managed = managed.ManagedSet.fromSortedPaths(gpa, plan_paths) catch |err| return mapManagedError(err); defer desired_managed.deinit(); // The managed set this update will commit is EXACTLY the target plan's // membership: one entry per plan path. `validateTopology` proved those paths // unique (and non-nesting), so every dedup insert added a distinct path — // the two can never disagree in count, the first half of the managed-set / // record / tree agreement the commit below depends on. std.debug.assert(desired_managed.iterate().len == target.entries.items.len); // Both plans are final from here on, so the unchanged marks computed now // hold for every phase below. try markUnchanged(gpa, &target, &old); // Overlay the carries LAST, so nothing above can be computed from them: // `verifyPlanAggregate` has already checked the target's R card against the // check-in's own content (a carried byte is not the check-in's and has no // place in its attestation), `desired_managed` is the target's membership, // and `markUnchanged` has compared the two plans as the store describes // them. Overlaying clears `unchanged` on every path it touches, so a // carried path is always written even when the two check-ins agree about it. if (carry.len != 0) { try overlayCarry(gpa, &target, &old, carry, &desired_managed); // The managed set must still describe exactly what the target plan // materializes, or the commit records a membership the tree does not // have. `overlayCarry` maintains both together for this reason. 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 staged in this same-filesystem transaction dir, from // which the commit renames them into place. Not fsync'd: the tree's // crash image is the undo journal's to guarantee, not the stage's. try stagePlan(gpa, io, log, next, &target); try stagePlan(gpa, io, log, previous, &old); try preflight(gpa, io, worktree, &target, &old_managed); var created_directories = try CreatedDirectories.init(gpa, &target); defer created_directories.deinit(gpa); // Journal the undo image (B-undo-3/14): the pre-mutation bytes of every path // this update writes or deletes — `old_managed ∪ target` — plus the current // `.zit-checkout`/`.zit-managed`. Written and fsynced HERE, before // `commitMaterialization` moves the first working-file byte. Placed after // `requireClean`/`preflight`, so a rejected update never journals; the // rollback branch below clears it so a cleanly-undone update leaves no level. try recordUndoImage(gpa, io, worktree, &old, &target); commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{ .version = target_id, .branch = branch, // `update` never carries a pending merge (that is `merge`'s job); a // plain check-out always clears it — and never a pending special merge. .merge_parent = "", .special_op = "", .manifest_flags = manifest_flags, }, .{ .log = log, .target_id = target_id, .tags = facts.tags }, cone_section_update) 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; }; // Sparse-cone RFC section 5.4: on a store that is ALREADY sparse, a // `.set` cone request narrows (or widens) store policy alongside the // worktree -- one flag, one intent. On a dense store the request stays // worktree-only: creating store policy here would silently make // `pack --drop-elided` eligible to reclaim, an escalation only the // fetch paths (which the operator sees) and clone --cone may perform. // Best-effort and post-commit-point, like the vstat sidecar below. if (sparse == .set) { var existing = repocache.readStoreCone(gpa, io, worktree); if (existing) |*c| { c.deinit(gpa); repocache.writeStoreCone(gpa, io, worktree, switch (sparse) { .set => |req| req, .inherit => unreachable, }); } } // The tree and record are committed; remember what was just written so // the next clean check can stat instead of reconstruct. After the commit // point deliberately: a sidecar describing a tree that failed to commit // would be a lie, while a missing sidecar is only slowness. VStat.writeForPlan(gpa, io, worktree, &target, target_id); // `.zit-checkout` is the commit point. A post-commit cleanup error is // reported but must never roll the committed tree or metadata back. transaction_live = false; if (shouldInjectTestFault(.before_cleanup)) return error.CleanupFailed; worktree.deleteTree(io, txn_name) catch return error.CleanupFailed; } /// Build the affected-path set (`old_managed ∪ target`, deduplicated by a /// throwaway `ManagedSet`) and hand it to the undo journal (B-undo-14: exactly /// one seam per mutator). The set unifies both directions of the mutation — a /// path the update writes, a path it deletes — so `zit undo` restores every one. fn recordUndoImage( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, old: *const Plan, target: *const Plan, ) ApplyError!void { var affected = managed.ManagedSet.init(gpa); defer affected.deinit(); // The OLD PLAN stands in for the old managed set: `requireClean` proved // their memberships identical before this runs, and the plan carries the // unchanged marks the set cannot. An unchanged path is neither written // nor deleted by this update, so B-undo-3's "every path this update // writes or deletes" excludes it by its own words. for (old.entries.items) |entry| { if (entry.unchanged) continue; _ = affected.insert(entry.path) catch |err| return mapManagedError(err); } for (target.entries.items) |entry| { if (entry.unchanged) continue; _ = affected.insert(entry.path) catch |err| return mapManagedError(err); } // The affected set is the union of both plans' CHANGED entries, journaled // before the first working byte moves (B-undo-3/14). It can therefore never // be larger than either plan, and never smaller than the target's changed // count -- the direction the durability rule actually needs. std.debug.assert(affected.iterate().len <= old.entries.items.len + target.entries.items.len); journal.record(gpa, io, worktree, affected.iterate()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // A journal device fault must abort BEFORE any working file moves: the // durability rule forbids mutating without a recoverable pre-image. error.Storage, error.Corrupt, error.Empty => return error.Storage, }; } /// Apply the caller's carries to the target plan (what materializes) and the /// baseline plan (what a failed update restores), keeping `desired_managed` in /// step with the target's membership. /// /// Every touched entry loses its `unchanged` mark: that mark means "the file /// already on disk is correct", which is decided by comparing the two CHECK-INS /// and is exactly the assumption a carry contradicts. fn overlayCarry( gpa: std.mem.Allocator, target: *Plan, old: *Plan, carry: []const Carry, desired_managed: *managed.ManagedSet, ) ApplyError!void { for (carry) |item| { try validateWorktreePath(item.path); if (item.after) |after| { try target.setCarried(gpa, item.path, after); _ = desired_managed.insert(item.path) catch |err| return mapManagedError(err); } else { target.removePath(gpa, item.path); _ = desired_managed.remove(item.path); } if (item.before) |before| { try old.setCarried(gpa, item.path, before); } else { old.removePath(gpa, item.path); } } } fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8, sparse: ?*const cone_mod.Cone) 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, }; // Sparse-cone RFC section 4.1: the cone lands here, at the seam, once -- // every plan consumer inherits the filter and no call site threads it. walk.sparse = sparse; var result = Plan{}; errdefer result.deinit(gpa); if (target.manifest.r) |r| { std.debug.assert(r.checksum.len == 32); // §4.7.1: the parser admits exactly 32 hex var r_owned: [32]u8 = undefined; @memcpy(&r_owned, r.checksum[0..32]); result.r_checksum = r_owned; } while (walk.next()) |file| { if (file.hash == null) continue; try result.add(gpa, file); } try result.validateTopology(); return result; } /// §6.3 the checkout-side R verification, Fossil's default repo-cksum /// behavior: when the target carries an R card, recompute the aggregate over /// the PLAN's enumeration from the log's own blobs and refuse a mismatch /// before anything is materialized. Entries whose F run omitted a hash never /// enter the plan, matching §6.3.2's enumeration of the effective set. A /// manifest without R (repo-cksum off at its writer) verifies nothing — /// absence is legal, disagreement is not. One full content pass over the /// plan; the price Fossil's default pays per checkout, paid here in the one /// verb family that materializes a check-in. fn verifyPlanAggregate(gpa: std.mem.Allocator, log: *logstore.LogStore, plan: *const Plan) ApplyError!void { const want = plan.r_checksum orelse return; var agg: artifact.ChecksumAggregator = .{}; for (plan.entries.items) |entry| { // A blob that cannot be read is not R's finding: the checksum claim is // "when the content is all present, it must agree". Abstain and let // the staging pass name the missing/damaged file precisely. const rid = log.lookup(entry.digestSlice()) orelse return; const bytes = (log.content(gpa, rid) catch |err| return mapContentError(err)) orelse return; defer gpa.free(bytes); agg.update(entry.path, bytes); } var got: [32]u8 = undefined; _ = agg.final(&got); if (!std.mem.eql(u8, &got, &want)) return error.AggregateChecksumMismatch; } /// 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); } /// How one path's working-tree state differs from the check-in the checkout /// names. Only divergent paths get one; a clean path is absent from the list. pub const DivergenceKind = enum { /// Managed and present in the check-in, but the bytes (or mode) on disk /// differ from it. modified, /// Managed and present in the check-in, but absent from the working tree. missing, /// Managed but NOT in the check-in — `zit add` since the materialization. added, /// In the check-in but no longer managed — `zit rm` since. removed, }; /// One divergent path. `path` borrows the managed set / plan that produced it, /// both of which die with the scan, so `scanWorktree` dupes onto its own arena. pub const Divergence = struct { path: []const u8, kind: DivergenceKind, }; /// How a carrying `update` decides what to keep. `applyCheckinWith` scans the /// working tree ONCE and, only if it finds divergence, hands the whole list /// here; the answer is the carries to overlay. /// /// A callback rather than a plain `[]const Carry` parameter because the caller /// cannot compute carries without knowing the divergences, and it cannot learn /// the divergences without a scan — so passing them in would mean scanning the /// tree twice for every update, including the clean ones that never carry /// anything. Measured on a 20k-file tree, that doubled scan cost a clean no-op /// update 63% (0.38s -> 0.62s). Inverting the call keeps it at one scan. /// /// The policy (three-way merging each divergent path) lives with the caller, /// which has the repository and the merge engine; the scan and the transaction /// live here. Neither half needs the other's machinery. pub const CarryResolver = struct { ctx: *anyopaque, /// `divergences` is ascending by path and borrows storage that dies when /// this call returns — copy anything that must outlive it. The returned /// carries must outlive the whole `applyCheckinWith` call. /// /// `error.Refused` means "these changes cannot be carried"; the caller has /// already reported which and why, and the update aborts before mutating /// anything. resolve: *const fn (ctx: *anyopaque, divergences: []const Divergence) error{ OutOfMemory, Refused }![]const Carry, }; /// The divergent paths of the working tree against the check-in it is on, /// ascending by path, duped onto `arena`. Empty means clean — exactly the /// condition `requireCleanTree` demands. /// /// `update` does not use this: it carries content across a materialization it /// already scans for, so it takes its divergences from that one scan via /// `CarryResolver`. `merge` does not materialize a check-in — it applies an /// outcome set through `applyMerge` — so it has no scan of its own to piggyback /// on and asks here instead. pub fn scanWorktree( gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, ) ApplyError![]const Divergence { var current_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err); defer current_managed.deinit(); var found: std.ArrayList(Divergence) = .empty; defer found.deinit(gpa); // The recorded paths BORROW the plan and the managed set, so they are duped // onto the arena before either dies — not after. var plan = try scanTree(gpa, io, worktree, log, ¤t_managed, &found); defer plan.deinit(gpa); const out = arena.alloc(Divergence, found.items.len) catch return error.OutOfMemory; for (found.items, out) |src, *dst| { dst.* = .{ .path = arena.dupe(u8, src.path) catch return error.OutOfMemory, .kind = src.kind, }; } return out; } fn requireClean( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, current_managed: *const managed.ManagedSet, ) ApplyError!Plan { return scanTree(gpa, io, worktree, log, current_managed, null); } /// The one worktree-vs-check-in comparison, in both of its modes. `found` null /// is the historical `requireClean`: the FIRST divergence returns /// `WorktreeNotClean` and no plan. `found` non-null records every divergence /// and returns the baseline plan regardless, for a caller that intends to merge /// the differences rather than refuse them. /// /// Deliberately one function and not two: the modes must agree about what /// "clean" means down to the last stat, or `update` would refuse a tree that /// the merge path considers unchanged (or worse, the reverse). fn scanTree( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, current_managed: *const managed.ManagedSet, found: ?*std.ArrayList(Divergence), ) 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); // The cone that produced the CURRENT materialization and managed set. // Deriving it here (not taking it as a parameter) keeps every caller -- // update, merge, revert -- incapable of comparing an in-cone managed set // against a dense plan, the mismatch that reports every out-of-cone file // as missing (sparse-cone RFC section 4.1). var stored_cone = try readStoredCone(gpa, io, worktree); defer if (stored_cone) |*c| c.deinit(gpa); // 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) { // Every managed path is an `added`: an unborn checkout names no // check-in, so nothing on disk can match one. if (found) |list| { for (current_managed.iterate()) |path| { list.append(gpa, .{ .path = path, .kind = .added }) catch return error.OutOfMemory; } } else return error.WorktreeNotClean; } // A record that names no check-in yet claims a merge parent or a // pending special operation is incoherent, not a fresh clone: both // describe work in progress against a check-in that the record does not // name. Only a present record can carry them. if (current) |c| { if (c.merge_parent.len != 0 or c.special_op.len != 0) return error.CurrentStateInvalid; } return Plan{}; } var vstat = VStat.read(gpa, io, worktree); defer vstat.deinit(gpa); // The sidecar-as-plan fast path: when the sidecar describes exactly the // current check-in AND its membership agrees with the authoritative // managed set, the baseline plan is reconstructed with no tree flatten at // all -- removing one of the two O(width) walks that made `update` cost // the same regardless of how little changed. A sidecar that fails either // test is discarded (not refused): staleness costs a flatten, never an // error, and every entry is still individually verified below before // anything acts on it. var plan = blk: { if (vstat.planFor(gpa, current.?.version)) |candidate_const| { var candidate = candidate_const; if (sameMembership(&candidate, current_managed)) break :blk candidate; candidate.deinit(gpa); } break :blk buildPlan(gpa, log, current.?.version, if (stored_cone) |*c| c else null) catch |err| switch (err) { error.OutOfMemory, error.Storage, error.Corrupt => return err, else => return error.CurrentStateInvalid, }; }; errdefer plan.deinit(gpa); const membership_drifted = !sameMembership(&plan, current_managed); if (membership_drifted) { if (found) |list| { try recordMembershipDrift(gpa, list, &plan, current_managed); } else return error.WorktreeNotClean; } for (plan.entries.items) |*entry| { // A path the check-in has but the checkout no longer manages was // already recorded as `removed` above; its on-disk state is not this // check-in's business, so it is not content-compared. Guarded on the // drift flag so the overwhelmingly common no-drift scan does not pay a // set lookup per path — this loop runs once per file in the check-in. if (membership_drifted and !current_managed.contains(entry.path)) continue; // Stat-first: a sidecar row carrying THIS entry's digest whose inode, // size and mtime still match the file proves it unmodified since the // materialization that wrote both. Everything else takes the full // content compare, which stays the authority. if (entry.mode != .symlink) { const existing = try statExisting(io, worktree, entry.path); switch (existing) { .missing => { if (found) |list| { list.append(gpa, .{ .path = entry.path, .kind = .missing }) catch return error.OutOfMemory; continue; } return error.WorktreeNotClean; }, .stat => |stat| if (modeMatches(stat, entry.mode) and vstat.proves(entry, stat)) continue, } } if (!try pathMatchesEntry(gpa, io, worktree, log, entry)) { if (found) |list| { list.append(gpa, .{ .path = entry.path, .kind = .modified }) catch return error.OutOfMemory; continue; } return error.WorktreeNotClean; } } // Membership drift is recorded in one pass and content drift in another, so // the two arrive grouped rather than interleaved. Sorting here makes // "ascending by path" a property of the RESULT rather than of the order the // passes happened to run in — which is what a caller reporting a path list // depends on. if (found) |list| { std.mem.sort(Divergence, list.items, {}, struct { fn lessThan(_: void, a: Divergence, b: Divergence) bool { return std.mem.order(u8, a.path, b.path) == .lt; } }.lessThan); } // Postcondition: a returned plan is the current check-in's. In refusing // mode its membership also equals the recorded managed set — the guard // above rejected any drift — so the record and the managed set agree on // every success path a caller of `requireClean` can reach. In collecting // mode the drift is the answer, not a fault, so it is reported instead. std.debug.assert(found != null or sameMembership(&plan, current_managed)); return plan; } /// The membership half of a dirty scan: paths the checkout manages that the /// check-in does not (`added`), and paths the check-in has that the checkout no /// longer manages (`removed`). Both lists are sorted and unique — the plan's /// order is `Plan.validateTopology`'s, the managed set's is its own — so this /// is one merge walk, not a pair of nested scans. fn recordMembershipDrift( gpa: std.mem.Allocator, list: *std.ArrayList(Divergence), plan: *const Plan, current_managed: *const managed.ManagedSet, ) ApplyError!void { const paths = current_managed.iterate(); var i: usize = 0; // into plan.entries (the check-in's set) var j: usize = 0; // into paths (the managed set) while (i < plan.entries.items.len or j < paths.len) { const in_plan = if (i < plan.entries.items.len) plan.entries.items[i].path else null; const in_managed = if (j < paths.len) paths[j] else null; const order: std.math.Order = if (in_plan == null) .gt else if (in_managed == null) .lt else std.mem.order(u8, in_plan.?, in_managed.?); switch (order) { .eq => { i += 1; j += 1; }, .lt => { list.append(gpa, .{ .path = in_plan.?, .kind = .removed }) catch return error.OutOfMemory; i += 1; }, .gt => { list.append(gpa, .{ .path = in_managed.?, .kind = .added }) catch return error.OutOfMemory; j += 1; }, } } } fn sameMembership(plan: *const Plan, current: *const managed.ManagedSet) bool { const paths = current.iterate(); if (paths.len != plan.entries.items.len) return false; for (paths, plan.entries.items) |path, entry| { if (!std.mem.eql(u8, path, entry.path)) return false; } return true; } /// The stat sidecar: `(path, inode, size, mtime, digest)` for every file the /// last materialization wrote, so the NEXT clean check can prove "unmodified /// since we wrote it" with one stat instead of reconstructing the file's /// plaintext out of the log. On a store with deep per-file delta chains that /// reconstruction dominated `update` outright: a no-op update measured ~109s /// of pure CPU, all of it `requireClean` re-deriving content that had not /// moved. /// /// Advisory, never authoritative. A row is trusted only when its digest is /// the digest the CURRENT baseline expects AND inode, size and mtime all /// still match the file on disk; anything else -- absent file, absent row, /// stale digest, any stat drift, unreadable or malformed sidecar -- falls /// back to the full content compare below, which remains the authority. The /// accepted trade is fossil's own vfile trade: an edit that restores mtime, /// size AND inode exactly is invisible to the stat check. const VStat = struct { /// A member of the advisory `.zit-cache/` directory, beside repocache's /// `repo`. One root name for everything disposable. const filename = repocache.cache_dirname ++ "/vstat"; const magic = "zitvstat"; /// v2: the sidecar carries the check-in it describes plus each entry's /// mode, a whole-file CRC, and PLAN ORDER -- which together make it the /// BASELINE PLAN, not merely a stat memo. A v1 file fails the version /// check, reads as empty, and costs one flatten to rebuild. const version: u16 = 2; const Row = struct { inode: u64, size: u64, mtime_ns: i96, digest: [logstore.max_hash_len]u8, digest_len: u8, mode: artifact.FileMode, }; const Entry = struct { path: []const u8, row: Row, }; /// The check-in whose materialization wrote these rows. The rows are only /// meaningful AS A PLAN for exactly this version; `planFor` refuses any /// other, and staleness therefore costs a flatten rather than an error. checkin: []const u8 = &.{}, /// In plan order -- `artifact.enumerate` emits paths sorted, `writeForPlan` /// preserves that, and `validateTopology` depends on it. entries: std.ArrayList(Entry) = .empty, index: std.StringHashMapUnmanaged(usize) = .empty, fn appendLittle(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime T: type, value: T) error{OutOfMemory}!void { var buf: [@divExact(@typeInfo(T).int.bits, 8)]u8 = undefined; std.mem.writeInt(T, &buf, value, .little); try out.appendSlice(gpa, &buf); } fn deinit(self: *VStat, gpa: std.mem.Allocator) void { if (self.checkin.len != 0) gpa.free(self.checkin); for (self.entries.items) |entry| gpa.free(entry.path); self.entries.deinit(gpa); self.index.deinit(gpa); self.* = undefined; } /// Absent, unreadable, malformed, torn, or wrong-version reads as EMPTY -- /// the sidecar can only ever remove work, never add a failure mode. fn read(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) VStat { var self: VStat = .{}; var file = worktree.openFile(io, filename, .{}) catch return self; defer file.close(io); const st = file.stat(io) catch return self; const size: usize = std.math.cast(usize, st.size) orelse return self; const buf = gpa.alloc(u8, size) catch return self; defer gpa.free(buf); const n = file.readPositionalAll(io, buf, 0) catch return self; self.parse(gpa, buf[0..n]) catch { self.deinit(gpa); self = .{}; }; return self; } fn parse(self: *VStat, gpa: std.mem.Allocator, bytes: []const u8) !void { const crc_len = 4; if (bytes.len < magic.len + 4 + crc_len) return error.Corrupt; // Whole-file CRC first: a torn write must read as absence, never as a // shorter-but-plausible plan. const stored_crc = std.mem.readInt(u32, bytes[bytes.len - crc_len ..][0..4], .little); var crc = crc32.Crc32.init(); crc.update(bytes[0 .. bytes.len - crc_len]); if (crc.final() != stored_crc) return error.Corrupt; const body = bytes[0 .. bytes.len - crc_len]; var pos: usize = 0; if (!std.mem.eql(u8, body[0..magic.len], magic)) return error.Corrupt; pos = magic.len; if (std.mem.readInt(u16, body[pos..][0..2], .little) != version) return error.Corrupt; pos += 2; const checkin_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little); pos += 2; if (body.len - pos < checkin_len) return error.Corrupt; self.checkin = try gpa.dupe(u8, body[pos..][0..checkin_len]); pos += checkin_len; while (pos < body.len) { if (body.len - pos < 2) return error.Corrupt; const path_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little); pos += 2; const fixed = 8 + 8 + 12 + 1; if (body.len - pos < path_len + fixed) return error.Corrupt; const path = body[pos..][0..path_len]; pos += path_len; var row: Row = undefined; row.inode = std.mem.readInt(u64, body[pos..][0..8], .little); pos += 8; row.size = std.mem.readInt(u64, body[pos..][0..8], .little); pos += 8; row.mtime_ns = @bitCast(std.mem.readInt(u96, body[pos..][0..12], .little)); pos += 12; row.digest_len = body[pos]; pos += 1; if (row.digest_len > logstore.max_hash_len or body.len - pos < row.digest_len + 1) return error.Corrupt; @memcpy(row.digest[0..row.digest_len], body[pos..][0..row.digest_len]); pos += row.digest_len; const mode_byte = body[pos]; pos += 1; if (mode_byte > @intFromEnum(artifact.FileMode.symlink)) return error.Corrupt; row.mode = @enumFromInt(mode_byte); const owned = try gpa.dupe(u8, path); errdefer gpa.free(owned); const gop = try self.index.getOrPut(gpa, owned); if (gop.found_existing) { gpa.free(owned); return error.Corrupt; // a duplicate path is not a record this writer produces } gop.value_ptr.* = self.entries.items.len; try self.entries.append(gpa, .{ .path = owned, .row = row }); } } /// Whether `entry`'s file can be proven unmodified by stat alone. fn proves(self: *const VStat, entry: *const PlanEntry, stat: std.Io.File.Stat) bool { const at = self.index.get(entry.path) orelse return false; const row = self.entries.items[at].row; if (row.digest_len != entry.digest_len) return false; if (!std.mem.eql(u8, row.digest[0..row.digest_len], entry.digestSlice())) return false; if (row.inode != @as(u64, @bitCast(stat.inode))) return false; if (row.size != stat.size) return false; return row.mtime_ns == stat.mtime.nanoseconds; } /// The BASELINE PLAN, reconstructed with no flatten, when and only when /// this sidecar describes exactly `checkin_id`. This is the step that /// removes one of `update`'s two O(width) tree walks: the rows were /// serialized from the plan the last materialization ran, in plan order, /// under a whole-file CRC -- the same trust `.zit-managed` gets, and less /// than `.zit-cache` gets, since every entry is still individually /// verified (stat-first, content on any doubt) by `requireClean` before /// anything acts on it. Null on any shortfall; the caller flattens. fn planFor(self: *const VStat, gpa: std.mem.Allocator, checkin_id: []const u8) ?Plan { if (self.checkin.len == 0 or !std.mem.eql(u8, self.checkin, checkin_id)) return null; var plan = Plan{}; var ok = false; defer if (!ok) plan.deinit(gpa); for (self.entries.items) |entry| { validateWorktreePath(entry.path) catch return null; var name_buf: [64]u8 = undefined; const name = encodeName(entry.row.digest[0..entry.row.digest_len], &name_buf); const path = gpa.dupe(u8, entry.path) catch return null; const owned_name = gpa.dupe(u8, name) catch { gpa.free(path); return null; }; plan.entries.append(gpa, .{ .path = path, .name = owned_name, .digest = entry.row.digest, .digest_len = entry.row.digest_len, .mode = entry.row.mode, }) catch { gpa.free(path); gpa.free(owned_name); return null; }; } plan.validateTopology() catch return null; ok = true; return plan; } /// Stat every plan entry and write the sidecar, atomically, mirroring the /// managed set's writer. Best-effort at every step -- EXCEPT that a file /// that cannot be stat'ed abandons the write entirely: v2's rows are a /// plan, and a plan missing an entry would reconstruct a baseline missing /// a file, where v1's per-row omission only cost a content compare. fn writeForPlan(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, plan: *const Plan, checkin_id: []const u8) void { if (checkin_id.len == 0 or checkin_id.len > std.math.maxInt(u16)) return; var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); out.appendSlice(gpa, magic) catch return; appendLittle(gpa, &out, u16, version) catch return; appendLittle(gpa, &out, u16, @intCast(checkin_id.len)) catch return; out.appendSlice(gpa, checkin_id) catch return; var parents: ParentCache = .{}; defer parents.deinit(io); // A carried path holds the working tree's bytes, which the plan's // digest does not describe: the row would prove the file "unmodified // since we wrote it" to the next `scanTree` and hide a real local edit. // Omitting just that row is not the alternative — by the rule above, a // plan missing an entry reconstructs a baseline missing a file, and // that baseline is what `stagePlan` writes as the rollback image. So a // carried plan abandons the sidecar wholesale, exactly as an // unstat-able path does. The cost is one flatten on the next update. for (plan.entries.items) |entry| { if (entry.carry != null) return; } for (plan.entries.items) |entry| { if (entry.path.len > std.math.maxInt(u16)) return; const existing = statExistingCached(&parents, io, worktree, entry.path) catch return; const stat = switch (existing) { .missing => return, .stat => |value| value, }; appendLittle(gpa, &out, u16, @intCast(entry.path.len)) catch return; out.appendSlice(gpa, entry.path) catch return; appendLittle(gpa, &out, u64, @bitCast(stat.inode)) catch return; appendLittle(gpa, &out, u64, stat.size) catch return; appendLittle(gpa, &out, u96, @bitCast(stat.mtime.nanoseconds)) catch return; appendLittle(gpa, &out, u8, entry.digest_len) catch return; out.appendSlice(gpa, entry.digestSlice()) catch return; appendLittle(gpa, &out, u8, @intFromEnum(entry.mode)) catch return; } var crc = crc32.Crc32.init(); crc.update(out.items); appendLittle(gpa, &out, u32, crc.final()) catch return; if (!repocache.ensureCacheDir(io, worktree)) return; var af = worktree.createFileAtomic(io, filename, .{ .replace = true }) catch return; defer af.deinit(io); af.file.writePositionalAll(io, out.items, 0) catch return; af.file.sync(io) catch return; af.replace(io) catch return; } }; fn pathMatchesEntry( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, log: *logstore.LogStore, entry: *const PlanEntry, ) ApplyError!bool { const existing = try statExisting(io, worktree, entry.path); const stat = switch (existing) { .missing => return false, .stat => |value| value, }; switch (entry.mode) { .symlink => if (stat.kind != .sym_link) return false, .regular, .executable => if (stat.kind != .file or !modeMatches(stat, entry.mode)) return false, } const rid = log.lookup(entry.digestSlice()) orelse return error.CurrentStateInvalid; const expected = log.content(gpa, rid) catch |err| return mapContentError(err); if (expected == null) return error.CurrentStateInvalid; defer gpa.free(expected.?); if (!artifact.verify(entry.name, expected.?)) return error.CurrentStateInvalid; const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) { error.PathMissing => return false, else => return @errorCast(err), }; defer parent.close(io); switch (entry.mode) { .symlink => { const actual = gpa.alloc(u8, std.math.add(usize, expected.?.len, 1) catch return error.OutOfMemory) catch return error.OutOfMemory; defer gpa.free(actual); const n = parent.dir.readLink(io, parent.name, actual) catch return false; return n == expected.?.len and std.mem.eql(u8, actual[0..n], expected.?); }, .regular, .executable => { if (stat.size != expected.?.len) return false; var file = parent.dir.openFile(io, parent.name, .{ .allow_directory = false, .follow_symlinks = false, .resolve_beneath = true, }) catch return false; defer file.close(io); const actual = gpa.alloc(u8, expected.?.len) catch return error.OutOfMemory; defer gpa.free(actual); const n = file.readPositionalAll(io, actual, 0) catch return error.Storage; return n == actual.len and std.mem.eql(u8, actual, expected.?); }, } } /// What one materialization needs out of the derived repository state: the /// branch the record commits, and — only when the `t` selector asked for it — /// the check-in's symbolic tag names. Both come from ONE state derive; a second /// pass just for the tags would double the most expensive read on the path. const TargetFacts = struct { branch: []u8, /// One symbolic tag name per line, newline-terminated; empty when `t` was /// not requested (and when the check-in carries no symbolic tag). tags: []u8, fn deinit(self: *TargetFacts, gpa: std.mem.Allocator) void { gpa.free(self.branch); gpa.free(self.tags); self.* = undefined; } }; /// The cached-view fast path for the facts below: the same questions answered /// from the `.zit-cache`-backed `Repository` view instead of `assembleState`'s /// from-scratch sweep over every artifact in the store. On a warm cache this /// is milliseconds where the sweep was tens of seconds, and the answers are /// the same by the cache's own contract -- the view it serves is proven /// equivalent to a cold derive. Null on ANY shortfall (no cache, open failure, /// target not in the view), and the caller falls back to the sweep, which /// stays the authority. fn cachedTargetFacts( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, target_id: []const u8, want_tags: bool, shared: ?*repository.Repository, ) ?TargetFacts { var own: repository.Repository = undefined; var opened_here = false; const repo: *repository.Repository = shared orelse blk: { own.open(gpa, io, root_dir, .{}) catch return null; opened_here = true; break :blk &own; }; defer if (opened_here) own.deinit(gpa); const rid = repo.lookup(target_id) orelse return null; const owned_branch = gpa.dupe(u8, repo.branchOf(rid)) catch return null; errdefer gpa.free(owned_branch); const tags: []u8 = if (want_tags) tagsListing(gpa, repo.state(), rid, owned_branch) catch { gpa.free(owned_branch); return null; } else &.{}; return .{ .branch = owned_branch, .tags = tags }; } fn deriveTargetFacts( gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8, want_tags: bool, ) ApplyError!TargetFacts { var assembly = try assembleState(gpa, log, target_id); defer assembly.deinit(); var config = log.readConfig(gpa) catch |err| return mapContentError(err); defer if (config) |*owned| owned.deinit(gpa); // Storage deliberately leaves an absent config to its caller. The // repository-state convention is the same `trunk` fallback used by the // existing state tests; a present config always overrides it. // An empty stored value is treated as unset, matching `RepoView.loadInner`; // `branchOf` requires a non-empty name and an empty one would trip it. const main_branch = blk: { if (config) |owned| { if (owned.config.default_branch.len != 0) break :blk owned.config.default_branch; } break :blk "trunk"; }; // This assembly builds its own bare `RepoState`, so nothing has told it // §6.1's configured name -- `RepoView` is what does that on the ordinary // load path. Tell it here, at the point the fact becomes known, so the // state's own `branchValueOf` (the §7.2 leaf test) and `branchOf` below // resolve an untagged check-in to the SAME branch. Leaving them to // disagree is what made an imported repository's untagged check-ins // display as one branch and leaf-test as another. assembly.state.main_branch = main_branch; const branch = assembly.state.branchOf(assembly.target_rid, main_branch); const owned_branch = gpa.dupe(u8, branch) catch return error.OutOfMemory; errdefer gpa.free(owned_branch); const tags: []u8 = if (want_tags) try tagsListing(gpa, &assembly.state, assembly.target_rid, branch) else &.{}; return .{ .branch = owned_branch, .tags = tags }; } /// Fossil's `manifest.tags` layout: the branch line, then one `tag` line per /// SYMBOLIC tag active on this check-in. Reserved non-symbolic names /// ("branch", "closed", ...) are not names a user checks out and are excluded, /// as fossil excludes them. Fossil emits its tag lines in tag-creation order, /// which no other repository can reproduce; `tagsOf` returns the active rows /// name-ascending, so zit's file is the same lines in a deterministic order. fn tagsListing(gpa: std.mem.Allocator, state: *const repostate.RepoState, rid: repostate.Rid, branch: []const u8) ApplyError![]u8 { const sym_prefix = "sym-"; const entries = state.tagsOf(gpa, rid) catch return error.OutOfMemory; defer gpa.free(entries); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); out.print(gpa, "branch {s}\n", .{branch}) catch return error.OutOfMemory; for (entries) |e| { if (!std.mem.startsWith(u8, e.name, sym_prefix)) continue; out.print(gpa, "tag {s}\n", .{e.name[sym_prefix.len..]}) catch return error.OutOfMemory; } return out.toOwnedSlice(gpa) catch return error.OutOfMemory; } /// State-record classification is intentionally conservative: any successful /// classified manifest is crosslinked, any successful classified control /// artifact ingests only tag effects, and all other successful classifications /// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque: /// the log frame format has no structural-kind bit to distinguish a blob from /// malformed cards. Integrity failures remain fatal. /// /// A classification failure is NOT fatal, and is not evidence of damage. A /// conforming repository contains artifacts a conforming parser declines: the /// artifact grammar requires the U card on a WIKI artifact, fossil elides it /// when the submitter is "nobody", and fossil stores and syncs the result, so a /// clone of an intact fossil repository carries such artifacts. Repository /// state §4 separates the two conditions by the name: bytes that hash to the /// name they are stored under are stored correctly and merely unclassifiable, /// so they are opaque to state and skipped; bytes that do not are an integrity /// failure and stay fatal. Verification is what distinguishes them, so it runs /// before the skip rather than after — see the arm below. /// /// This is why assembly must not answer a decline with a corruption claim: the /// verified name has already ruled damage out. pub fn assembleState(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!StateAssembly { const mem = gpa.create(repostate.MemStore) catch return error.OutOfMemory; mem.* = repostate.MemStore.init(gpa); errdefer { mem.deinit(); gpa.destroy(mem); } var state = repostate.RepoState.init(gpa, mem.store()); errdefer state.deinit(); var target_rid: ?repostate.Rid = null; const persisted_rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory; defer gpa.free(persisted_rids); // Same memo, same reason as `RepoView.loadInner`: bases persist strictly // before their deltas, so persisted-order resolution keeps each base hot // and collapses delta-chain read-amplification to ~1x. This walk ran // WITHOUT one while duplicating loadInner's sweep, which made every // update and commit re-reconstruct each delta chain from its root. var plaintext = logstore.PlaintextCache.init(); defer plaintext.deinit(gpa); var raw_name: [64]u8 = undefined; for (persisted_rids) |rid| { const name = encodeName(log.hashOf(rid), &raw_name); const bytes = log.contentCached(gpa, rid, &plaintext) catch |err| return mapContentError(err); if (bytes == null) continue; defer gpa.free(bytes.?); // Read path: repostate recovery over persisted (fossil-origin) history // tolerates the legacy F-card trailing separator. var parsed = artifact.parseForImport(gpa, bytes.?) catch |err| switch (err) { error.SyntaxError => continue, error.OutOfMemory => return error.OutOfMemory, // Classification declined the artifact. Repository state §4 splits // this from damage by the name: bytes that hash to the name they // are stored under arrived and were stored correctly, so the only // event is that this reader's grammar does not admit them. Such an // artifact is opaque to state — it is skipped exactly as a // non-participating type is. Bytes that do NOT hash to their name // are the other condition, and stay fatal. // // A conforming repository reaches this: the artifact grammar makes // the U card required on a WIKI artifact, fossil elides it when the // submitter is "nobody", and fossil stores and syncs the result. else => { if (artifact.verify(name, bytes.?)) continue; return error.Corrupt; }, }; defer parsed.deinit(gpa); switch (parsed.manifest.kind) { .manifest => { if (!artifact.verify(name, bytes.?)) return error.Corrupt; const record = try manifestRecord(gpa, name, &parsed.manifest); defer { gpa.free(record.parents); gpa.free(record.tags); } const state_rid = state.crosslink(record) catch return error.OutOfMemory; if (std.mem.eql(u8, name, target_id)) target_rid = state_rid; }, .control => { if (!artifact.verify(name, bytes.?)) return error.Corrupt; const record = try controlRecord(gpa, &parsed.manifest); defer gpa.free(record.tags); _ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory; }, else => {}, } } return .{ .gpa = gpa, .mem = mem, .state = state, // Unreachable on the shipped path, and deliberately not given its own // variant for that reason. `applyCheckin` runs `buildPlan` on this same // `target_id` first, and `buildPlan` already proved the name valid, the // rid present, the content non-null, `artifact.verify` passing, // `parseForImport` succeeding and `kind == .manifest`. The loop above // re-parses those same bytes with the same two tests, and // `ridsInPersistedOrder` filters only phantoms — which a non-null // `content` already excluded — so the target is always crosslinked by // the time this runs. It stays a state-record failure rather than a // user error: were it ever to fire it would mean the assembler and the // log disagree, which is not something the caller named wrongly. .target_rid = target_rid orelse return error.StateRecordInvalid, }; } const StateRecordParts = struct { mtime: repostate.Mtime, parents: []const []const u8 = &.{}, tags: []const repostate.TagOp = &.{}, }; fn manifestRecord(gpa: std.mem.Allocator, name: []const u8, manifest: *const artifact.Manifest) ApplyError!repostate.Record { const parts = try stateRecordParts(gpa, manifest, true); return .{ .hash = name, .mtime = parts.mtime, .parents = parts.parents, .tags = parts.tags }; } fn controlRecord(gpa: std.mem.Allocator, manifest: *const artifact.Manifest) ApplyError!StateRecordParts { return stateRecordParts(gpa, manifest, false); } fn stateRecordParts(gpa: std.mem.Allocator, manifest: *const artifact.Manifest, include_parents: bool) ApplyError!StateRecordParts { const date = manifest.d orelse return error.StateRecordInvalid; const mtime = repostate.parseMtime(date.timestamp) orelse return error.StateRecordInvalid; var parents = std.ArrayList([]const u8).empty; errdefer parents.deinit(gpa); if (include_parents) { for (manifest.p) |card| { var it = card.hashes(); while (it.next()) |hash| { parents.append(gpa, hash) catch return error.OutOfMemory; } } } const tags = gpa.alloc(repostate.TagOp, manifest.t.len) catch return error.OutOfMemory; errdefer gpa.free(tags); for (manifest.t, tags) |card, *out| { out.* = .{ .type = switch (card.sign) { .add => .singleton, .cancel => .cancel, .propagate => .propagate, }, .name = card.name, .target = card.target, .value = card.value, }; } return .{ .mtime = mtime, .parents = parents.toOwnedSlice(gpa) catch return error.OutOfMemory, .tags = tags }; } /// Which file staging could not materialize. Plain data, no allocation: the /// path borrows the plan, which outlives the call, and the name is copied. /// /// This exists because staging walks every file in the check-in — thousands on /// a real repository, minutes of work — and a bare `Corrupt` at the end of that /// says only that something, somewhere, is wrong. The path and hash are what /// turn it into a question someone can answer: is that artifact in the source /// repository, and did it fail to arrive? pub const StageFault = struct { /// COPIED, not borrowed. `PlanEntry.path` is `gpa.dupe`d and freed by /// `Plan.deinit`, which `applyCheckin` runs from its own `defer` on the way /// out — so it is already gone by the time a caller renders this. An earlier /// version of this struct held the slice and documented it as borrowing a /// plan that "outlives the call"; it does not, and the diagnostic printed /// freed heap. A fault report that lies about the file is worse than the /// generic message it replaced. path_buf: [path_max]u8 = [_]u8{0} ** path_max, path_len: u16 = 0, /// Also copied, for the same reason: `PlanEntry.name` is owned by the plan. name: [64]u8 = [_]u8{0} ** 64, name_len: u8 = 0, reason: enum { not_in_repository, content_absent, content_mismatch }, /// Long enough for any path this repository can hold (`managed.max_path_len` /// is PATH_MAX), so a diagnostic never truncates a real filename. pub const path_max: usize = 4096; pub fn artifactName(self: *const StageFault) []const u8 { return self.name[0..self.name_len]; } pub fn filePath(self: *const StageFault) []const u8 { return self.path_buf[0..self.path_len]; } }; /// Human-readable cause for the last staging fault, or a neutral line if none /// was recorded. Shared so every verb that can hit this says the same thing /// about the same failure. pub fn stageFaultReason(buf: []u8) []const u8 { const f = last_stage_fault orelse return "a file's content is missing from this repository"; const why: []const u8 = switch (f.reason) { .not_in_repository => "is not in this repository", .content_absent => "has no stored content in this repository", .content_mismatch => "does not match its recorded name", }; return std.fmt.bufPrint(buf, "'{s}': content {s} {s}", .{ f.filePath(), f.artifactName(), why }) catch "a file's content is missing from this repository"; } /// Why a referenced file can be absent from a repository that verifies clean. /// /// Shared by every verb that can hit `FileContentMissing`, because the cause is /// the same in all of them and it is not guessable from the missing path. The /// format types artifacts by STRUCTURE (artifact-format §5.1) and repo-state /// §4.4 exempts only what a parser declines to classify — so a stored blob whose /// bytes are a valid check-in manifest IS a check-in, joins the DAG, and can /// carry its own branch tags. A repository that tracks such a file therefore /// holds check-ins of ANOTHER repository, whose file content it never had. /// /// That is conformant derivation, not damage, which is why the refusal explains /// rather than alleging corruption. pub const foreign_checkin_note: []const u8 = "this happens when a repository stores artifacts that parse as check-ins of\n" ++ "ANOTHER repository -- imported mirror metadata is the common source -- so they\n" ++ "join the branch as check-ins whose blobs were never here. Nothing was modified.\n"; /// Set by `stagePlan` when it refuses a file; read only after `applyCheckin` /// returns `error.FileContentMissing`. pub var last_stage_fault: ?StageFault = null; fn stageFaulted(entry_path: []const u8, name: []const u8, reason: anytype) error{FileContentMissing} { var f: StageFault = .{ .reason = reason }; const n = @min(name.len, f.name.len); @memcpy(f.name[0..n], name[0..n]); f.name_len = @intCast(n); const pn = @min(entry_path.len, StageFault.path_max); @memcpy(f.path_buf[0..pn], entry_path[0..pn]); f.path_len = @intCast(pn); last_stage_fault = f; return error.FileContentMissing; } /// Mark, in BOTH plans, every path whose (digest, mode) pair is identical on /// the two sides. Everything else -- content moved, mode flipped, path only on /// one side -- stays unmarked and takes the full mutation path. /// /// This is what makes `update`'s cost track the DIFF rather than the width: /// before it, every update staged, journaled, deleted and rewrote the entire /// tree, and the measured cost was ~52ms per tracked file regardless of how /// little changed. It is sound only downstream of `requireClean`, which has /// already proven every baseline file byte-correct on disk -- an unchanged /// entry is not "skipped work", it is work whose result is already present. fn markUnchanged(gpa: std.mem.Allocator, target: *Plan, old: *Plan) error{OutOfMemory}!void { var by_path: std.StringHashMapUnmanaged(*PlanEntry) = .empty; defer by_path.deinit(gpa); try by_path.ensureTotalCapacity(gpa, @intCast(old.entries.items.len)); for (old.entries.items) |*entry| by_path.putAssumeCapacity(entry.path, entry); for (target.entries.items) |*entry| { const prior = by_path.get(entry.path) orelse continue; if (prior.mode != entry.mode) continue; if (!std.mem.eql(u8, prior.digestSlice(), entry.digestSlice())) continue; entry.unchanged = true; prior.unchanged = true; } } fn stagePlan(gpa: std.mem.Allocator, io: std.Io, log: *logstore.LogStore, stage: std.Io.Dir, plan: *const Plan) ApplyError!void { var parents: ParentCache = .{}; defer parents.deinit(io); for (plan.entries.items) |entry| { if (entry.unchanged) continue; // A carried path's bytes are the working tree's, not the store's: // there is no artifact to look up and nothing to verify them against. // They are staged like any other payload so the commit/rollback rename // machinery below treats carried and check-in content identically. if (entry.carry) |bytes| { try writePayload(io, stage, entry.path, bytes, entry.mode, true, null, false, &parents); continue; } const rid = log.lookup(entry.digestSlice()) orelse return stageFaulted(entry.path, entry.name, .not_in_repository); const bytes = log.content(gpa, rid) catch |err| return mapContentError(err); if (bytes == null) return stageFaulted(entry.path, entry.name, .content_absent); defer gpa.free(bytes.?); if (!artifact.verify(entry.name, bytes.?)) return stageFaulted(entry.path, entry.name, .content_mismatch); try writePayload(io, stage, entry.path, bytes.?, entry.mode, true, null, false, &parents); } } fn preflight( gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, target: *const Plan, current_managed: *const managed.ManagedSet, ) ApplyError!void { // Ancestors already proven to be directories (or absent). A path-sorted // plan names the same few ancestors thousands of times; without this each // entry re-walked and re-stat'd every level above it from the root. var known_dirs: std.StringHashMapUnmanaged(void) = .empty; defer known_dirs.deinit(gpa); var parents: ParentCache = .{}; defer parents.deinit(io); for (target.entries.items) |entry| { if (entry.unchanged) continue; var replaces_managed_ancestor = false; var component_end: usize = 0; while (std.mem.indexOfScalarPos(u8, entry.path, component_end, '/')) |slash| { const parent = entry.path[0..slash]; if (known_dirs.contains(parent)) { component_end = slash + 1; continue; } const existing = try statExistingCached(&parents, io, worktree, parent); switch (existing) { .missing => known_dirs.put(gpa, parent, {}) catch return error.OutOfMemory, .stat => |stat| switch (stat.kind) { .directory => known_dirs.put(gpa, parent, {}) catch return error.OutOfMemory, .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 statExistingCached(&parents, 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, cone_update: ?checkout.SectionUpdate, ) 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, .move); 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); // The membership and the record commit in ONE container write: there is no // window in which a crash leaves a new managed set beside an old checkout. const mbuf = gpa.alloc(u8, desired_managed.bytes) catch return error.OutOfMemory; defer gpa.free(mbuf); var ck_buf: [checkout.max_ck_payload]u8 = undefined; if (cone_update) |u| { std.debug.assert(u.tag == .cone); // the seam widens for exactly one tag checkout.updateSections(io, worktree, &.{ .{ .tag = .managed, .payload = desired_managed.encode(mbuf) }, .{ .tag = .checkout, .payload = checkout.encodeCk(&ck_buf, next_checkout) }, u, }) catch |err| return mapCheckoutError(err); } else { checkout.updateSections(io, worktree, &.{ .{ .tag = .managed, .payload = desired_managed.encode(mbuf) }, .{ .tag = .checkout, .payload = checkout.encodeCk(&ck_buf, 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, true, 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, true, null); }, 't' => try writePayload(io, worktree, path, src.tags, .regular, false, null, true, 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, .copy); old_managed.write(io, worktree) catch |err| return mapManagedError(err); } fn removePlanFiles(io: std.Io, worktree: std.Io.Dir, plan: *const Plan, prune_empty_ancestors: bool) ApplyError!void { for (plan.entries.items) |entry| { if (entry.unchanged) continue; const removed = blk: { const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) { error.PathMissing => break :blk false, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteFile(io, parent.name) catch |err| switch (err) { error.FileNotFound => break :blk false, else => return error.Storage, }; break :blk true; }; if (prune_empty_ancestors and removed) try pruneEmptyPlanAncestors(io, worktree, entry.path); } } /// Deletes only empty ancestors of a removed managed file. A non-empty /// directory is never recursed into, so unmanaged entries remain untouched. fn pruneEmptyPlanAncestors(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void { var index = path.len; while (index > 0) { index -= 1; if (path[index] != '/') continue; const ancestor = path[0..index]; const parent = openParentNoFollow(io, worktree, ancestor, false, null) catch |err| switch (err) { error.PathMissing => continue, else => return @errorCast(err), }; defer parent.close(io); parent.dir.deleteDir(io, parent.name) catch |err| switch (err) { error.FileNotFound, error.DirNotEmpty => {}, else => return error.Storage, }; } } /// How a staged payload reaches the working tree. `.move` renames it into /// place -- the stage lives inside the worktree, so it is one same-filesystem /// rename per entry, and the bytes are neither read back nor written twice. /// `.copy` reads and rewrites, leaving the staged tree intact: the rollback /// path uses it so a failed inverse still has `previous/` as its complete /// recovery copy (see the retain branch in `applyCheckin`). const Transfer = enum { move, copy }; fn materializePlan( gpa: std.mem.Allocator, io: std.Io, stage: std.Io.Dir, worktree: std.Io.Dir, plan: *const Plan, created_directories: ?*CreatedDirectories, transfer: Transfer, ) ApplyError!void { var sources: ParentCache = .{}; defer sources.deinit(io); var destinations: ParentCache = .{}; defer destinations.deinit(io); for (plan.entries.items) |entry| { if (entry.unchanged) continue; try prepareDestination(io, worktree, entry.path, &destinations); const source_parent = openParentCached(&sources, io, stage, entry.path, false, null) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; switch (transfer) { .move => { const dest = openParentCached(&destinations, io, worktree, entry.path, true, created_directories) catch |err| switch (err) { error.PathMissing => return error.Storage, else => return @errorCast(err), }; // A plain rename, not `renamePreserve`: on Darwin the latter // is link+unlink, and that linkat was half the verb's wall // time. `preflight` already proved the slot is free or // managed-and-removed, so there is nothing to preserve. source_parent.dir.rename(source_parent.name, dest.dir, dest.name, io) catch return error.Storage; }, .copy => 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, false, &destinations); }, .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, false, &destinations); }, }, } } } fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8, cache: ?*ParentCache) ApplyError!void { const existing = try statExistingCached(cache, 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, }, } } /// `sync` fsyncs a regular file before returning. The bulk plan walks pass /// false: fossil fsyncs nothing on checkout, and an fsync per staged file was /// ~8% of a tree materialization (#114). The single-file writers (`revert`, /// the manifest stamps) keep it. `parents`, when given, amortizes the /// directory walk across a path-sorted plan. fn writePayload( io: std.Io, root: std.Io.Dir, path: []const u8, bytes: []const u8, mode: artifact.FileMode, exclusive: bool, created_directories: ?*CreatedDirectories, sync: bool, parents: ?*ParentCache, ) ApplyError!void { const parent = (if (parents) |cache| openParentCached(cache, io, root, path, true, created_directories) else 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; if (sync) 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, true, 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. /// Sparse-cone RFC section 4.4 -- one resolved out-of-cone outcome of a /// cross-cone merge, held in the checkout container's merge_overlay section /// until the next commit composes it. `hash == null` is theirs' deletion. pub const OverlayEntry = struct { path: []const u8, hash: ?[]const u8, perm: ?[]const u8, }; /// merge_overlay wire form: magic "ZITOVLY1" | count u16 LE | per entry: /// flag u8 (1 = write, 0 = delete) | path u16 LE + bytes | hash u8 + bytes /// (write only) | perm u8 + bytes (write only, 0 = none). Fail-closed parse: /// authoritative state, never best-effort. const overlay_magic = "ZITOVLY1"; pub fn encodeOverlay(gpa: std.mem.Allocator, entries: []const OverlayEntry) error{ OutOfMemory, Overflow }![]u8 { // Explicit rejections first (release_assert rule 5): the asserts below // restate already-established facts, never guard in release builds. if (entries.len == 0) return error.Overflow; // an empty overlay is spelled "no section" if (entries.len > std.math.maxInt(u16)) return error.Overflow; std.debug.assert(entries.len >= 1); std.debug.assert(entries.len <= std.math.maxInt(u16)); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); try out.appendSlice(gpa, overlay_magic); var n16: [2]u8 = undefined; std.mem.writeInt(u16, &n16, @intCast(entries.len), .little); try out.appendSlice(gpa, &n16); for (entries) |entry| { if (entry.path.len == 0 or entry.path.len > std.math.maxInt(u16)) return error.Overflow; if (entry.hash) |h| { if (h.len == 0 or h.len > std.math.maxInt(u8)) return error.Overflow; if (entry.perm) |perm_check| { if (perm_check.len > std.math.maxInt(u8)) return error.Overflow; } } try out.append(gpa, if (entry.hash != null) 1 else 0); std.mem.writeInt(u16, &n16, @intCast(entry.path.len), .little); try out.appendSlice(gpa, &n16); try out.appendSlice(gpa, entry.path); if (entry.hash) |h| { try out.append(gpa, @intCast(h.len)); try out.appendSlice(gpa, h); const perm = entry.perm orelse ""; try out.append(gpa, @intCast(perm.len)); try out.appendSlice(gpa, perm); } } return out.toOwnedSlice(gpa); } /// Entries borrow `bytes`; the arena (or caller) owns both lifetimes. pub fn decodeOverlay(gpa: std.mem.Allocator, bytes: []const u8) error{ OutOfMemory, Corrupt }![]OverlayEntry { if (bytes.len < overlay_magic.len + 2) return error.Corrupt; if (!std.mem.eql(u8, bytes[0..overlay_magic.len], overlay_magic)) return error.Corrupt; const count = std.mem.readInt(u16, bytes[overlay_magic.len..][0..2], .little); if (count == 0) return error.Corrupt; const entries = try gpa.alloc(OverlayEntry, count); errdefer gpa.free(entries); var off: usize = overlay_magic.len + 2; for (entries) |*entry| { if (bytes.len - off < 3) return error.Corrupt; const flag = bytes[off]; if (flag > 1) return error.Corrupt; const path_len = std.mem.readInt(u16, bytes[off + 1 ..][0..2], .little); off += 3; if (path_len == 0 or bytes.len - off < path_len) return error.Corrupt; entry.path = bytes[off..][0..path_len]; off += path_len; if (flag == 1) { if (bytes.len - off < 1) return error.Corrupt; const hash_len = bytes[off]; off += 1; if (hash_len == 0 or bytes.len - off < hash_len) return error.Corrupt; entry.hash = bytes[off..][0..hash_len]; off += hash_len; if (bytes.len - off < 1) return error.Corrupt; const perm_len = bytes[off]; off += 1; if (bytes.len - off < perm_len) return error.Corrupt; entry.perm = if (perm_len == 0) null else bytes[off..][0..perm_len]; off += perm_len; } else { entry.hash = null; entry.perm = null; } } if (off != bytes.len) return error.Corrupt; // no residue return entries; } 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, overlay_bytes: ?[]const u8, new_checkout: checkout.CheckoutState, ) ApplyError!void { for (entries) |entry| try validateWorktreePath(entry.path); // Sparse-cone RFC section 4.4: the caller resolves out-of-cone changes // at record level (the overlay) and hands this layer IN-CONE entries // only. The error remains for a caller that skips that split -- crossing // here would silently lose the change at the next commit. { var stored_cone = try readStoredCone(gpa, io, worktree); defer if (stored_cone) |*c| c.deinit(gpa); if (stored_cone) |*c| { for (entries) |entry| { if (!c.covers(entry.path)) return error.SparseMergeUnsupported; } } } // (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 (checkout carries merge_parent = other) and // the membership it implies in ONE container write — a failure here has // moved no working byte and left no half-recorded merge, but the // journal is already armed, so roll it back. { const mbuf = gpa.alloc(u8, new_managed.bytes) catch return error.OutOfMemory; defer gpa.free(mbuf); var merge_ck_buf: [checkout.max_ck_payload]u8 = undefined; checkout.updateSections(io, worktree, &.{ .{ .tag = .managed, .payload = new_managed.encode(mbuf) }, .{ .tag = .checkout, .payload = checkout.encodeCk(&merge_ck_buf, new_checkout) }, // Sparse-cone RFC section 4.4: the resolved out-of-cone outcomes // ride the SAME splice as the pending-merge record -- the pair is // never observable half-updated, and a merge with no overlay // clears any stale one an undone earlier merge left behind. .{ .tag = .merge_overlay, .payload = overlay_bytes }, }) catch |err| { journal.clear(io, worktree); return mapCheckoutError(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, true, 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. One container // write restores both records (an absent pre-merge checkout removes the // section). const mbuf = old_managed.gpa.alloc(u8, old_managed.bytes) catch return error.OutOfMemory; defer old_managed.gpa.free(mbuf); var ck_buf: [checkout.max_ck_payload]u8 = undefined; const ck_payload: ?[]const u8 = if (old_checkout) |ck| checkout.encodeCk(&ck_buf, ck) else null; checkout.updateSections(io, worktree, &.{ .{ .tag = .managed, .payload = old_managed.encode(mbuf) }, .{ .tag = .checkout, .payload = ck_payload }, }) catch |err| return mapCheckoutError(err); } /// 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 { return statExistingCached(null, io, root, path); } /// `statExisting` through an optional `ParentCache` -- the plan walks pass one /// so a path-sorted plan stats its entries from one open parent handle. fn statExistingCached(cache: ?*ParentCache, io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath { const parent = (if (cache) |c| openParentCached(c, io, root, path, false, null) else 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); // Recomputed for the prefix, not guessed: 13-byte prefix + the 24-hex // suffix of a 12-byte nonce. var name: [".zit-tmp-txn-".len + 24]u8 = undefined; const prefix = ".zit-tmp-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] == '/'; } /// The largest working file a carrying update will merge. A path over the cap /// is `.unsupported`, not silently truncated — the same posture as every other /// content read that has a ceiling. pub const max_carry_bytes: usize = 1 << 30; /// What a working path holds, for the purpose of carrying it across an update. pub const WorktreeContent = union(enum) { /// Nothing is at this path. absent, /// A regular (or executable) file, read whole. content: Carry.Content, /// Something a three-way merge has no bytes for: a symlink, a directory, a /// device node, or a file past `max_carry_bytes`. The caller names the path /// and refuses rather than guessing which side should win a type change. unsupported, }; /// Read `path` from the working tree as merge input. The single place that /// decides a worktree file's `FileMode`, so it cannot drift from /// `modeMatches`, which decides whether that same mode is clean. pub fn readWorktreeContent( arena: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, path: []const u8, ) ApplyError!WorktreeContent { const st = worktree.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound, error.NotDir => return .absent, else => return error.Storage, }; if (st.kind != .file) return .unsupported; if (st.size > max_carry_bytes) return .unsupported; const executable = if (comptime std.Io.File.Permissions.has_executable_bit) st.permissions.toMode() & 0o111 != 0 else false; const bytes = worktree.readFileAlloc(io, path, arena, .limited(max_carry_bytes)) catch |err| switch (err) { error.FileNotFound, error.NotDir => return .absent, error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }; return .{ .content = .{ .bytes = bytes, .mode = if (executable) .executable else .regular, } }; } 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, 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, 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, .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, .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, .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, 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, .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, .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 = rfc_vectors.submanifest_a2_1_leaf; const sm_a2_2_leaf = rfc_vectors.submanifest_a2_2_leaf; const sm_a3_manifest = rfc_vectors.submanifest_a3_tree_manifest; /// §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, null); 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, null)); } 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-CaChE/repo", .reserved = true }, .{ .path = ".zit-tmp-txn-user/file", .reserved = true }, .{ .path = ".ZiT-Tmp-anything", .reserved = true }, .{ .path = ".zit-checkout-user/file", .reserved = false }, .{ .path = "sub/.zit/object", .reserved = false }, // The deleted legacy tier: ordinary admissible names now. .{ .path = ".zIT-Managed/entries", .reserved = false }, .{ .path = ".ZiT-VStat/data", .reserved = false }, .{ .path = ".zit-import-map.jsonl/sidecar", .reserved = false }, .{ .path = "ReFs/heads/main", .reserved = false }, .{ .path = ".zit-update-txn-user/file", .reserved = false }, .{ .path = ".zit-update-txn", .reserved = false }, .{ .path = ".zit-update-txns/user", .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-tmp-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 §6.3: a lying R card refuses materialization; the truthful one applies (Fossil's default repo-cksum posture)" { const t = std.testing; const test_storage = @import("storage.zig"); const io = t.io; const blob = "content the R card attests\n"; var blob_id_buf: [64]u8 = undefined; // One fixture builder, two R spellings: the aggregate over the real // content, and a hex-flipped lie of it. var truth_hex: [32]u8 = undefined; { var agg: artifact.ChecksumAggregator = .{}; agg.update("a.txt", blob); _ = agg.final(&truth_hex); } var lie_hex: [32]u8 = truth_hex; lie_hex[0] = if (lie_hex[0] == '0') '1' else '0'; for ([2]struct { r: []const u8, ok: bool }{ .{ .r = &lie_hex, .ok = false }, .{ .r = &truth_hex, .ok = true }, }) |case| { var tmp = t.tmpDir(.{}); defer tmp.cleanup(); 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 blob_id = try putVectorArtifact(&log, blob, &blob_id_buf); const body = try std.fmt.allocPrint( t.allocator, "C r\nD 2024-01-01T00:00:00\nF a.txt {s}\nR {s}\nU u\n", .{ blob_id, case.r }, ); defer t.allocator.free(body); const sealed = try completeTestArtifact(t.allocator, body); defer t.allocator.free(sealed); var id_buf: [64]u8 = undefined; const id = try putVectorArtifact(&log, sealed, &id_buf); if (case.ok) { try applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit); try expectFileState(t.allocator, io, tmp.dir, "a.txt", blob, .regular); } else { try t.expectError( error.AggregateChecksumMismatch, applyCheckin(t.allocator, io, tmp.dir, &log, id, .{ .set = "" }, .inherit), ); // Fail-closed means fail-early: nothing was materialized. try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "a.txt", .{})); } } } 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 = "" }, .inherit); 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, true, null); try writePayload(io, worktree, "remove-me", "gone\n", .regular, true, null, 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, null, .{ .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). const journal_bytes = (try checkout.readSectionAlloc(t.allocator, io, tmp.dir, .undo)).?; t.allocator.free(journal_bytes); } 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, null, .{ .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, null, .{ .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. const undo_section = try checkout.readSectionAlloc(t.allocator, io, tmp.dir, .undo); try t.expectEqual(@as(?[]u8, null), undo_section); } // 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, null); 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 = "" }, .inherit), ); 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); } /// A `CarryResolver` for the in-module tests: answers with a fixed carry set. const FixedCarryResolver = struct { answer: []const Carry, fn resolver(self: *FixedCarryResolver) CarryResolver { return .{ .ctx = self, .resolve = resolve }; } fn resolve( ctx: *anyopaque, divergences: []const Divergence, ) error{ OutOfMemory, Refused }![]const Carry { _ = divergences; const self: *FixedCarryResolver = @ptrCast(@alignCast(ctx)); return self.answer; } }; test "a carried update rolls back to the WORKING bytes, not the check-in's (zit#135)" { // The invariant that makes `Carry` name two endpoints instead of one. // // A carrying update writes merged bytes over a file the operator has // uncommitted edits in. If it fails partway, the staged `previous/` tree is // what gets restored — and if that tree held the CHECK-IN's content (which // is what the baseline plan describes) the rollback would quietly replace // uncommitted work with committed bytes. A failed update destroying the // edits it was asked to preserve is the same class of loss zit#135 exists // to prevent, and it would leave no trace: the update reports an error, the // tree looks plausible, and the work is gone. // // So `overlayCarry` puts `before` into the baseline plan as well, and this // is the test that the two endpoints are actually wired to their two // destinations rather than both to the same one. 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 v1_buf: [64]u8 = undefined; const v1 = try putTestPayload(&log, "committed base", &v1_buf); var v2_buf: [64]u8 = undefined; const v2 = try putTestPayload(&log, "committed target", &v2_buf); const base_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF moving {s}\n", .{v1}); defer t.allocator.free(base_body); var base_buf: [64]u8 = undefined; const base = try putCompleteTestArtifact(t.allocator, &log, base_body, &base_buf); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF moving {s}\n", .{v2}); defer t.allocator.free(target_body); var target_buf: [64]u8 = undefined; const target = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_buf); try applyCheckin(t.allocator, io, tmp.dir, &log, base, .inherit, .inherit); // The operator's uncommitted work. Deliberately different from BOTH // check-ins, so a rollback that restored either one would be caught. const working_bytes = "uncommitted local work"; try tmp.dir.deleteFile(io, "moving"); { var f = try tmp.dir.createFile(io, "moving", .{}); defer f.close(io); try f.writePositionalAll(io, working_bytes, 0); } const carry = [_]Carry{.{ .path = "moving", .after = .{ .bytes = "merged bytes that must not survive" }, .before = .{ .bytes = working_bytes }, }}; var fixed = FixedCarryResolver{ .answer = &carry }; // Fail AFTER the merged bytes have been materialized — the only window in // which the rollback has real work to undo. test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckinWith( t.allocator, io, tmp.dir, &log, target, .inherit, .inherit, null, fixed.resolver(), )); // The operator's bytes are back. NOT "committed base" (what the baseline // plan describes and what a one-endpoint carry would have restored), and // NOT the merged bytes the failed update wrote. try expectFileState(t.allocator, io, tmp.dir, "moving", working_bytes, .regular); // And the checkout did not move, so the failed update is invisible except // for its error. var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(base, state.version); 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("moving")); } test "a carried update rolls a locally-added file back to existing, not to absent (zit#135)" { // The membership half of the same invariant. A path the checkout manages // but neither check-in has (`zit add`, not yet committed) is carried into // the TARGET plan so the move does not delete it — which also makes it a // path the target plan creates. A rollback that reasoned only from the // target would therefore delete it as "a file this update created", // destroying an add the operator made before the update ran. 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 v1_buf: [64]u8 = undefined; const v1 = try putTestPayload(&log, "committed base", &v1_buf); var v2_buf: [64]u8 = undefined; const v2 = try putTestPayload(&log, "committed target", &v2_buf); const base_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF moving {s}\n", .{v1}); defer t.allocator.free(base_body); var base_buf: [64]u8 = undefined; const base = try putCompleteTestArtifact(t.allocator, &log, base_body, &base_buf); const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF moving {s}\n", .{v2}); defer t.allocator.free(target_body); var target_buf: [64]u8 = undefined; const target = try putCompleteTestArtifact(t.allocator, &log, target_body, &target_buf); try applyCheckin(t.allocator, io, tmp.dir, &log, base, .inherit, .inherit); const added_bytes = "a file the operator added but has not committed"; { var f = try tmp.dir.createFile(io, "added", .{}); defer f.close(io); try f.writePositionalAll(io, added_bytes, 0); } { var set = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer set.deinit(); _ = try set.insert("added"); try set.write(io, tmp.dir); } // Neither side of the merge has an opinion about `added`, so it is carried // unchanged: same bytes at both endpoints. const carry = [_]Carry{.{ .path = "added", .after = .{ .bytes = added_bytes }, .before = .{ .bytes = added_bytes }, }}; var fixed = FixedCarryResolver{ .answer = &carry }; test_fault = .after_materialize; defer test_fault = null; try t.expectError(error.Storage, applyCheckinWith( t.allocator, io, tmp.dir, &log, target, .inherit, .inherit, null, fixed.resolver(), )); // Still there, with its bytes, and still managed. try expectFileState(t.allocator, io, tmp.dir, "added", added_bytes, .regular); var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir); defer current_managed.deinit(); try t.expect(current_managed.contains("added")); try t.expect(current_managed.contains("moving")); var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined; const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?; try t.expectEqualStrings(base, state.version); }