//! 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 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, @intFromEnum(f.disposition));
try appendInt(gpa, &buf, u8, @intFromEnum(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".fields) |f| {
if (raw == f.value) return @enumFromInt(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 = "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("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("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));
}
}