//! 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 repeat = @import("repeat.zig").repeat;
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
/// 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 (`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 = repeat("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(repeat("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 = repeat("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 = repeat("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(repeat("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 = repeat("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 = repeat("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(repeat("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 = repeat("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 = repeat("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 = repeat("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 = "+" ++ repeat("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("+" ++ repeat("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 = repeat("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 });
}