Skip to content
zit Artifact [c73901b29e]
Login

Artifact [c73901b29e]

Artifact c73901b29eafde4e5995c7f57b0f296375be46b3cdbdf30f4c7a851dc0a0495a:


//! `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, @backingInt(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);
    }
};