//! `MirrorState` — the export's persistent, LOCAL-ONLY sidecar inside
//! DEST/.zit-export-state/: which zit check-ins have already been exported,
//! under which fast-import mark and which Git OID, keyed to the SOURCE log's
//! identity (a `logstore.LogKey`) so a re-run exports only what appended
//! since. Modeled byte-for-byte on `repocache.zig`'s container discipline:
//! magic, version, self-checksum over every earlier byte, and a validated
//! HIT or a total MISS — a stale/corrupt/truncated/wrong-anything file loads
//! as a distinct error, never partially. The file is disposable: deleting it
//! costs a full re-export, never a wrong answer, and it is never synced.
const std = @import("std");
const logstore = @import("../logstore.zig");
const crc32 = @import("../crc32.zig");
/// The sidecar directory inside DEST, and the state member within it. A
/// directory (not a bare file) because the fast-import marks file the session
/// exchanges with Git lives beside the state during a run.
pub const state_dirname: []const u8 = ".zit-export-state";
pub const state_member: []const u8 = "state";
pub const state_filename: []const u8 = state_dirname ++ "/" ++ state_member;
const magic: [8]u8 = "ZITGXPRT".*;
const format_version: u16 = 1;
/// The accepted file-size ceiling. Never trust a claimed length: a record
/// count is validated against this bound before any allocation (the
/// repocache rule, restated where it is enforced).
pub const max_state_bytes: u64 = 1 << 30;
/// What one exported artifact's row records. `kind` distinguishes commits
/// (which own marks Git resolves to commit OIDs) from annotated tags.
pub const RecordKind = enum(u8) { commit = 0, tag = 1 };
pub const oid_hex_max: usize = 64;
pub const Record = struct {
/// The zit artifact name, lowercase hex (40 or 64).
hash: []const u8,
/// The fast-import mark this run or a prior run assigned.
mark: u64,
/// The Git OID the marks file resolved the mark to, lowercase hex.
oid: []const u8,
kind: RecordKind,
};
pub const Ref = struct {
/// The published Git ref name, e.g. `refs/heads/trunk`.
name: []const u8,
/// The OID it was last published at, lowercase hex.
oid: []const u8,
};
pub const LoadError = error{
Absent,
WrongMagic,
WrongVersion,
Truncated,
TooLarge,
LyingLength,
ChecksumMismatch,
OutOfMemory,
};
/// The loaded state. Owns every slice; `deinit` frees the arena.
pub const MirrorState = struct {
arena_state: std.heap.ArenaAllocator,
/// The SOURCE log identity this state was recorded against.
source_key: logstore.LogStore.LogKey,
/// DESIGN §9: the mirror was exported from an S-bearing (provisional
/// fixture) source; re-runs keep announcing the disposable-mirror notice.
provisional_fixture: bool,
records: []const Record,
refs: []const Ref,
/// hash -> index into `records`, built at load for `containsHash`.
by_hash: std.StringHashMapUnmanaged(usize),
pub fn deinit(self: *MirrorState) void {
self.arena_state.deinit();
self.* = undefined;
}
/// The record for `hash`, or null. Pure over the loaded table.
pub fn containsHash(self: *const MirrorState, hash: []const u8) ?Record {
const index = self.by_hash.get(hash) orelse return null;
return self.records[index];
}
/// Whether the LIVE log still contains the RECORDED log as a prefix: the
/// recorded end fits inside the live log AND the live log's frame at the
/// recorded last-frame offset still carries the recorded header CRC. A
/// swapped/rewritten source (pack, shun --force) fails this and the
/// caller falls back to a full export — stale marks must never be
/// trusted against a log they no longer describe.
pub fn isPrefixExtensionOf(recorded: logstore.LogStore.LogKey, live_prefix: logstore.LogStore.LogKey) bool {
if (recorded.end > live_prefix.end) return false;
if (recorded.frame_count > live_prefix.frame_count) return false;
if (recorded.end == live_prefix.end) {
return recorded.frame_count == live_prefix.frame_count and
recorded.last_frame_offset == live_prefix.last_frame_offset and
recorded.last_header_crc == live_prefix.last_header_crc;
}
return true;
}
};
/// Load and validate DEST's state file. Every framing violation is its own
/// error and never a partial load; an absent file is `Absent` (the ordinary
/// first-run condition, distinct from damage).
pub fn load(gpa: std.mem.Allocator, io: std.Io, dest_dir: std.Io.Dir) LoadError!MirrorState {
var file = dest_dir.openFile(io, state_filename, .{}) catch return error.Absent;
defer file.close(io);
const st = file.stat(io) catch return error.Absent;
if (st.size > max_state_bytes) return error.TooLarge;
const size = std.math.cast(usize, st.size) orelse return error.TooLarge;
var arena_state = std.heap.ArenaAllocator.init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
const bytes = arena.alloc(u8, size) catch return error.OutOfMemory;
const n = file.readPositionalAll(io, bytes, 0) catch return error.Truncated;
if (n != size) return error.Truncated;
var r = Cursor{ .bytes = bytes };
const got_magic = r.take(8) orelse return error.Truncated;
if (!std.mem.eql(u8, got_magic, &magic)) return error.WrongMagic;
const version = r.int(u16) orelse return error.Truncated;
if (version != format_version) return error.WrongVersion;
// The self-checksum covers every byte before it; validate BEFORE trusting
// any length field the body carries.
if (bytes.len < 4) return error.Truncated;
const declared_crc = std.mem.readInt(u32, bytes[bytes.len - 4 ..][0..4], .little);
var crc = crc32.Crc32.init();
crc.update(bytes[0 .. bytes.len - 4]);
if (crc.final() != declared_crc) return error.ChecksumMismatch;
const source_key: logstore.LogStore.LogKey = .{
.end = r.int(u64) orelse return error.Truncated,
.frame_count = r.int(u64) orelse return error.Truncated,
.last_frame_offset = r.int(u64) orelse return error.Truncated,
.last_header_crc = r.int(u32) orelse return error.Truncated,
};
const provisional = (r.int(u8) orelse return error.Truncated) != 0;
const record_count = r.int(u64) orelse return error.Truncated;
// A lying count cannot drive allocation: every record is at least this
// many bytes, so the residual body bounds the count.
const min_record_bytes: u64 = 1 + 8 + 1 + 1 + 1;
if (record_count > (bytes.len - r.pos) / min_record_bytes + 1) return error.LyingLength;
const count = std.math.cast(usize, record_count) orelse return error.LyingLength;
const records = arena.alloc(Record, count) catch return error.OutOfMemory;
for (records) |*record| {
record.* = .{
.hash = r.lenPrefixed(u8) orelse return error.Truncated,
.mark = r.int(u64) orelse return error.Truncated,
.oid = r.lenPrefixed(u8) orelse return error.Truncated,
.kind = switch (r.int(u8) orelse return error.Truncated) {
0 => .commit,
1 => .tag,
else => return error.LyingLength,
},
};
}
const ref_count = r.int(u64) orelse return error.Truncated;
if (ref_count > (bytes.len - r.pos) / 2 + 1) return error.LyingLength;
const n_refs = std.math.cast(usize, ref_count) orelse return error.LyingLength;
const refs = arena.alloc(Ref, n_refs) catch return error.OutOfMemory;
for (refs) |*ref| {
ref.* = .{
.name = r.lenPrefixed(u16) orelse return error.Truncated,
.oid = r.lenPrefixed(u8) orelse return error.Truncated,
};
}
if (r.pos != bytes.len - 4) return error.LyingLength; // exact accounting
var by_hash: std.StringHashMapUnmanaged(usize) = .empty;
for (records, 0..) |record, index| {
by_hash.put(arena, record.hash, index) catch return error.OutOfMemory;
}
return .{
.arena_state = arena_state,
.source_key = source_key,
.provisional_fixture = provisional,
.records = records,
.refs = refs,
.by_hash = by_hash,
};
}
/// Serialize and atomically publish the state: temp file inside the sidecar
/// directory, sync, rename over the member. Returns whether the publish
/// LANDED — the state is the export's commit point, so the caller treats
/// false as the whole run not having happened.
pub fn store(
gpa: std.mem.Allocator,
io: std.Io,
dest_dir: std.Io.Dir,
source_key: logstore.LogStore.LogKey,
provisional_fixture: bool,
records: []const Record,
refs: []const Ref,
) bool {
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
serialize(gpa, &body, source_key, provisional_fixture, records, refs) catch return false;
var af = dest_dir.createFileAtomic(io, state_filename, .{ .replace = true }) catch |first_err| retry: {
// The ordinary first-run miss: the sidecar directory does not exist
// yet. Create it and retry once — the repocache idiom.
if (first_err != error.FileNotFound) return false;
dest_dir.createDir(io, state_dirname, .default_dir) catch |mk| if (mk != error.PathAlreadyExists) return false;
break :retry dest_dir.createFileAtomic(io, state_filename, .{ .replace = true }) catch return false;
};
defer af.deinit(io);
af.file.writePositionalAll(io, body.items, 0) catch return false;
af.file.sync(io) catch return false;
af.replace(io) catch return false;
return true;
}
fn serialize(
gpa: std.mem.Allocator,
out: *std.ArrayList(u8),
source_key: logstore.LogStore.LogKey,
provisional_fixture: bool,
records: []const Record,
refs: []const Ref,
) error{OutOfMemory}!void {
try out.appendSlice(gpa, &magic);
try appendInt(gpa, out, u16, format_version);
try appendInt(gpa, out, u64, source_key.end);
try appendInt(gpa, out, u64, source_key.frame_count);
try appendInt(gpa, out, u64, source_key.last_frame_offset);
try appendInt(gpa, out, u32, source_key.last_header_crc);
try out.append(gpa, @intFromBool(provisional_fixture));
try appendInt(gpa, out, u64, records.len);
for (records) |record| {
std.debug.assert(record.hash.len == 40 or record.hash.len == 64);
std.debug.assert(record.oid.len <= oid_hex_max);
try appendLenPrefixed(gpa, out, u8, record.hash);
try appendInt(gpa, out, u64, record.mark);
try appendLenPrefixed(gpa, out, u8, record.oid);
try out.append(gpa, @intFromEnum(record.kind));
}
try appendInt(gpa, out, u64, refs.len);
for (refs) |ref| {
try appendLenPrefixed(gpa, out, u16, ref.name);
try appendLenPrefixed(gpa, out, u8, ref.oid);
}
var crc = crc32.Crc32.init();
crc.update(out.items);
try appendInt(gpa, out, u32, crc.final());
}
fn appendInt(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime T: type, v: T) error{OutOfMemory}!void {
var buf: [@sizeOf(T)]u8 = undefined;
std.mem.writeInt(T, &buf, v, .little);
try out.appendSlice(gpa, &buf);
}
fn appendLenPrefixed(gpa: std.mem.Allocator, out: *std.ArrayList(u8), comptime L: type, bytes: []const u8) error{OutOfMemory}!void {
std.debug.assert(bytes.len <= std.math.maxInt(L));
try appendInt(gpa, out, L, @intCast(bytes.len));
try out.appendSlice(gpa, bytes);
}
/// Bounds-checked cursor: every read validates the remaining bytes first, so
/// a truncated or lying body yields null rather than an out-of-bounds slice.
const Cursor = struct {
bytes: []const u8,
pos: usize = 0,
fn take(self: *Cursor, n: usize) ?[]const u8 {
if (self.pos + n > self.bytes.len) return null;
const out = self.bytes[self.pos..][0..n];
self.pos += n;
return out;
}
fn int(self: *Cursor, comptime T: type) ?T {
const raw = self.take(@sizeOf(T)) orelse return null;
return std.mem.readInt(T, raw[0..@sizeOf(T)], .little);
}
fn lenPrefixed(self: *Cursor, comptime L: type) ?[]const u8 {
const len: usize = self.int(L) orelse return null;
return self.take(len);
}
};