Skip to content
zit Artifact [339792e01a]
Login

Artifact [339792e01a]

Artifact 339792e01aef607511f0ea33dffad045d3aa0b0981418b4eec8018c9912f3c00:


//! 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: three length-prefixed strings.
pub const max_ck_payload: usize = 3 * (2 + max_ck_str); // = 771

/// 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,
    /// The pending merge parent's artifact hash, or empty when no merge is
    /// pending. Deliberately has NO default: every construction site threads it
    /// explicitly so a reconstruction can never silently drop a pending merge.
    merge_parent: []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);
    std.debug.assert(state.merge_parent.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][merge_parent_len u16 LE][merge_parent]` and returns the written
/// slice. Three TLVs are ALWAYS emitted (an empty merge_parent is `[00 00]`).
/// 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);
    std.debug.assert(s.merge_parent.len <= max_ck_str);
    const vl: usize = s.version.len;
    const bl: usize = s.branch.len;
    const ml: usize = s.merge_parent.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);
    std.mem.writeInt(u16, buf[4 + vl + bl ..][0..2], @intCast(ml), .little);
    @memcpy(buf[6 + vl + bl ..][0..ml], s.merge_parent);
    return buf[0 .. 6 + vl + bl + ml];
}

/// 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;

    // Third TLV (merge_parent) is read WHEN PRESENT. A legacy two-field record
    // ends exactly here (`pos == payload.len`): it parses as "no pending merge"
    // — the pending merge errors out of existence, no versioning byte needed.
    var merge_parent: []const u8 = "";
    if (pos != payload.len) {
        if (payload.len - pos < 2) return error.Corrupt;
        const mlen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
        pos += 2;
        if (mlen > max_ck_str) return error.Corrupt;
        if (payload.len - pos < mlen) return error.Corrupt;
        merge_parent = payload[pos..][0..mlen];
        pos += mlen;
    }

    if (pos != payload.len) return error.Corrupt; // no trailing bytes
    return .{ .version = version, .branch = branch, .merge_parent = merge_parent };
}

// ---------------------------------------------------------------------------
// Tests (storage-track-plan.md §E; no RFC section behind this file).
// ---------------------------------------------------------------------------

test "checkout §E: write then read round-trips version branch and merge_parent" {
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const io = std.testing.io;

    const state: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "b" ** 64 };
    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);
    try std.testing.expectEqualStrings(state.merge_parent, got.merge_parent);
}

test "checkout §E: an empty merge_parent round-trips as no pending merge" {
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const io = std.testing.io;

    try write(io, tmp.dir, .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "" });

    var buf: [max_ck_payload + 1]u8 = undefined;
    const got = (try read(io, tmp.dir, &buf)).?;
    try std.testing.expectEqualStrings("a" ** 64, got.version);
    try std.testing.expectEqualStrings("trunk", got.branch);
    try std.testing.expectEqualStrings("", got.merge_parent);
}

test "checkout §E: a legacy two-field record parses with empty merge_parent" {
    // A record written before merge_parent existed carries only version+branch
    // and ends after the branch. parseCk must yield merge_parent == "" (no
    // pending merge) rather than reject it — errors-out-of-existence, §E.
    const legacy = [_]u8{ 0x01, 0x00, 'v', 0x05, 0x00, 't', 'r', 'u', 'n', 'k' };
    const got = try parseCk(&legacy);
    try std.testing.expectEqualStrings("v", got.version);
    try std.testing.expectEqualStrings("trunk", got.branch);
    try std.testing.expectEqualStrings("", got.merge_parent);
}

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", .merge_parent = "" });
    try write(io, tmp.dir, .{ .version = "2" ** 40, .branch = "release", .merge_parent = "" });

    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", .merge_parent = "" });

    // 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", .merge_parent = "" });
        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 }, // truncated merge_parent_len after branch
        &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x05, 0x00, 'a' }, // merge_parent_len 5 exceeds remaining
        &[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x09 }, // trailing byte after merge_parent
    };
    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
    // (three length-prefixed 255-byte strings). 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, .merge_parent = "m" ** 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);
    try std.testing.expectEqual(@as(usize, max_ck_str), ok.merge_parent.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, .merge_parent = "m" ** 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;
        var mbuf: [max_ck_str]u8 = undefined;
        const vn = random.uintAtMost(usize, max_ck_str);
        const bn = random.uintAtMost(usize, max_ck_str);
        const mn = random.uintAtMost(usize, max_ck_str);
        random.bytes(vbuf[0..vn]); // wire data embeds NUL legally
        random.bytes(bbuf[0..bn]);
        random.bytes(mbuf[0..mn]);
        var enc: [max_ck_payload]u8 = undefined;
        const bytes = encodeCk(&enc, .{ .version = vbuf[0..vn], .branch = bbuf[0..bn], .merge_parent = mbuf[0..mn] });
        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);
        try std.testing.expectEqualSlices(u8, mbuf[0..mn], rt.merge_parent);

        // 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 => {},
        }
    }
}