Skip to content
zit Artifact [190fa5a5ad]
Login

Artifact [190fa5a5ad]

Artifact 190fa5a5ad07fe52fd479313d7c4f2efe6480fabadf4e49463987709f4888b5b:


//! The `.zit-stash` sidecar — `zit stash`'s storage.
//!
//! NOT AN RFC SURFACE, and unlike every `.zit-cache` member this is USER
//! DATA: stashed working-tree changes exist nowhere else, so the file is
//! durable, never regenerable, and corruption is an ERROR the operator must
//! see — never a silent miss. Local to the checkout, never synced, never an
//! artifact (Fossil stores its stash in the checkout database for the same
//! reasons).
//!
//! Layout, little-endian, whole-file CRC32 last:
//!
//!   magic "ZITSTASH" | version u16 = 1 | reserved u16 = 0 | next_id u32 |
//!   entry_count u32 | entry... | crc32 u32
//!
//!   entry: id u32 | checkin_len u8 | checkin-hex | msg_len u16 | msg |
//!          file_count u32 | file...
//!   file:  path_len u16 | path | disposition u8 | mode u8 |
//!          content_len u32 | content
//!
//! Every load validates framing, lengths, and the CRC; every store is an
//! atomic replace. Entries are immutable once written — `drop` rewrites the
//! file without the entry, ids never recycle (`next_id` is monotonic).

const std = @import("std");
const repeat = @import("repeat.zig").repeat;
const artifact = @import("artifact.zig");

pub const stash_filename: []const u8 = ".zit-stash";
const magic = "ZITSTASH";
const version: u16 = 1;

/// The per-file change a stash entry captured, mirroring what the diff walk
/// classified when the entry was saved.
pub const Disposition = enum(u8) {
    /// In the check-in and edited: `content` is the working-tree version.
    modified = 0,
    /// Managed but not in the check-in: `content` is the new file.
    added = 1,
    /// In the check-in, absent (or unmanaged) in the working tree: no content.
    deleted = 2,
};

pub const Mode = enum(u8) {
    regular = 0,
    executable = 1,
    symlink = 2,
};

pub const File = struct {
    path: []const u8,
    disposition: Disposition,
    mode: Mode,
    /// The stashed bytes (a symlink's target string); empty for `deleted`.
    content: []const u8,
};

pub const Entry = struct {
    id: u32,
    /// The check-in the working changes were relative to — `apply` refuses a
    /// checkout positioned anywhere else (fail-closed; no merge machinery).
    checkin: []const u8,
    message: []const u8,
    files: []const File,
};

pub const Stash = struct {
    gpa: std.mem.Allocator,
    /// Backing bytes every entry slice borrows; null for a fresh empty stash.
    backing: ?[]u8 = null,
    next_id: u32 = 1,
    entries: std.ArrayList(Entry) = .empty,

    pub fn deinit(self: *Stash) void {
        for (self.entries.items) |e| self.freeEntryIfOwned(e);
        self.entries.deinit(self.gpa);
        if (self.backing) |b| self.gpa.free(b);
        self.* = undefined;
    }

    /// An entry appended in this session owns its STRING slices (duped in);
    /// a loaded entry's strings borrow `backing`. The `files` ARRAY is
    /// gpa-owned in both cases. Ownership of the strings is distinguished by
    /// address: a borrowed slice points into `backing`.
    fn freeEntryIfOwned(self: *Stash, e: Entry) void {
        const strings_borrowed = if (self.backing) |b| blk: {
            const start = @intFromPtr(b.ptr);
            const p = @intFromPtr(e.checkin.ptr);
            break :blk p >= start and p < start + b.len;
        } else false;
        if (!strings_borrowed) {
            self.gpa.free(e.checkin);
            self.gpa.free(e.message);
            for (e.files) |f| {
                self.gpa.free(f.path);
                self.gpa.free(f.content);
            }
        }
        self.gpa.free(e.files);
    }

    pub fn byId(self: *const Stash, id: u32) ?Entry {
        for (self.entries.items) |e| {
            if (e.id == id) return e;
        }
        return null;
    }

    /// The newest entry — what `apply`/`pop`/`show` default to.
    pub fn newest(self: *const Stash) ?Entry {
        if (self.entries.items.len == 0) return null;
        return self.entries.items[self.entries.items.len - 1];
    }

    /// Append a new entry, duplicating every input slice into owned memory.
    /// Assigns and returns the entry's id.
    pub fn append(self: *Stash, checkin: []const u8, message: []const u8, files: []const File) error{OutOfMemory}!u32 {
        const id = self.next_id;
        const own_checkin = try self.gpa.dupe(u8, checkin);
        errdefer self.gpa.free(own_checkin);
        const own_message = try self.gpa.dupe(u8, message);
        errdefer self.gpa.free(own_message);
        const own_files = try self.gpa.alloc(File, files.len);
        var owned: usize = 0;
        errdefer {
            for (own_files[0..owned]) |f| {
                self.gpa.free(f.path);
                self.gpa.free(f.content);
            }
            self.gpa.free(own_files);
        }
        for (files, own_files) |f, *slot| {
            const p = try self.gpa.dupe(u8, f.path);
            errdefer self.gpa.free(p);
            const c = try self.gpa.dupe(u8, f.content);
            slot.* = .{ .path = p, .disposition = f.disposition, .mode = f.mode, .content = c };
            owned += 1;
        }
        try self.entries.append(self.gpa, .{ .id = id, .checkin = own_checkin, .message = own_message, .files = own_files });
        self.next_id += 1;
        return id;
    }

    /// Remove the entry with `id`; false when absent. The id is never reused.
    pub fn drop(self: *Stash, id: u32) bool {
        for (self.entries.items, 0..) |e, i| {
            if (e.id != id) continue;
            self.freeEntryIfOwned(e);
            _ = self.entries.orderedRemove(i);
            return true;
        }
        return false;
    }
};

pub const LoadError = error{ OutOfMemory, Storage, Corrupt };

/// Load the checkout's stash, an empty one when the sidecar is absent. Any
/// framing or checksum violation is `Corrupt` — this file is user data, so
/// damage is surfaced, never treated as a miss.
pub fn load(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) LoadError!Stash {
    const bytes = dir.readFileAlloc(io, stash_filename, gpa, .limited(1 << 30)) catch |err| switch (err) {
        error.FileNotFound => return .{ .gpa = gpa },
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.Storage,
    };
    errdefer gpa.free(bytes);

    if (bytes.len < magic.len + 2 + 2 + 4 + 4 + 4) return error.Corrupt;
    if (!std.mem.eql(u8, bytes[0..magic.len], magic)) return error.Corrupt;
    var crc = std.hash.Crc32.init();
    crc.update(bytes[0 .. bytes.len - 4]);
    if (crc.final() != std.mem.readInt(u32, bytes[bytes.len - 4 ..][0..4], .little)) return error.Corrupt;

    var r = Reader{ .bytes = bytes[0 .. bytes.len - 4], .pos = magic.len };
    if (try r.int(u16) != version) return error.Corrupt;
    if (try r.int(u16) != 0) return error.Corrupt;
    var out = Stash{ .gpa = gpa, .backing = bytes };
    errdefer {
        for (out.entries.items) |e| gpa.free(e.files); // strings borrow `bytes`
        out.entries.deinit(gpa);
        out.backing = null; // the earlier errdefer frees `bytes`
    }
    out.next_id = try r.int(u32);
    const n = try r.int(u32);
    if (n > 100_000) return error.Corrupt; // sanity bound, far above any real stash
    var i: u32 = 0;
    while (i < n) : (i += 1) {
        const id = try r.int(u32);
        if (id >= out.next_id) return error.Corrupt; // ids are assigned below next_id
        const checkin_len = try r.int(u8);
        if (checkin_len != 40 and checkin_len != 64) return error.Corrupt;
        const checkin = try r.slice(checkin_len);
        if (!artifact.validateName(checkin)) return error.Corrupt;
        const msg = try r.slice(try r.int(u16));
        const file_count = try r.int(u32);
        if (file_count > 1_000_000) return error.Corrupt;
        const files = try gpa.alloc(File, file_count);
        errdefer gpa.free(files);
        for (files) |*slot| {
            const path = try r.slice(try r.int(u16));
            if (path.len == 0) return error.Corrupt;
            const disposition = enumFromWire(Disposition, try r.int(u8)) orelse return error.Corrupt;
            const mode = enumFromWire(Mode, try r.int(u8)) orelse return error.Corrupt;
            const content = try r.slice(try r.int(u32));
            if (disposition == .deleted and content.len != 0) return error.Corrupt;
            slot.* = .{ .path = path, .disposition = disposition, .mode = mode, .content = content };
        }
        try out.entries.append(gpa, .{ .id = id, .checkin = checkin, .message = msg, .files = files });
    }
    if (r.pos != r.bytes.len) return error.Corrupt; // exact accounting
    return out;
}

/// Persist `stash` atomically (temp + rename), or delete the sidecar when it
/// is empty — an empty stash leaves no residue in the checkout.
pub fn store(stash: *const Stash, gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) error{ OutOfMemory, Storage }!void {
    if (stash.entries.items.len == 0) {
        dir.deleteFile(io, stash_filename) catch {};
        return;
    }
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(gpa);
    try buf.appendSlice(gpa, magic);
    try appendInt(gpa, &buf, u16, version);
    try appendInt(gpa, &buf, u16, 0);
    try appendInt(gpa, &buf, u32, stash.next_id);
    try appendInt(gpa, &buf, u32, @intCast(stash.entries.items.len));
    for (stash.entries.items) |e| {
        try appendInt(gpa, &buf, u32, e.id);
        try appendInt(gpa, &buf, u8, @intCast(e.checkin.len));
        try buf.appendSlice(gpa, e.checkin);
        try appendInt(gpa, &buf, u16, @intCast(e.message.len));
        try buf.appendSlice(gpa, e.message);
        try appendInt(gpa, &buf, u32, @intCast(e.files.len));
        for (e.files) |f| {
            try appendInt(gpa, &buf, u16, @intCast(f.path.len));
            try buf.appendSlice(gpa, f.path);
            try appendInt(gpa, &buf, u8, @backingInt(f.disposition));
            try appendInt(gpa, &buf, u8, @backingInt(f.mode));
            try appendInt(gpa, &buf, u32, @intCast(f.content.len));
            try buf.appendSlice(gpa, f.content);
        }
    }
    var crc = std.hash.Crc32.init();
    crc.update(buf.items);
    try appendInt(gpa, &buf, u32, crc.final());

    var af = dir.createFileAtomic(io, stash_filename, .{ .replace = true }) catch return error.Storage;
    defer af.deinit(io);
    af.file.writePositionalAll(io, buf.items, 0) catch return error.Storage;
    af.file.sync(io) catch return error.Storage; // user data: fsync before the rename
    af.replace(io) catch return error.Storage;
}

fn appendInt(gpa: std.mem.Allocator, buf: *std.ArrayList(u8), comptime T: type, v: T) error{OutOfMemory}!void {
    var tmp: [@sizeOf(T)]u8 = undefined;
    std.mem.writeInt(T, &tmp, v, .little);
    try buf.appendSlice(gpa, &tmp);
}

fn enumFromWire(comptime E: type, raw: u8) ?E {
    inline for (@typeInfo(E).@"enum".field_values) |v| {
        if (raw == v) return @fromBackingInt(@intCast(raw));
    }
    return null;
}

const Reader = struct {
    bytes: []const u8,
    pos: usize,

    fn int(self: *Reader, comptime T: type) error{Corrupt}!T {
        const n = @sizeOf(T);
        if (self.pos + n > self.bytes.len) return error.Corrupt;
        const v = std.mem.readInt(T, self.bytes[self.pos..][0..n], .little);
        self.pos += n;
        return v;
    }

    fn slice(self: *Reader, len: usize) error{Corrupt}![]const u8 {
        if (self.pos + len > self.bytes.len) return error.Corrupt;
        const out = self.bytes[self.pos..][0..len];
        self.pos += len;
        return out;
    }
};

const t = std.testing;

test "stash container: append/store/load round-trips entries, ids stay monotonic across drop" {
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;

    var s = Stash{ .gpa = t.allocator };
    defer s.deinit();
    const c64 = repeat("a", 64);
    const id1 = try s.append(c64, "first", &.{
        .{ .path = "a.txt", .disposition = .modified, .mode = .regular, .content = "edited bytes" },
        .{ .path = "b/c.txt", .disposition = .added, .mode = .executable, .content = "#!/bin/sh\n" },
        .{ .path = "gone.txt", .disposition = .deleted, .mode = .regular, .content = "" },
    });
    const id2 = try s.append(c64, "second", &.{});
    try t.expectEqual(@as(u32, 1), id1);
    try t.expectEqual(@as(u32, 2), id2);
    try store(&s, t.allocator, io, tmp.dir);

    var back = try load(t.allocator, io, tmp.dir);
    defer back.deinit();
    try t.expectEqual(@as(usize, 2), back.entries.items.len);
    const e = back.byId(id1).?;
    try t.expectEqualStrings("first", e.message);
    try t.expectEqualStrings(c64, e.checkin);
    try t.expectEqual(@as(usize, 3), e.files.len);
    try t.expectEqualStrings("edited bytes", e.files[0].content);
    try t.expectEqual(Disposition.added, e.files[1].disposition);
    try t.expectEqual(Mode.executable, e.files[1].mode);
    try t.expectEqual(Disposition.deleted, e.files[2].disposition);

    // Drop the first; the next id keeps counting from where it was.
    try t.expect(back.drop(id1));
    try store(&back, t.allocator, io, tmp.dir);
    var third = try load(t.allocator, io, tmp.dir);
    defer third.deinit();
    try t.expectEqual(@as(usize, 1), third.entries.items.len);
    const id3 = try third.append(c64, "third", &.{});
    try t.expectEqual(@as(u32, 3), id3);
}

test "stash container: an empty stash deletes the sidecar; absence loads empty" {
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var s = try load(t.allocator, io, tmp.dir);
    defer s.deinit();
    try t.expectEqual(@as(usize, 0), s.entries.items.len);
    _ = try s.append(repeat("b", 64), "x", &.{});
    try store(&s, t.allocator, io, tmp.dir);
    try t.expect(s.drop(1));
    try store(&s, t.allocator, io, tmp.dir);
    try t.expectError(error.FileNotFound, tmp.dir.statFile(io, stash_filename, .{}));
}

test "stash container: user data fails loud — every mutation is Corrupt, never a silent miss" {
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();
    const io = t.io;
    var s = Stash{ .gpa = t.allocator };
    defer s.deinit();
    _ = try s.append(repeat("c", 64), "keep me", &.{
        .{ .path = "f.txt", .disposition = .modified, .mode = .regular, .content = "precious" },
    });
    try store(&s, t.allocator, io, tmp.dir);
    const good = try tmp.dir.readFileAlloc(io, stash_filename, t.allocator, .limited(1 << 20));
    defer t.allocator.free(good);

    var i: usize = 0;
    while (i < good.len) : (i += 7) {
        const bad = try t.allocator.dupe(u8, good);
        defer t.allocator.free(bad);
        bad[i] ^= 0xFF;
        var af = try tmp.dir.createFileAtomic(io, stash_filename, .{ .replace = true });
        try af.file.writePositionalAll(io, bad, 0);
        try af.replace(io);
        af.deinit(io);
        try t.expectError(error.Corrupt, load(t.allocator, io, tmp.dir));
    }
}