Artifact 354e561b4e838453f90b6bec3f858614723e25b81b2a27b1bb0d1c00716dfe62:
- File
src/undo.zig
— part of check-in
[2a522aab40]
at
2026-08-29 07:35:42
on branch trunk
— merge: --integrate, --baseline, -n, and a bare merge onto the one other leaf (zit#164)
The checkout record carries the integrated leaf's hash as an optional sixth field; commit closes it with the same control artifact branch close mints. (user: dmestas, size: 46252) [annotate] [blame] [check-ins using]
//! Single-level undo/redo journal for the working-file mutators (`zit update`, //! `zit revert`). Contract: contracts/undo.md (B-undo-1..21). No RFC touches the //! working tree — this is a checkout-layer safety net and a settled DX decision, //! so the behavior contract, not an RFC, is the oracle. //! //! The on-disk journal — the undo section of the `.zit-checkout` container //! (legacy `.zit-undo` sibling on a pre-container checkout) — holds at most //! ONE undo image and at most ONE redo image (fossil-parity single level, NOT //! a stack). An *image* is the pre-mutation bytes+mode of every path a //! sheltered verb will write or delete, plus the pre-mutation checkout and //! managed records — enough to make the restored tree indistinguishable from //! before the mutation (B-undo-1). Callers never see the wire format or the //! slot bookkeeping: a mutator calls `record` once before it moves its first //! byte (B-undo-3/14); `undo`/`redo` swap the two slots and materialize. //! //! This module owns NO second file-writing path (B-undo-12): working files //! restore through `update.restoreFile`/`update.removeFile`, and every //! control record lands through `checkout.updateSections` — the restore of //! checkout + managed is a single container write, so the old window between //! two separate control-file writes no longer exists. //! //! Effects flow only through an injected `std.Io` + `std.Io.Dir` (design-seams //! rule — no ambient filesystem access); tests bind `std.testing.io` + a tmpDir. const std = @import("std"); const artifact = @import("artifact.zig"); const checkout = @import("checkout.zig"); const managed = @import("managed.zig"); const update = @import("update.zig"); const crc32 = @import("crc32.zig"); /// An image never enumerates more paths than a full managed set (B-undo bound). pub const max_files: usize = managed.max_entries; /// Per-file worktree-content cap carried in an image. This MUST be the same /// ceiling commit and diff apply to a tracked file: undo captures the pre-image /// of files those layers accepted, so a smaller bound here cannot protect /// anything — it can only refuse to journal a file that is already tracked. /// /// It previously read `managed.max_file_bytes`, the managed set's serialized /// PATH-TABLE bound (64 MiB), on the reasoning that the aggregate doubled as a /// single-file ceiling. The two measure different things, and the gap was live: /// `captureFile`'s `readFileAlloc` refused any tracked file over 64 MiB as /// `error.Storage`, `record` propagated it, and `update.zig` correctly aborted /// the whole operation rather than mutate the tree without a recoverable /// pre-image. A perfectly ordinary large tracked file broke checkout, update /// and merge outright. pub const max_blob: usize = managed.max_tracked_file_bytes; const magic = "ZITUNDO\x00"; // 8 bytes // Bumped to 2 when the checkout slot gained a fourth `special_op` string (the // pending cherry-pick/backout op), and to 3 when it gained a fifth // `manifest_flags` string (the working-tree manifest opt-in): an older journal // lacks the field, so the reader rejects it as Corrupt rather than misparsing // the following bytes. Undo journals are transient working-tree state, so an // unreadable stale journal is harmless. const fmt_version: u8 = 4; // 4: a sixth `integrate` string (zit#164) /// Closed error set at the module boundary. `Empty` is the one exit-1 signal /// for a missing required slot (B-undo-9/10 make emptiness an operation /// failure, not empty-output success); `Corrupt` is a damaged journal caught at /// parse before any write (B-undo-11); `Storage` is any device fault. pub const JournalError = error{ Storage, Corrupt, OutOfMemory, Empty }; // --------------------------------------------------------------------------- // In-memory image (arena-backed for the lifetime of one call). // --------------------------------------------------------------------------- const FileEntry = struct { /// Repo-relative worktree path (arena-owned). path: []const u8, /// Whether the path held a regular file at snapshot time. Absent = the /// mutation created it (undo deletes it), so absence is an image entry, not /// an error (errors defined out of existence). present: bool, mode: artifact.FileMode, /// The file's bytes when `present`; empty otherwise (arena-owned). blob: []const u8, }; const Image = struct { /// The `.zit-checkout` record at snapshot time, or null when absent (a fresh /// repo's first checkout). Strings arena-owned. checkout: ?checkout.CheckoutState, /// The raw `.zit-managed` bytes at snapshot time, or null when absent. /// Whole-file so restoration is byte-exact regardless of any pending /// rename record the structured set would drop. Arena-owned. managed: ?[]const u8, files: []const FileEntry, }; const Slots = struct { undo: ?Image, redo: ?Image, }; // --------------------------------------------------------------------------- // Public API. // --------------------------------------------------------------------------- /// Record the undo image for a mutation about to touch `paths`, replacing any /// existing journal (which clears the stale redo slot — B-undo-7). Called ONCE /// per sheltered verb, BEFORE the first working-file byte moves: the write is /// fsync'd before returning, so a crash after this leaves a tree `zit undo` can /// recover and a crash before it leaves the untouched pre-mutation tree /// (B-undo-3, the B-commit-15 discipline on the checkout layer). `paths` should /// be deduplicated by the caller; duplicates are harmless (idempotent restore). pub fn record( gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, paths: []const []const u8, ) JournalError!void { std.debug.assert(paths.len <= max_files); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); const image = try captureImage(a, io, root, paths); // Undo slot holds the pre-image; redo slot is cleared (a new mutation always // invalidates redo). try writeJournal(gpa, io, root, .{ .undo = image, .redo = null }); } /// undo: materialize the undo image onto the tree, moving the pre-undo /// (post-mutation) state into the redo slot as it goes, and consume the undo /// slot (B-undo-4). `Empty` when no undo image is present (B-undo-6/9). The /// whole journal is parsed and validated BEFORE the first working file is /// touched, so a corrupt journal aborts with the tree byte-identical /// (B-undo-11). pub fn undo(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!void { return swap(gpa, io, root, .undo_dir); } /// redo: the exact inverse of `undo` — materialize the redo image, repopulate /// the undo slot, consume the redo slot (B-undo-5). `Empty` when no redo image /// is available (B-undo-10). pub fn redo(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!void { return swap(gpa, io, root, .redo_dir); } /// Drop the journal after a sheltered mutation cleanly rolls back, so a failed /// update leaves no spurious undo level (keeps B-undo-9 honest). Best-effort: /// an absent journal is success, and a splice fault is swallowed because the /// mutation it guarded did not happen. pub fn clear(io: std.Io, root: std.Io.Dir) void { checkout.updateSections(io, root, &.{.{ .tag = .undo, .payload = null }}) catch {}; } pub const Direction = enum { undo_dir, redo_dir }; /// What a swap in `dir` would touch (zit#164 `undo -n`): the file paths in /// the slot and whether the checkout record is part of the image. Allocated /// from `a`, which the caller owns (an arena). pub const Peek = struct { paths: []const []const u8, restores_checkout: bool }; pub fn peek(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir, dir: Direction) JournalError!Peek { const raw = (try readJournalFile(a, io, root)) orelse return error.Empty; const slots = try parseJournal(a, raw); const source = (if (dir == .undo_dir) slots.undo else slots.redo) orelse return error.Empty; return .{ .paths = try imagePaths(a, source), .restores_checkout = source.checkout != null }; } /// The shared swap engine for `undo`/`redo`. `dir` selects which slot restores /// onto the tree and which receives the captured current state. Ordering /// (B-undo-4, durability): parse+validate the whole journal, capture the /// current state of the affected paths into the opposite slot IN MEMORY, write /// and fsync the swapped journal (the opposite image is durable before the /// post-mutation bytes are overwritten), then materialize the restore. fn swap(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, dir: Direction) JournalError!void { const raw = (try readJournalFile(gpa, io, root)) orelse return error.Empty; defer gpa.free(raw); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); const slots = try parseJournal(a, raw); // Corrupt caught here, before any write const source = (if (dir == .undo_dir) slots.undo else slots.redo) orelse return error.Empty; // Capture the current (post-mutation) state of exactly the paths the source // image restores, so the discarded state round-trips. const path_list = try imagePaths(a, source); const captured = try captureImage(a, io, root, path_list); // A file↔directory transition includes both an ancestor path and a // descendant path. Prepare the restore order before consuming the source // slot: absent descendants must be deleted before a present ancestor can // replace their now-empty directory. var ordered_source = source; const ordered_files = try a.dupe(FileEntry, source.files); std.mem.sort(FileEntry, ordered_files, {}, restoreOrderLess); ordered_source.files = ordered_files; const next: Slots = if (dir == .undo_dir) .{ .undo = null, .redo = captured } else .{ .undo = captured, .redo = null }; try writeJournal(gpa, io, root, next); try materialize(io, root, ordered_source); } // --------------------------------------------------------------------------- // Snapshot (capture the current state of a path set + control records). // --------------------------------------------------------------------------- /// Snapshot the current bytes+mode of every path in `paths` plus the current /// `.zit-checkout` and `.zit-managed` records into an arena-backed image. A /// path that is absent (or not a regular file) is recorded `present = false`. fn captureImage( a: std.mem.Allocator, io: std.Io, root: std.Io.Dir, paths: []const []const u8, ) JournalError!Image { std.debug.assert(paths.len <= max_files); var files = try a.alloc(FileEntry, paths.len); for (paths, 0..) |path, i| { std.debug.assert(path.len <= managed.max_path_len); files[i] = try captureFile(a, io, root, path); } return .{ .checkout = try captureCheckout(a, io, root), .managed = try captureManaged(a, io, root), .files = files, }; } fn captureFile(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir, path: []const u8) JournalError!FileEntry { const st = root.statFile(io, path, .{ .follow_symlinks = false }) catch |e| switch (e) { error.FileNotFound, error.NotDir => return .{ .path = try a.dupe(u8, path), .present = false, .mode = .regular, .blob = "" }, else => return error.Storage, }; if (st.kind != .file) { // A directory/symlink at this path is not a regular-file pre-image; the // mutation replaces it, so undo treats it as absent (see the module note // on transitions). return .{ .path = try a.dupe(u8, path), .present = false, .mode = .regular, .blob = "" }; } const mode: artifact.FileMode = if (isExecutable(st)) .executable else .regular; const blob = root.readFileAlloc(io, path, a, .limited(max_blob)) catch |e| switch (e) { error.FileNotFound, error.NotDir => return .{ .path = try a.dupe(u8, path), .present = false, .mode = .regular, .blob = "" }, error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, // includes a file larger than max_blob }; return .{ .path = try a.dupe(u8, path), .present = true, .mode = mode, .blob = blob }; } fn captureCheckout(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?checkout.CheckoutState { var buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = checkout.read(io, root, &buf) catch return error.Storage; const state = ck orelse return null; return .{ .version = try a.dupe(u8, state.version), .branch = try a.dupe(u8, state.branch), .merge_parent = try a.dupe(u8, state.merge_parent), .special_op = try a.dupe(u8, state.special_op), .manifest_flags = try a.dupe(u8, state.manifest_flags), .integrate = try a.dupe(u8, state.integrate), }; } fn captureManaged(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?[]const u8 { return checkout.readSectionAlloc(a, io, root, .managed) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.Storage => error.Storage, }; } fn isExecutable(st: std.Io.File.Stat) bool { if (comptime std.Io.File.Permissions.has_executable_bit) { return st.permissions.toMode() & 0o111 != 0; } return false; } fn imagePaths(a: std.mem.Allocator, image: Image) JournalError![]const []const u8 { var list = try a.alloc([]const u8, image.files.len); for (image.files, 0..) |fe, i| list[i] = fe.path; return list; } // --------------------------------------------------------------------------- // Materialize (restore an image onto the working tree). // --------------------------------------------------------------------------- /// Transition-safe file ordering: every absent path precedes every present /// path, and longer absent paths precede shorter ones so a descendant is /// removed before its ancestor. fn restoreOrderLess(_: void, a: FileEntry, b: FileEntry) bool { if (a.present != b.present) return !a.present; if (!a.present and a.path.len != b.path.len) return a.path.len > b.path.len; return std.mem.order(u8, a.path, b.path) == .lt; } /// Restore working files in the prepared order, then the managed set and the /// checkout record together as the commit point — ONE container write /// (checkout.zig `updateSections`), which closes the old two-file window /// between `.zit-managed` and `.zit-checkout` this module's header used to /// document. Reuses `update`'s file materialization; undo owns no parallel /// working-file path (B-undo-12). fn materialize(io: std.Io, root: std.Io.Dir, image: Image) JournalError!void { std.debug.assert(image.files.len <= max_files); for (image.files) |fe| { if (fe.present) { std.debug.assert(fe.blob.len <= max_blob); update.restoreFile(io, root, fe.path, fe.blob, fe.mode) catch return error.Storage; } else { update.removeFile(io, root, fe.path) catch return error.Storage; } } var ck_buf: [checkout.max_ck_payload]u8 = undefined; const ck_payload: ?[]const u8 = if (image.checkout) |ck| checkout.encodeCk(&ck_buf, ck) else null; checkout.updateSections(io, root, &.{ .{ .tag = .managed, .payload = image.managed }, .{ .tag = .checkout, .payload = ck_payload }, }) catch return error.Storage; } /// Atomic whole-file write (write-temp + fsync + rename-replace), the same /// durable sequence `checkout.write`/`ManagedSet.write` use. The one place a /// control record's raw bytes are restored byte-for-byte. fn writeRawAtomic(io: std.Io, root: std.Io.Dir, name: []const u8, bytes: []const u8) JournalError!void { var af = root.createFileAtomic(io, name, .{ .replace = true }) catch return error.Storage; defer af.deinit(io); af.file.writePositionalAll(io, bytes, 0) catch return error.Storage; af.file.sync(io) catch return error.Storage; // fsync before the rename af.replace(io) catch return error.Storage; } // --------------------------------------------------------------------------- // Wire format. `magic(8) | fmt(1) | slot(undo) | slot(redo) | crc32 le(4)`, // crc over all preceding bytes. A slot is `present u8 [image]`. Every length is // bounds-checked against the remaining input and its cap before use; // consumption must be exact; the crc must match. Any violation is // `error.Corrupt` (B-undo-11). Read via `readJournalFile`, written via // `writeJournal`, both atomic + fsync'd. // --------------------------------------------------------------------------- fn writeJournal(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, slots: Slots) JournalError!void { const bytes = try encodeJournal(gpa, slots); defer gpa.free(bytes); checkout.updateSections(io, root, &.{.{ .tag = .undo, .payload = bytes }}) catch |e| switch (e) { error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, // the container being spliced is damaged }; // Negative space: the just-written slots reflect the intended swap. std.debug.assert((slots.undo == null) or (slots.redo == null)); } /// Read the journal whole — the undo section of the `.zit-checkout` /// container, or the legacy `.zit-undo` sibling on a pre-container checkout. /// /// There is no capacity constant here on purpose. A journal holds the /// pre-images of every path one mutation touches, so its size is /// (files touched) x (their content) — both properties of the user's tree, not /// of this format. Any fixed number is therefore a guess about someone else's /// repository, and the guess fails in the direction that matters: `record` has /// no such cap, so a ceiling below what a real checkout produces yields a /// journal this code wrote and then refuses to read, disabling undo precisely /// when a large operation made it most valuable. The allocation is sized from /// the container's crc-verified section table (or the sibling's stat), never /// from an unbacked declared length; structural validation is `parseJournal`'s /// job and is unchanged. fn readJournalFile(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?[]u8 { return checkout.readSectionAlloc(gpa, io, root, .undo) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, error.Storage => error.Storage, }; } fn encodeJournal(gpa: std.mem.Allocator, slots: Slots) JournalError![]u8 { var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(gpa); buf.appendSlice(gpa, magic) catch return error.OutOfMemory; buf.append(gpa, fmt_version) catch return error.OutOfMemory; try encodeSlot(gpa, &buf, slots.undo); try encodeSlot(gpa, &buf, slots.redo); var crc: [4]u8 = undefined; std.mem.writeInt(u32, &crc, crc32.Crc32.hash(buf.items), .little); buf.appendSlice(gpa, &crc) catch return error.OutOfMemory; return buf.toOwnedSlice(gpa) catch return error.OutOfMemory; } fn encodeSlot(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), image: ?Image) JournalError!void { const img = image orelse { buf.append(gpa, 0) catch return error.OutOfMemory; return; }; buf.append(gpa, 1) catch return error.OutOfMemory; // checkout if (img.checkout) |ck| { buf.append(gpa, 1) catch return error.OutOfMemory; try encodeStr16(gpa, buf, ck.version); try encodeStr16(gpa, buf, ck.branch); try encodeStr16(gpa, buf, ck.merge_parent); try encodeStr16(gpa, buf, ck.special_op); try encodeStr16(gpa, buf, ck.manifest_flags); try encodeStr16(gpa, buf, ck.integrate); } else { buf.append(gpa, 0) catch return error.OutOfMemory; } // managed (raw) if (img.managed) |m| { buf.append(gpa, 1) catch return error.OutOfMemory; try encodeU32(gpa, buf, @intCast(m.len)); buf.appendSlice(gpa, m) catch return error.OutOfMemory; } else { buf.append(gpa, 0) catch return error.OutOfMemory; } // files try encodeU32(gpa, buf, @intCast(img.files.len)); for (img.files) |fe| { try encodeStr16(gpa, buf, fe.path); buf.append(gpa, @intFromBool(fe.present)) catch return error.OutOfMemory; buf.append(gpa, modeByte(fe.mode)) catch return error.OutOfMemory; if (fe.present) { try encodeU64(gpa, buf, @intCast(fe.blob.len)); buf.appendSlice(gpa, fe.blob) catch return error.OutOfMemory; } } } fn encodeStr16(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) JournalError!void { var len: [2]u8 = undefined; std.mem.writeInt(u16, &len, @intCast(s.len), .little); buf.appendSlice(gpa, &len) catch return error.OutOfMemory; buf.appendSlice(gpa, s) catch return error.OutOfMemory; } fn encodeU32(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), v: u32) JournalError!void { var b: [4]u8 = undefined; std.mem.writeInt(u32, &b, v, .little); buf.appendSlice(gpa, &b) catch return error.OutOfMemory; } fn encodeU64(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), v: u64) JournalError!void { var b: [8]u8 = undefined; std.mem.writeInt(u64, &b, v, .little); buf.appendSlice(gpa, &b) catch return error.OutOfMemory; } fn modeByte(mode: artifact.FileMode) u8 { return switch (mode) { .regular => 0, .executable => 1, .symlink => 2, // never emitted; parsed back as Corrupt (worktree files are regular) }; } /// A forward cursor over the journal bytes. Every read bounds-checks against the /// remaining input; an over-read is `error.Corrupt`, never a panic. const Cursor = struct { bytes: []const u8, pos: usize = 0, fn u8v(self: *Cursor) JournalError!u8 { if (self.pos + 1 > self.bytes.len) return error.Corrupt; const v = self.bytes[self.pos]; self.pos += 1; return v; } fn u16v(self: *Cursor) JournalError!u16 { if (self.pos + 2 > self.bytes.len) return error.Corrupt; const v = std.mem.readInt(u16, self.bytes[self.pos..][0..2], .little); self.pos += 2; return v; } fn u32v(self: *Cursor) JournalError!u32 { if (self.pos + 4 > self.bytes.len) return error.Corrupt; const v = std.mem.readInt(u32, self.bytes[self.pos..][0..4], .little); self.pos += 4; return v; } fn u64v(self: *Cursor) JournalError!u64 { if (self.pos + 8 > self.bytes.len) return error.Corrupt; const v = std.mem.readInt(u64, self.bytes[self.pos..][0..8], .little); self.pos += 8; return v; } fn take(self: *Cursor, n: usize) JournalError![]const u8 { if (self.pos + n > self.bytes.len) return error.Corrupt; const s = self.bytes[self.pos..][0..n]; self.pos += n; return s; } }; fn parseJournal(a: std.mem.Allocator, raw: []const u8) JournalError!Slots { if (raw.len < magic.len + 1 + 4) return error.Corrupt; if (!std.mem.eql(u8, raw[0..magic.len], magic)) return error.Corrupt; // crc covers every byte before the trailing 4-byte crc field. const body = raw[0 .. raw.len - 4]; const stored = std.mem.readInt(u32, raw[raw.len - 4 ..][0..4], .little); if (crc32.Crc32.hash(body) != stored) return error.Corrupt; var cur = Cursor{ .bytes = body, .pos = magic.len }; if (try cur.u8v() != fmt_version) return error.Corrupt; const undo_img = try parseSlot(a, &cur); const redo_img = try parseSlot(a, &cur); if (cur.pos != body.len) return error.Corrupt; // exact consumption, no trailing bytes return .{ .undo = undo_img, .redo = redo_img }; } fn parseSlot(a: std.mem.Allocator, cur: *Cursor) JournalError!?Image { switch (try cur.u8v()) { 0 => return null, 1 => {}, else => return error.Corrupt, } const ck: ?checkout.CheckoutState = switch (try cur.u8v()) { 0 => null, 1 => blk: { const version = try parseStr16(a, cur); const branch = try parseStr16(a, cur); const merge_parent = try parseStr16(a, cur); const special_op = try parseStr16(a, cur); const manifest_flags = try parseStr16(a, cur); const integrate = try parseStr16(a, cur); break :blk .{ .version = version, .branch = branch, .merge_parent = merge_parent, .special_op = special_op, .manifest_flags = manifest_flags, .integrate = integrate, }; }, else => return error.Corrupt, }; const m: ?[]const u8 = switch (try cur.u8v()) { 0 => null, 1 => blk: { const len = try cur.u32v(); if (len > managed.max_file_bytes) return error.Corrupt; break :blk try a.dupe(u8, try cur.take(len)); }, else => return error.Corrupt, }; const count = try cur.u32v(); // A DECLARED count, unlike the stat-derived sizes elsewhere in this tree — // and the allocation below is sized from it, so it must be disbelieved. // Bound it by what the remaining payload could possibly back: `parseFile` // consumes at least a u16 path length, a presence byte and a mode byte, so // four bytes per entry is the floor. A lying count therefore cannot force an // oversized allocation, it fails here. // // This used to read `count > max_files`. That stopped protecting anything // when `max_files` became the format's own u32 range (a u32 count can never // exceed it), which would have left a 4-billion-entry claim reaching the // allocator — the precise shape of hazard that raising a shared limit // without re-checking its dependents creates. const min_entry_bytes: usize = 4; if (count > (cur.bytes.len - cur.pos) / min_entry_bytes) return error.Corrupt; var files = try a.alloc(FileEntry, count); for (0..count) |i| files[i] = try parseFile(a, cur); return .{ .checkout = ck, .managed = m, .files = files }; } fn parseFile(a: std.mem.Allocator, cur: *Cursor) JournalError!FileEntry { const path = try parseStr16Path(a, cur); const present = switch (try cur.u8v()) { 0 => false, 1 => true, else => return error.Corrupt, }; const mode: artifact.FileMode = switch (try cur.u8v()) { 0 => .regular, 1 => .executable, else => return error.Corrupt, // symlink/other never a valid worktree pre-image }; if (!present) return .{ .path = path, .present = false, .mode = mode, .blob = "" }; const len = try cur.u64v(); if (len > max_blob) return error.Corrupt; const blob = try a.dupe(u8, try cur.take(@intCast(len))); return .{ .path = path, .present = true, .mode = mode, .blob = blob }; } fn parseStr16(a: std.mem.Allocator, cur: *Cursor) JournalError![]const u8 { const len = try cur.u16v(); if (len > checkout.max_ck_str) return error.Corrupt; return a.dupe(u8, try cur.take(len)) catch error.OutOfMemory; } fn parseStr16Path(a: std.mem.Allocator, cur: *Cursor) JournalError![]const u8 { const len = try cur.u16v(); if (len == 0 or len > managed.max_path_len) return error.Corrupt; return a.dupe(u8, try cur.take(len)) catch error.OutOfMemory; } // --------------------------------------------------------------------------- // Tests (contracts/undo.md). Expected values are the exact bytes each test // stages pre-mutation — the contract defines restoration as returning those // bytes; no fossil is ever run. // --------------------------------------------------------------------------- const testing = std.testing; fn writeWorktreeFile(io: std.Io, dir: std.Io.Dir, name: []const u8, bytes: []const u8) !void { var f = try dir.createFile(io, name, .{ .truncate = true }); defer f.close(io); try f.writePositionalAll(io, bytes, 0); } fn readWorktreeFile(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, name: []const u8) ![]u8 { return dir.readFileAlloc(io, name, gpa, .limited(1 << 20)); } test "undo journal: the pre-image cap is the tracked-file cap, not the path-table aggregate" { // A regression pin, not a style preference. Undo journals the pre-image of // files commit and diff already accepted, so a cap below theirs cannot // protect anything and can only refuse a file that is already tracked. // While this read `managed.max_file_bytes` (the serialized PATH-TABLE // bound), every tracked file over 64 MiB failed `record` with // `error.Storage`, and since a durable pre-image gates any mutation, that // aborted checkout, update and merge entirely. try testing.expectEqual(managed.max_tracked_file_bytes, max_blob); // And it is NOT the path-table aggregate. Stated as inequality of identity, // not of magnitude: an earlier version of this asserted `max_blob > // max_file_bytes`, which held only because the aggregate happened to be a // flat 64 MiB at the time. Once that became the format's own expressible // maximum the ordering flipped and the test failed — while the property it // meant to protect (these are different quantities and undo uses the // content one) was never in danger. Magnitudes are incidental here; which // constant is used is the invariant. try testing.expect(max_blob != managed.max_file_bytes); } test "undo journal: record then parse round-trips checkout, managed, and file entries" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var set = managed.ManagedSet.init(gpa); defer set.deinit(); _ = try set.insert("a.txt"); try set.write(io, tmp.dir); try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n"); try record(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" }); const raw = (try readJournalFile(gpa, io, tmp.dir)).?; defer gpa.free(raw); var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const slots = try parseJournal(arena.allocator(), raw); try testing.expect(slots.undo != null); try testing.expect(slots.redo == null); // record clears redo (B-undo-7) const img = slots.undo.?; try testing.expectEqualStrings("a" ** 64, img.checkout.?.version); try testing.expectEqualStrings("trunk", img.checkout.?.branch); try testing.expect(img.managed != null); try testing.expectEqual(@as(usize, 2), img.files.len); try testing.expectEqualStrings("a.txt", img.files[0].path); try testing.expect(img.files[0].present); try testing.expectEqualStrings("PRE\n", img.files[0].blob); try testing.expectEqualStrings("b.txt", img.files[1].path); try testing.expect(!img.files[1].present); // absent path recorded, not an error } test "undo journal B-undo-3: record writes+fsyncs the pre-image before any mutation, and undo recovers it" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // Pre-mutation tree. try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var set = managed.ManagedSet.init(gpa); defer set.deinit(); _ = try set.insert("a.txt"); try set.write(io, tmp.dir); try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n"); // Journal is written by record; the working file is still the pre-image (the // ordering guarantee: the journal is durable before the first byte moves). try record(gpa, io, tmp.dir, &.{ "a.txt", "b.txt" }); { const still = try readWorktreeFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(still); try testing.expectEqualStrings("PRE\n", still); } // Now simulate the mutation: overwrite a.txt, create b.txt, advance the // control records — exactly what `applyCheckin` does after `record` returns. try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n"); try writeWorktreeFile(io, tmp.dir, "b.txt", "NEW\n"); try checkout.write(io, tmp.dir, .{ .version = "2" ** 64, .branch = "feature", .merge_parent = "", .special_op = "", .manifest_flags = "" }); _ = try set.insert("b.txt"); try set.write(io, tmp.dir); // undo restores the journaled pre-image byte-for-byte. try undo(gpa, io, tmp.dir); { const restored = try readWorktreeFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(restored); try testing.expectEqualStrings("PRE\n", restored); } try testing.expect(!fileExists(io, tmp.dir, "b.txt")); // created file removed var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?; try testing.expectEqualStrings("1" ** 64, ck.version); try testing.expectEqualStrings("trunk", ck.branch); } test "undo journal B-undo-4/5: undo then redo round-trips the post-mutation state" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var set = managed.ManagedSet.init(gpa); defer set.deinit(); _ = try set.insert("a.txt"); try set.write(io, tmp.dir); try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n"); try record(gpa, io, tmp.dir, &.{"a.txt"}); try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n"); try checkout.write(io, tmp.dir, .{ .version = "2" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); try undo(gpa, io, tmp.dir); // -> PRE { const v = try readWorktreeFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(v); try testing.expectEqualStrings("PRE\n", v); } try redo(gpa, io, tmp.dir); // -> POST (exact inverse) { const v = try readWorktreeFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(v); try testing.expectEqualStrings("POST\n", v); } var ckbuf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (try checkout.read(io, tmp.dir, &ckbuf)).?; try testing.expectEqualStrings("2" ** 64, ck.version); } test "undo journal B-undo-6: a second consecutive undo finds no image (Empty)" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var set = managed.ManagedSet.init(gpa); defer set.deinit(); _ = try set.insert("a.txt"); try set.write(io, tmp.dir); try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n"); try record(gpa, io, tmp.dir, &.{"a.txt"}); try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n"); try undo(gpa, io, tmp.dir); try testing.expectError(error.Empty, undo(gpa, io, tmp.dir)); } test "undo journal B-undo-9/10: a fresh repo has no undo and no redo image" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try testing.expectError(error.Empty, undo(gpa, io, tmp.dir)); try testing.expectError(error.Empty, redo(gpa, io, tmp.dir)); } test "undo journal B-undo-11: a corrupt journal is error.Corrupt and mutates nothing" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var set = managed.ManagedSet.init(gpa); defer set.deinit(); _ = try set.insert("a.txt"); try set.write(io, tmp.dir); try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n"); try record(gpa, io, tmp.dir, &.{"a.txt"}); try writeWorktreeFile(io, tmp.dir, "a.txt", "POST\n"); // Corrupt the journal: flip a byte inside the container's undo section. // (The container's own crc catches it before parseJournal would.) { const raw = try tmp.dir.readFileAlloc(io, checkout.filename, gpa, .limited(1 << 24)); defer gpa.free(raw); raw[raw.len - 8] ^= 0xff; // a payload byte (the last section's tail) try writeRawAtomic(io, tmp.dir, checkout.filename, raw); } try testing.expectError(error.Corrupt, undo(gpa, io, tmp.dir)); const unchanged = try readWorktreeFile(gpa, io, tmp.dir, "a.txt"); defer gpa.free(unchanged); try testing.expectEqualStrings("POST\n", unchanged); // no partial restore } test "undo journal B-undo-12: undo restores an executable bit through the update layer" { if (comptime !std.Io.File.Permissions.has_executable_bit) return error.SkipZigTest; const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); var set = managed.ManagedSet.init(gpa); defer set.deinit(); _ = try set.insert("s.sh"); try set.write(io, tmp.dir); // Stage an executable pre-image via the update layer (the same writer undo reuses). try update.restoreFile(io, tmp.dir, "s.sh", "#!/bin/sh\n", .executable); try record(gpa, io, tmp.dir, &.{"s.sh"}); try update.restoreFile(io, tmp.dir, "s.sh", "changed\n", .regular); try undo(gpa, io, tmp.dir); const st = try tmp.dir.statFile(io, "s.sh", .{ .follow_symlinks = false }); try testing.expect(isExecutable(st)); // exec bit restored const bytes = try readWorktreeFile(gpa, io, tmp.dir, "s.sh"); defer gpa.free(bytes); try testing.expectEqualStrings("#!/bin/sh\n", bytes); } test "undo journal: encodeJournal/parseJournal round-trips both slots" { const gpa = testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); const undo_img = Image{ .checkout = .{ .version = "v", .branch = "b", .merge_parent = "m", .special_op = "+" ++ "a" ** 64, .manifest_flags = "rut" }, .managed = "MANAGED", .files = &.{ .{ .path = "x", .present = true, .mode = .executable, .blob = "hi" }, .{ .path = "y/z", .present = false, .mode = .regular, .blob = "" }, }, }; const bytes = try encodeJournal(gpa, .{ .undo = undo_img, .redo = null }); defer gpa.free(bytes); const slots = try parseJournal(a, bytes); try testing.expect(slots.redo == null); const got = slots.undo.?; try testing.expectEqualStrings("v", got.checkout.?.version); try testing.expectEqualStrings("m", got.checkout.?.merge_parent); // third field round-trips try testing.expectEqualStrings("+" ++ "a" ** 64, got.checkout.?.special_op); // fourth field round-trips try testing.expectEqualStrings("rut", got.checkout.?.manifest_flags); // fifth field round-trips try testing.expectEqualStrings("MANAGED", got.managed.?); try testing.expectEqual(@as(usize, 2), got.files.len); try testing.expect(got.files[0].mode == .executable); try testing.expectEqualStrings("hi", got.files[0].blob); try testing.expect(!got.files[1].present); } test "undo journal B-undo-11: corruption cases are error.Corrupt, never a crash" { const gpa = testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); const good = try encodeJournal(gpa, .{ .undo = .{ .checkout = null, .managed = null, .files = &.{} }, .redo = null, }); defer gpa.free(good); // Sanity: the well-formed journal parses. _ = try parseJournal(a, good); // Bad magic. { const bad = try gpa.dupe(u8, good); defer gpa.free(bad); bad[0] = 'X'; try testing.expectError(error.Corrupt, parseJournal(a, bad)); } // Bad crc. { const bad = try gpa.dupe(u8, good); defer gpa.free(bad); bad[bad.len - 1] ^= 0xff; try testing.expectError(error.Corrupt, parseJournal(a, bad)); } // Truncated (drop the crc). try testing.expectError(error.Corrupt, parseJournal(a, good[0 .. good.len - 1])); // Trailing byte (crc no longer over the true body). { const bad = try gpa.alloc(u8, good.len + 1); defer gpa.free(bad); @memcpy(bad[0..good.len], good); bad[good.len] = 0; try testing.expectError(error.Corrupt, parseJournal(a, bad)); } } test "undo journal B-undo bound: a file_count over max_files is error.Corrupt" { const gpa = testing.allocator; var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); // Hand-build: magic | fmt | undo present | ck absent | managed absent | // file_count = max_files+1 | redo absent | crc. var buf: std.ArrayList(u8) = .empty; defer buf.deinit(gpa); try buf.appendSlice(gpa, magic); try buf.append(gpa, fmt_version); try buf.append(gpa, 1); // undo present try buf.append(gpa, 0); // ck absent try buf.append(gpa, 0); // managed absent // A count no payload could back: nothing follows it, so four bytes per // entry cannot be found. `max_files + 1` is unrepresentable now that // `max_files` is the u32 field's own range, and would not have been the // real hazard anyway — an unbacked count reaching the allocator is. try encodeU32(gpa, &buf, std.math.maxInt(u32)); // more entries than bytes try buf.append(gpa, 0); // redo absent var crc: [4]u8 = undefined; std.mem.writeInt(u32, &crc, crc32.Crc32.hash(buf.items), .little); try buf.appendSlice(gpa, &crc); try testing.expectError(error.Corrupt, parseJournal(a, buf.items)); } // Seeded deterministic fuzz: parseJournal on adversarial bytes yields a value // or error.Corrupt, never a crash; and parseJournal(encodeJournal(x)) recovers // x for any bounded slot pair. test "undo journal fuzz: parseJournal never crashes and encode round-trips" { const gpa = testing.allocator; var prng = std.Random.DefaultPrng.init(0x5EED_11D0); const random = prng.random(); var iter: usize = 0; while (iter < 500) : (iter += 1) { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); // Round-trip a randomly shaped image. const undo_img = try randomImage(a, random); const bytes = try encodeJournal(gpa, .{ .undo = undo_img, .redo = null }); defer gpa.free(bytes); const slots = try parseJournal(a, bytes); try testing.expect(slots.undo != null); try testing.expectEqual(undo_img.files.len, slots.undo.?.files.len); // Garbage: any random byte string is a value or Corrupt, never a crash. var garbage: [96]u8 = undefined; const gn = random.uintAtMost(usize, garbage.len); random.bytes(garbage[0..gn]); if (parseJournal(a, garbage[0..gn])) |_| {} else |err| switch (err) { error.Corrupt, error.OutOfMemory => {}, error.Storage, error.Empty => unreachable, } // Flip one byte of a valid journal: parse yields a value or Corrupt. if (bytes.len > 0) { const mutant = try a.dupe(u8, bytes); mutant[random.uintLessThan(usize, mutant.len)] ^= @as(u8, 1) << random.int(u3); if (parseJournal(a, mutant)) |_| {} else |err| switch (err) { error.Corrupt, error.OutOfMemory => {}, error.Storage, error.Empty => unreachable, } } } } fn randomImage(a: std.mem.Allocator, random: std.Random) !Image { const n = random.uintAtMost(usize, 4); var files = try a.alloc(FileEntry, n); for (0..n) |i| { const plen = random.uintLessThan(usize, 8) + 1; const path = try a.alloc(u8, plen); random.bytes(path); const present = random.boolean(); var blob: []const u8 = ""; if (present) { const blen = random.uintAtMost(usize, 16); const b = try a.alloc(u8, blen); random.bytes(b); blob = b; } files[i] = .{ .path = path, .present = present, .mode = if (random.boolean()) .executable else .regular, .blob = blob, }; } return .{ .checkout = if (random.boolean()) .{ .version = "ver", .branch = "br", .merge_parent = "mp", .special_op = "", .manifest_flags = "" } else null, .managed = if (random.boolean()) "M" else null, .files = files, }; } fn fileExists(io: std.Io, dir: std.Io.Dir, name: []const u8) bool { var f = dir.openFile(io, name, .{}) catch return false; f.close(io); return true; } // checkAllAllocationFailures for the one public allocating entry point that is // unit-reachable without a full logstore: `record`. Every allocation-failure // path must surface `error.OutOfMemory`, never leak or crash. test "undo journal: record survives injected allocation failures" { const io = testing.io; const Ctx = struct { fn run(gpa: std.mem.Allocator, dir: std.Io.Dir, aio: std.Io) !void { try record(gpa, aio, dir, &.{ "a.txt", "b.txt" }); } }; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "1" ** 40, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" }); try writeWorktreeFile(io, tmp.dir, "a.txt", "PRE\n"); try testing.checkAllAllocationFailures(testing.allocator, Ctx.run, .{ tmp.dir, io }); }