Artifact c3ef95e4c6affeb893e60922506b760f483a6640fe3fd5c63bf86c6d20cf2675:
- File
src/undo.zig
— part of check-in
[55d6e7c7bb]
at
2026-07-29 13:19:41
on branch logstore-index
— repocache, bisect, undo: checksum through the vendored crc32
The other three on-disk formats defined in terms of this checksum: the cache image self-checksum, the bisect session trailer, the undo journal trailer. Same swap, same values. (user: dmestas, size: 41120) [annotate] [blame] [check-ins using] [more...]
//! 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 `.zit-undo` at the checkout root 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 `.zit-checkout` and `.zit-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`, `.zit-checkout` //! through `checkout.write`; only the whole-file control records `.zit-undo` //! and `.zit-managed` are written here, each through the same atomic //! write-temp + fsync + rename sequence `checkout.write` uses. //! //! 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"); /// The journal file, resolved relative to the injected checkout root. Joins the /// never-tracked control set and the extras exclusion (B-undo-13). pub const filename: []const u8 = ".zit-undo"; /// 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; the managed-set aggregate /// bound doubles as the single-file ceiling. pub const max_blob: usize = managed.max_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 = 3; /// 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 delete fault is swallowed because the /// mutation it guarded did not happen. pub fn clear(io: std.Io, root: std.Io.Dir) void { root.deleteFile(io, filename) catch {}; } const Direction = enum { undo_dir, redo_dir }; /// 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), }; } fn captureManaged(a: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?[]const u8 { return root.readFileAlloc(io, managed.filename, a, .limited(managed.max_file_bytes)) catch |e| switch (e) { error.FileNotFound => null, error.OutOfMemory => error.OutOfMemory, else => 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 `.zit-managed`, then /// `.zit-checkout` last as the commit point. Reuses `update`'s materialization /// and `checkout.write`; undo owns no parallel 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; } } if (image.managed) |bytes| { try writeRawAtomic(io, root, managed.filename, bytes); } else { root.deleteFile(io, managed.filename) catch |e| switch (e) { error.FileNotFound => {}, else => return error.Storage, }; } if (image.checkout) |ck| { checkout.write(io, root, ck) catch return error.Storage; } else { root.deleteFile(io, checkout.filename) catch |e| switch (e) { error.FileNotFound => {}, else => 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); try writeRawAtomic(io, root, filename, bytes); // Negative space: the just-written slots reflect the intended swap. std.debug.assert((slots.undo == null) or (slots.redo == null)); } fn readJournalFile(gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir) JournalError!?[]u8 { return root.readFileAlloc(io, filename, gpa, .limited(journal_read_cap)) catch |e| switch (e) { error.FileNotFound => null, error.OutOfMemory => error.OutOfMemory, else => error.Storage, }; } /// Upper bound on a well-formed journal: two images, each at most a full /// managed set of max-size blobs, plus both control records and framing. A /// larger file is rejected before allocation grows unbounded. const journal_read_cap: usize = 2 * (2 * managed.max_file_bytes + 4096) + 64; 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); } 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); break :blk .{ .version = version, .branch = branch, .merge_parent = merge_parent, .special_op = special_op, .manifest_flags = manifest_flags, }; }, 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(); if (count > max_files) 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: 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 payload). { const raw = (try readJournalFile(gpa, io, tmp.dir)).?; defer gpa.free(raw); raw[magic.len + 3] ^= 0xff; try writeRawAtomic(io, tmp.dir, 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 try encodeU32(gpa, &buf, @intCast(max_files + 1)); // over the bound 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 }); }