//! Storage §E: per-checkout working state, kept OUT of the shared log.
//! Fossil keeps checkout/working state outside the repository file (in
//! `_FOSSIL_`); this is the analog — a small record materialized atomically
//! into `.zit-checkout` beside the working tree via a write-temp + fsync +
//! rename-replace sequence (all inside std's `Dir.createFileAtomic` /
//! `File.Atomic.replace`). A crash mid-write leaves the prior record intact:
//! a reader only ever observes `filename`, never a half-written temp.
//!
//! No RFC section behind this file: native storage-engine design, the same
//! oracle as logstore.zig (storage-track-plan.md §E). 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`.
//!
//! This state deliberately does NOT enter the log: it is per-working-copy,
//! not part of the shippable `.zit` container (§E).
const std = @import("std");
/// The device failed, or the record on disk is not a `.zit-checkout` this
/// reader can parse. Closed at the boundary so a CLI caller can switch
/// exhaustively.
pub const CheckoutError = error{ Storage, Corrupt };
/// The working-state file, resolved relative to the injected checkout dir.
pub const filename: []const u8 = ".zit-checkout";
/// Per-string upper bound (a hash or branch name never approaches it).
pub const max_ck_str: usize = 255;
/// Fixed record bound: two length-prefixed strings.
pub const max_ck_payload: usize = 2 * (2 + max_ck_str); // = 514
/// One checkout's working state. Both slices borrow the caller's buffer
/// (for `read`) or the caller's own storage (for `write`); this struct owns
/// nothing.
pub const CheckoutState = struct {
/// The checked-out artifact hash.
version: []const u8,
/// The current branch name.
branch: []const u8,
};
/// Materializes `state` atomically into `filename`. The ephemeral file is
/// created with `replace = true` because it is committed via
/// `File.Atomic.replace`, which asserts `file_exists` — a flag std sets only
/// when that option is true. Data is fsync'd before the rename so the record
/// is durable the instant it becomes visible. Every I/O failure collapses to
/// one outcome (`error.Storage`); on any early return the trailing `deinit`
/// deletes the unmaterialized temp.
pub fn write(io: std.Io, dir: std.Io.Dir, state: CheckoutState) CheckoutError!void {
std.debug.assert(state.version.len <= max_ck_str);
std.debug.assert(state.branch.len <= max_ck_str);
var buf: [max_ck_payload]u8 = undefined;
const bytes = encodeCk(&buf, state);
var af = dir.createFileAtomic(io, filename, .{ .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 data before the rename
af.replace(io) catch return error.Storage; // atomic rename into place
}
/// Reads and parses `filename` into `out_buf`, which must hold at least one
/// byte past the record bound (`> max_ck_payload`): that extra byte is what
/// lets an oversized file be detected rather than silently truncated to a
/// valid-looking maximal prefix. Returns null when no checkout file exists
/// (`FileNotFound` is absence, not an error, §E). A malformed record is
/// `error.Corrupt`; any other I/O failure is `error.Storage`. The result's
/// slices borrow `out_buf`, which must outlive them.
pub fn read(io: std.Io, dir: std.Io.Dir, out_buf: []u8) CheckoutError!?CheckoutState {
std.debug.assert(out_buf.len > max_ck_payload);
var file = dir.openFile(io, filename, .{}) catch |e| switch (e) {
error.FileNotFound => return null,
else => return error.Storage,
};
defer file.close(io);
const n = file.readPositionalAll(io, out_buf, 0) catch return error.Storage;
// No valid record exceeds max_ck_payload bytes. A read past it means the
// file is longer than any record this reader emits — reject it, never parse
// a truncated prefix (an exactly-maximal record leaves the extra byte free).
if (n > max_ck_payload) return error.Corrupt;
return try parseCk(out_buf[0..n]);
}
/// Encodes `s` into `buf` as `[version_len u16 LE][version][branch_len u16
/// LE][branch]` and returns the written slice. Fixed bounds, no unbounded
/// copy.
fn encodeCk(buf: []u8, s: CheckoutState) []const u8 {
std.debug.assert(buf.len >= max_ck_payload);
std.debug.assert(s.version.len <= max_ck_str);
std.debug.assert(s.branch.len <= max_ck_str);
const vl: usize = s.version.len;
const bl: usize = s.branch.len;
std.mem.writeInt(u16, buf[0..2], @intCast(vl), .little);
@memcpy(buf[2..][0..vl], s.version);
std.mem.writeInt(u16, buf[2 + vl ..][0..2], @intCast(bl), .little);
@memcpy(buf[4 + vl ..][0..bl], s.branch);
return buf[0 .. 4 + vl + bl];
}
/// Parses a `encodeCk` record. Every length is bounds-checked against the
/// remaining slice before use, each length is capped at `max_ck_str`, and
/// total consumption must be exact (trailing bytes are corruption, never
/// silently dropped). All wire-derived, validated before use.
fn parseCk(payload: []const u8) error{Corrupt}!CheckoutState {
var pos: usize = 0;
if (payload.len - pos < 2) return error.Corrupt;
const vlen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += 2;
if (vlen > max_ck_str) return error.Corrupt;
if (payload.len - pos < vlen) return error.Corrupt;
const version = payload[pos..][0..vlen];
pos += vlen;
if (payload.len - pos < 2) return error.Corrupt;
const blen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += 2;
if (blen > max_ck_str) return error.Corrupt;
if (payload.len - pos < blen) return error.Corrupt;
const branch = payload[pos..][0..blen];
pos += blen;
if (pos != payload.len) return error.Corrupt; // no trailing bytes
return .{ .version = version, .branch = branch };
}
// ---------------------------------------------------------------------------
// Tests (storage-track-plan.md §E; no RFC section behind this file).
// ---------------------------------------------------------------------------
test "checkout §E: write then read round-trips version and branch" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
const state: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk" };
try write(io, tmp.dir, state);
var buf: [max_ck_payload + 1]u8 = undefined;
const got = (try read(io, tmp.dir, &buf)).?;
try std.testing.expectEqualStrings(state.version, got.version);
try std.testing.expectEqualStrings(state.branch, got.branch);
}
test "checkout §E: a second write atomically replaces the state (last wins)" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
try write(io, tmp.dir, .{ .version = "1" ** 40, .branch = "feature" });
try write(io, tmp.dir, .{ .version = "2" ** 40, .branch = "release" });
var buf: [max_ck_payload + 1]u8 = undefined;
const got = (try read(io, tmp.dir, &buf)).?;
try std.testing.expectEqualStrings("2" ** 40, got.version);
try std.testing.expectEqualStrings("release", got.branch);
}
test "checkout §E: read of an absent checkout file returns null" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
var buf: [max_ck_payload + 1]u8 = undefined;
try std.testing.expectEqual(@as(?CheckoutState, null), try read(io, tmp.dir, &buf));
}
test "checkout §E: a partial write that never materializes leaves the prior state intact" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
try write(io, tmp.dir, .{ .version = "old" ** 8, .branch = "trunk" });
// Begin a fresh atomic file, write bytes, then drop it WITHOUT replace:
// deinit deletes the temp, and a reader only ever sees `filename`.
{
var af = try tmp.dir.createFileAtomic(io, filename, .{ .replace = true });
defer af.deinit(io);
var b: [max_ck_payload]u8 = undefined;
const bytes = encodeCk(&b, .{ .version = "new" ** 8, .branch = "feature" });
try af.file.writePositionalAll(io, bytes, 0);
// no af.replace(io): the temp is abandoned
}
var buf: [max_ck_payload + 1]u8 = undefined;
const got = (try read(io, tmp.dir, &buf)).?;
try std.testing.expectEqualStrings("old" ** 8, got.version);
try std.testing.expectEqualStrings("trunk", got.branch);
}
test "checkout §E: parseCk rejects malformed checkout records" {
// TLV bounds: one rejection per condition (storage-track-plan.md §E).
var over: [4 + 300]u8 = undefined;
std.mem.writeInt(u16, over[0..2], 300, .little); // version_len > max_ck_str
const cases = [_][]const u8{
&[_]u8{0x00}, // truncated version_len (1 of 2 bytes)
over[0..2], // version_len exceeds max_ck_str
&[_]u8{ 0x05, 0x00, 'a', 'b' }, // version_len 5 exceeds remaining
&[_]u8{ 0x01, 0x00, 'v', 0x02 }, // truncated branch_len after version
&[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x09 }, // trailing byte after branch
};
for (cases) |c| try std.testing.expectError(error.Corrupt, parseCk(c));
// Whole-file variant: garbage in `filename` surfaces as Corrupt.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
{
var f = try tmp.dir.createFile(io, filename, .{});
defer f.close(io);
try f.writePositionalAll(io, &[_]u8{ 0xff, 0xff, 0xaa }, 0); // version_len 0xffff
}
var buf: [max_ck_payload + 1]u8 = undefined;
try std.testing.expectError(error.Corrupt, read(io, tmp.dir, &buf));
}
test "checkout §E: a file one byte past a maximal record is rejected, not truncated" {
const io = std.testing.io;
// The unique maximal record consumes exactly max_ck_payload bytes
// (version_len 255 + 255 + branch_len 255 + 255). It must still round-trip.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try write(io, tmp.dir, .{ .version = "v" ** max_ck_str, .branch = "b" ** max_ck_str });
var okbuf: [max_ck_payload + 1]u8 = undefined;
const ok = (try read(io, tmp.dir, &okbuf)).?;
try std.testing.expectEqual(@as(usize, max_ck_str), ok.version.len);
try std.testing.expectEqual(@as(usize, max_ck_str), ok.branch.len);
// The same maximal record with one trailing byte is a longer-than-any-record
// file: read must reject it rather than parse the maximal prefix and drop
// the excess.
var tmp2 = std.testing.tmpDir(.{});
defer tmp2.cleanup();
var rec: [max_ck_payload]u8 = undefined;
const maximal = encodeCk(&rec, .{ .version = "v" ** max_ck_str, .branch = "b" ** max_ck_str });
try std.testing.expectEqual(@as(usize, max_ck_payload), maximal.len);
{
var f = try tmp2.dir.createFile(io, filename, .{});
defer f.close(io);
try f.writePositionalAll(io, maximal, 0);
try f.writePositionalAll(io, &[_]u8{0x00}, @intCast(maximal.len));
}
var buf: [max_ck_payload + 1]u8 = undefined;
try std.testing.expectError(error.Corrupt, read(io, tmp2.dir, &buf));
}
// Seeded deterministic fuzz: parseCk on adversarial bytes yields a value or
// error.Corrupt, never a crash; and parseCk(encodeCk(x)) == x for any bounded
// pair.
test "checkout §E fuzz: parseCk never crashes and round-trips encodeCk" {
var prng = std.Random.DefaultPrng.init(0xC4EC);
const random = prng.random();
var iter: usize = 0;
while (iter < 400) : (iter += 1) {
// Round-trip: parseCk(encodeCk(x)) == x for random bounded strings.
var vbuf: [max_ck_str]u8 = undefined;
var bbuf: [max_ck_str]u8 = undefined;
const vn = random.uintAtMost(usize, max_ck_str);
const bn = random.uintAtMost(usize, max_ck_str);
random.bytes(vbuf[0..vn]); // wire data embeds NUL legally
random.bytes(bbuf[0..bn]);
var enc: [max_ck_payload]u8 = undefined;
const bytes = encodeCk(&enc, .{ .version = vbuf[0..vn], .branch = bbuf[0..bn] });
const rt = try parseCk(bytes);
try std.testing.expectEqualSlices(u8, vbuf[0..vn], rt.version);
try std.testing.expectEqualSlices(u8, bbuf[0..bn], rt.branch);
// Garbage: any random byte string is a value or Corrupt, never a crash.
var garbage: [64]u8 = undefined;
const gn = random.uintAtMost(usize, garbage.len);
random.bytes(garbage[0..gn]);
if (parseCk(garbage[0..gn])) |_| {} else |err| switch (err) {
error.Corrupt => {},
}
}
}