//! Storage plan 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 item 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 (plan E).
const std = @import("std");
const crc32 = @import("crc32.zig");
/// 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 };
/// `CheckoutError` plus the allocation a variable-size section read needs.
pub const SectionError = error{ Storage, Corrupt, OutOfMemory };
/// 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: five length-prefixed strings.
pub const max_ck_payload: usize = 5 * (2 + max_ck_str); // = 1285
/// The manifest-file selectors, in canonical order: `r` the check-in artifact,
/// `u` its hash, `t` its symbolic tags. A `manifest_flags` value is a subset of
/// these, deduplicated and in this order; the empty string means "write none",
/// which is the default.
pub const manifest_selectors: []const u8 = "rut";
/// 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,
/// The pending special-merge operation (cherry-pick / backout), recorded as
/// the q-sign byte (`+` cherry-pick, `-` backout) concatenated with the
/// target check-in's artifact hash, or empty when none is pending. A special
/// merge and a plain merge are mutually exclusive: at most one of
/// `merge_parent` / `special_op` is ever non-empty. Like `merge_parent`, it
/// has NO default so no reconstruction silently drops a pending operation.
special_op: []const u8,
/// Which working-tree manifest files this checkout wants refreshed on every
/// materialization: a subset of `manifest_selectors`, empty for none. Set
/// once by `open --manifest`, then carried forward by every later `update`
/// — so, like the two fields above, it has NO default: a reconstruction
/// that forgets it would silently switch the files off.
manifest_flags: []const u8,
};
// ---------------------------------------------------------------------------
// Container v2 (consolidation DESIGN §5.1). `.zit-checkout` is a sectioned
// container holding every authoritative checkout-local record:
//
// magic(8)="ZITCKOT\x00" | fmt_version(u16 LE)=2 | section_count(u8) |
// reserved(u8)=0 | table: count × { tag(u8) | len(u64 LE) } |
// payloads in table order | crc32 LE (4) over every earlier byte
//
// Section payloads are the EXISTING serialized forms of the absorbed files,
// unchanged — the container adds only framing. Sections are optional; tags in
// the table are strictly ascending (canonical order, which makes a duplicate
// unrepresentable); an unknown tag, a zero length, a nonzero reserved byte, a
// version mismatch, a table/size mismatch or a crc mismatch are all
// `error.Corrupt` — authoritative state is never best-effort parsed.
//
// The legacy single-record `.zit-checkout` begins with a u16 length ≤ 255, so
// its byte 1 is always 0x00, while the container's byte 1 is 'I': byte-1
// dispatch (§8). On a legacy read the sibling files are folded in through
// their raw bytes; once a container exists the siblings are DEAD — never
// read, deleted best-effort after every container write.
// ---------------------------------------------------------------------------
pub const container_magic: []const u8 = "ZITCKOT\x00";
/// v3 (sparse-cone RFC + issue #59): adds the `cone` and `store_location`
/// tags. Written always; v2 is read-accepted and migrates to v3 on the first
/// splice, exactly as v2 folds the legacy siblings. A v2 binary meeting a v3
/// container refuses loudly at the version gate rather than misparsing.
/// v4 (sparse-cone RFC section 4.4): adds `merge_overlay` -- the resolved
/// out-of-cone F-card set a cross-cone merge records for the next commit's
/// compose. Same fold-forward/refuse-backward discipline as v3's bump.
pub const container_version: u16 = 4;
/// The newest version whose sections this reader folds forward.
pub const container_version_min_read: u16 = 2;
/// The absorbed records, one tag each. Payload formats stay owned by their
/// modules; this file frames bytes it never interprets (except tag 1, its own).
/// `cone` (sparse-cone RFC section 3) is owned by `cone.zig`; `store_location`
/// is RESERVED for issue #59 (worktrees): parse-accepted, carried verbatim
/// through every splice, never interpreted here or anywhere yet.
pub const SectionTag = enum(u8) { checkout = 1, managed = 2, undo = 3, bisect = 4, cone = 5, store_location = 6, merge_overlay = 7 };
/// One splice instruction for `updateSections`: replace the tagged section's
/// payload, or remove the section entirely (`payload = null`).
pub const SectionUpdate = struct {
tag: SectionTag,
payload: ?[]const u8,
};
const container_header_len: usize = 12; // magic 8 + version 2 + count 1 + reserved 1
const table_entry_len: usize = 9; // tag u8 + len u64 LE
const max_sections: usize = 7;
/// Tag ceiling per container version: v2 wrote tags 1..4 only, and a v2 table
/// carrying a higher tag is corrupt, not forward-compatible.
fn tagLimit(version: u16) usize {
std.debug.assert(version >= container_version_min_read);
std.debug.assert(version <= container_version);
return switch (version) {
2 => 4,
3 => 6,
else => max_sections,
};
}
const max_prefix_len: usize = container_header_len + max_sections * table_entry_len; // 75
const crc_len: usize = 4;
/// The legacy per-record files a pre-container checkout carries, indexed by
/// tag. Names are deliberate literals: this module frames payloads without
/// importing the modules that own them (the registry's generated pin test in
/// `control_paths.zig` is the drift guard, per the consolidation design §9).
fn legacyName(tag: SectionTag) ?[]const u8 {
return switch (tag) {
.checkout => filename,
.managed => ".zit-managed",
.undo => ".zit-undo",
.bisect => ".zit-bisect",
// v3/v4 tags: born inside the container, no legacy sibling existed.
.cone, .store_location, .merge_overlay => null,
};
}
const Extent = struct { offset: u64, len: u64 };
/// A validated container prefix: which sections exist and where. Constructed
/// only by `parsePrefix`, which has already proven the table is canonical and
/// exactly accounts for the file size — so extents derived here are backed by
/// real bytes, never by a declared length alone.
const Table = struct {
lens: [max_sections]u64, // indexed by tag-1; 0 = absent (a real section is never empty)
payload_start: u64,
total_len: u64,
fn extent(self: *const Table, tag: SectionTag) ?Extent {
var offset: u64 = self.payload_start;
for (self.lens, 1..) |len, t| {
if (t == @intFromEnum(tag)) {
if (len == 0) return null;
return .{ .offset = offset, .len = len };
}
offset += len;
}
unreachable; // every SectionTag value indexes the loop
}
};
/// Byte-1 dispatch (§8): the container's second byte is 'I'; the legacy
/// record's second byte is the high byte of a u16 length ≤ 255, always 0x00.
fn isContainer(prefix: []const u8) bool {
return prefix.len >= 2 and prefix[1] == container_magic[1];
}
/// Validates a container's header + section table against the stat'd file
/// size. Closed parsing: every violation is `error.Corrupt`, and the byte
/// accounting must be EXACT — the table's sections plus framing must equal the
/// file size, so neither a truncated payload nor trailing garbage survives to
/// the crc check.
fn parsePrefix(prefix: []const u8, file_size: u64) error{Corrupt}!Table {
if (prefix.len < container_header_len) return error.Corrupt;
if (!std.mem.eql(u8, prefix[0..container_magic.len], container_magic)) return error.Corrupt;
const version = std.mem.readInt(u16, prefix[8..10], .little);
if (version < container_version_min_read) return error.Corrupt;
if (version > container_version) return error.Corrupt;
const tag_limit = tagLimit(version);
const count: usize = prefix[10];
if (count == 0 or count > tag_limit) return error.Corrupt; // a writer never emits an empty container
if (prefix[11] != 0) return error.Corrupt;
const table_end = container_header_len + count * table_entry_len;
if (prefix.len < table_end) return error.Corrupt;
var table: Table = .{
.lens = @splat(0),
.payload_start = table_end,
.total_len = 0,
};
var payload_sum: u64 = 0;
var prev_tag: u8 = 0;
for (0..count) |i| {
const entry = prefix[container_header_len + i * table_entry_len ..][0..table_entry_len];
const tag = entry[0];
// Strictly ascending known tags: canonical order, and a duplicate or
// unknown tag is unrepresentable rather than merely rejected.
if (tag <= prev_tag or tag > tag_limit) return error.Corrupt;
prev_tag = tag;
const len = std.mem.readInt(u64, entry[1..9], .little);
if (len == 0) return error.Corrupt; // absent is "not in the table", never a zero-length section
if (len > file_size or payload_sum > file_size - len) return error.Corrupt;
payload_sum += len;
table.lens[tag - 1] = len;
}
const framing: u64 = @intCast(table_end + crc_len);
if (payload_sum > file_size or file_size - payload_sum != framing) return error.Corrupt;
table.total_len = file_size;
return table;
}
/// One streaming pass over a validated container: computes the whole-file crc
/// (over every byte before the trailing 4) and, when `want` is set, copies
/// that extent's bytes into `dest` as they stream past. The copy is returned
/// to the caller ONLY if the trailing crc matches — no section is trusted
/// before the whole file proves intact.
fn verifyStream(io: std.Io, file: std.Io.File, table: Table, want: ?Extent, dest: []u8) CheckoutError!void {
if (want) |w| std.debug.assert(dest.len >= w.len);
var crc = crc32.Crc32.init();
const body_len = table.total_len - crc_len;
var pos: u64 = 0;
var chunk: [4096]u8 = undefined;
while (pos < body_len) {
const take: usize = @intCast(@min(chunk.len, body_len - pos));
const n = file.readPositionalAll(io, chunk[0..take], pos) catch return error.Storage;
if (n != take) return error.Storage; // size came from stat; a short read is device trouble, not framing
crc.update(chunk[0..take]);
if (want) |w| {
const lo = @max(pos, w.offset);
const hi = @min(pos + take, w.offset + w.len);
if (lo < hi) {
const src = chunk[@intCast(lo - pos)..@intCast(hi - pos)];
@memcpy(dest[@intCast(lo - w.offset)..][0..src.len], src);
}
}
pos += take;
}
var stored: [crc_len]u8 = undefined;
const n = file.readPositionalAll(io, &stored, body_len) catch return error.Storage;
if (n != crc_len) return error.Storage;
if (std.mem.readInt(u32, &stored, .little) != crc.final()) return error.Corrupt;
}
/// Reads one section's raw payload, dispatching on the file generation:
/// container reads stream-verify the whole-file crc while extracting exactly
/// the requested extent; a legacy checkout reads the corresponding sibling
/// file raw (the payload formats are identical, so the sibling's bytes ARE the
/// section). Returns null when the section (or the whole file) is absent.
/// The caller owns the returned bytes.
pub fn readSectionAlloc(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, tag: SectionTag) SectionError!?[]u8 {
var file = dir.openFile(io, filename, .{}) catch |e| switch (e) {
error.FileNotFound => return legacyReadAlloc(gpa, io, dir, tag),
else => return error.Storage,
};
defer file.close(io);
const st = file.stat(io) catch return error.Storage;
var prefix: [max_prefix_len]u8 = undefined;
const pn: usize = @intCast(@min(prefix.len, st.size));
const got = file.readPositionalAll(io, prefix[0..pn], 0) catch return error.Storage;
if (got != pn) return error.Storage;
if (!isContainer(prefix[0..pn])) {
// Pre-container checkout: the requested record lives in its own file.
// For tag 1 that file is this one; re-read it raw below either way.
return legacyReadAlloc(gpa, io, dir, tag);
}
const table = try parsePrefix(prefix[0..pn], st.size);
const ext = table.extent(tag) orelse {
// Absence is also a statement the crc must back before it is trusted.
try verifyStream(io, file, table, null, &.{});
return null;
};
const len = std.math.cast(usize, ext.len) orelse return error.OutOfMemory;
const buf = gpa.alloc(u8, len) catch return error.OutOfMemory;
errdefer gpa.free(buf);
try verifyStream(io, file, table, ext, buf);
return buf;
}
/// The legacy-generation read behind `readSectionAlloc`: the tagged record's
/// own file, whole, sized from stat (the journal has no format-level size cap
/// by design — see `undo.zig`'s `readJournalFile` rationale, which this
/// inherits). Absent file = absent section.
fn legacyReadAlloc(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, tag: SectionTag) SectionError!?[]u8 {
const name = legacyName(tag) orelse return null;
var file = dir.openFile(io, name, .{}) catch |e| switch (e) {
error.FileNotFound => return null,
else => return error.Storage,
};
defer file.close(io);
const st = file.stat(io) catch return error.Storage;
const size = std.math.cast(usize, st.size) orelse return error.OutOfMemory;
const buf = gpa.alloc(u8, size) catch return error.OutOfMemory;
errdefer gpa.free(buf);
const n = file.readPositionalAll(io, buf, 0) catch return error.Storage;
if (n != size) return error.Storage;
return buf;
}
/// Where a carried-forward section's bytes come from during a splice.
const CarrySource = union(enum) {
omit,
/// Caller-supplied replacement payload.
payload: []const u8,
/// Copy out of the (crc-verified) old container.
old: Extent,
/// Legacy fold: stream the sibling file's raw bytes in (§8). The handle is
/// opened during resolution and closed by the splice's cleanup.
sibling: struct { file: std.Io.File, len: u64 },
};
/// Read-splice-write for the authoritative container. Resolves every
/// section's source (caller update > old container > legacy sibling), then
/// materializes the new container through the same write-temp + fsync +
/// rename-replace sequence every authoritative writer uses. After a
/// successful rename the legacy sibling files are dead and are deleted
/// best-effort; removing the last section deletes the container itself, so
/// "no checkout state" stays spelled "no file".
///
/// An existing container's crc is verified BEFORE any of its sections are
/// carried forward; an existing legacy record is gated through `parseCk`
/// before adoption. Allocation-free: bounded stack buffers and streamed
/// copies only, which is what lets `write`/`read` keep their signatures.
pub fn updateSections(io: std.Io, dir: std.Io.Dir, updates: []const SectionUpdate) CheckoutError!void {
// Caller contract, checked pairwise (which also bounds the list: five or
// more updates cannot all name distinct tags). No assert names the slice
// parameter itself — the release_assert gate's rule. An EMPTY update list
// is well-defined: a pure rewrite, which on a legacy checkout is exactly
// "migrate to the container".
for (updates, 0..) |u, i| {
for (updates[i + 1 ..]) |v| std.debug.assert(u.tag != v.tag);
if (u.payload) |p| std.debug.assert(p.len > 0); // empty ≠ absent; absence is payload = null
}
const old_file: ?std.Io.File = dir.openFile(io, filename, .{}) catch |e| switch (e) {
error.FileNotFound => null,
else => return error.Storage,
};
defer if (old_file) |f| f.close(io);
// Old-generation state: exactly one of these is set when the file exists.
var old_table: ?Table = null;
var legacy_ck_buf: [max_ck_payload + 1]u8 = undefined;
var legacy_ck: ?[]const u8 = null;
if (old_file) |f| {
const st = f.stat(io) catch return error.Storage;
var prefix: [max_prefix_len]u8 = undefined;
const pn: usize = @intCast(@min(prefix.len, st.size));
const got = f.readPositionalAll(io, prefix[0..pn], 0) catch return error.Storage;
if (got != pn) return error.Storage;
if (isContainer(prefix[0..pn])) {
const table = try parsePrefix(prefix[0..pn], st.size);
try verifyStream(io, f, table, null, &.{}); // trust nothing the crc has not backed
old_table = table;
} else {
if (st.size > max_ck_payload) return error.Corrupt;
const n = f.readPositionalAll(io, legacy_ck_buf[0..@as(usize, @intCast(st.size))], 0) catch return error.Storage;
if (n != st.size) return error.Storage;
_ = try parseCk(legacy_ck_buf[0..n]); // structural gate before adoption
legacy_ck = legacy_ck_buf[0..n];
}
}
// Resolve each tag's source. Sibling handles opened here are closed by the
// deferred sweep below whether the splice commits or fails.
var sources: [max_sections]CarrySource = @splat(.omit);
defer for (&sources) |*s| {
if (s.* == .sibling) s.sibling.file.close(io);
};
resolve: for (1..max_sections + 1) |t| {
const tag: SectionTag = @enumFromInt(t);
for (updates) |u| {
if (u.tag == tag) {
if (u.payload) |p| sources[t - 1] = .{ .payload = p };
continue :resolve;
}
}
if (old_table) |table| {
// Container generation: the siblings are dead (§8) — carry only
// what the container itself holds.
if (table.extent(tag)) |ext| sources[t - 1] = .{ .old = ext };
continue :resolve;
}
if (tag == .checkout) {
if (legacy_ck) |ck| sources[t - 1] = .{ .payload = ck };
continue :resolve;
}
const sibling_name = legacyName(tag) orelse continue :resolve;
var sf = dir.openFile(io, sibling_name, .{}) catch |e| switch (e) {
error.FileNotFound => continue :resolve,
else => return error.Storage,
};
const st = sf.stat(io) catch {
sf.close(io);
return error.Storage;
};
if (st.size == 0) {
// A zero-byte record is corrupt in every absorbed format; refuse
// to launder it into a valid-looking absence mid-migration.
sf.close(io);
return error.Corrupt;
}
sources[t - 1] = .{ .sibling = .{ .file = sf, .len = st.size } };
}
var count: usize = 0;
for (sources) |s| {
if (s != .omit) count += 1;
}
if (count == 0) {
dir.deleteFile(io, filename) catch |e| switch (e) {
error.FileNotFound => {},
else => return error.Storage,
};
deleteLegacySiblings(io, dir);
return;
}
var af = dir.createFileAtomic(io, filename, .{ .replace = true }) catch return error.Storage;
defer af.deinit(io);
var crc = crc32.Crc32.init();
var pos: u64 = 0;
var pbuf: [max_prefix_len]u8 = undefined;
@memcpy(pbuf[0..container_magic.len], container_magic);
std.mem.writeInt(u16, pbuf[8..10], container_version, .little);
pbuf[10] = @intCast(count);
pbuf[11] = 0;
var off: usize = container_header_len;
for (sources, 1..) |s, t| {
const len: u64 = switch (s) {
.omit => continue,
.payload => |p| p.len,
.old => |ext| ext.len,
.sibling => |sib| sib.len,
};
pbuf[off] = @intCast(t);
std.mem.writeInt(u64, pbuf[off + 1 ..][0..8], len, .little);
off += table_entry_len;
}
try appendChunk(io, af.file, &crc, &pos, pbuf[0..off]);
for (sources) |s| switch (s) {
.omit => {},
.payload => |p| try appendChunk(io, af.file, &crc, &pos, p),
.old => |ext| try appendCopy(io, af.file, &crc, &pos, old_file.?, ext.offset, ext.len),
.sibling => |sib| try appendCopy(io, af.file, &crc, &pos, sib.file, 0, sib.len),
};
var trailer: [crc_len]u8 = undefined;
std.mem.writeInt(u32, &trailer, crc.final(), .little);
af.file.writePositionalAll(io, &trailer, pos) 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
// The container is durable; whatever the siblings say is now dead (§8).
// A crash before these deletes leaves both, and the byte-1 dispatch above
// keeps ignoring the siblings — the deletes are cleanup, not correctness.
deleteLegacySiblings(io, dir);
}
fn deleteLegacySiblings(io: std.Io, dir: std.Io.Dir) void {
dir.deleteFile(io, legacyName(.managed).?) catch {};
dir.deleteFile(io, legacyName(.undo).?) catch {};
dir.deleteFile(io, legacyName(.bisect).?) catch {};
}
fn appendChunk(io: std.Io, file: std.Io.File, crc: *crc32.Crc32, pos: *u64, bytes: []const u8) CheckoutError!void {
file.writePositionalAll(io, bytes, pos.*) catch return error.Storage;
crc.update(bytes);
pos.* += bytes.len;
}
fn appendCopy(io: std.Io, dst: std.Io.File, crc: *crc32.Crc32, pos: *u64, src: std.Io.File, src_offset: u64, len: u64) CheckoutError!void {
var chunk: [4096]u8 = undefined;
var copied: u64 = 0;
while (copied < len) {
const take: usize = @intCast(@min(chunk.len, len - copied));
const n = src.readPositionalAll(io, chunk[0..take], src_offset + copied) catch return error.Storage;
if (n != take) return error.Storage; // len came from a verified table or stat
try appendChunk(io, dst, crc, pos, chunk[0..take]);
copied += take;
}
}
/// Materializes `state` atomically as the container's checkout section,
/// carrying every other section forward (read-splice-write; §5.1). A legacy
/// checkout is migrated to the container by this first write. Data is fsync'd
/// before the rename so the record is durable the instant it becomes visible;
/// a crash mid-write leaves the prior generation intact.
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);
std.debug.assert(state.special_op.len <= max_ck_str);
std.debug.assert(state.manifest_flags.len <= max_ck_str);
var buf: [max_ck_payload]u8 = undefined;
const bytes = encodeCk(&buf, state);
return updateSections(io, dir, &.{.{ .tag = .checkout, .payload = bytes }});
}
/// Reads and parses the checkout record 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 legacy file be detected rather than silently
/// truncated to a valid-looking maximal prefix. Byte-1 dispatch: a container
/// stream-verifies its whole-file crc while extracting the checkout section
/// (still allocation-free — the section is bounded); a legacy record parses
/// as before. Returns null when no checkout file exists, or when a container
/// exists but carries no checkout section (`FileNotFound` is absence, not an
/// error, plan E). A malformed record is `error.Corrupt`; any other I/O
/// failure is `error.Storage`. The result's slices borrow `out_buf`.
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 st = file.stat(io) catch return error.Storage;
var prefix: [max_prefix_len]u8 = undefined;
const pn: usize = @intCast(@min(prefix.len, st.size));
const got = file.readPositionalAll(io, prefix[0..pn], 0) catch return error.Storage;
if (got != pn) return error.Storage;
if (isContainer(prefix[0..pn])) {
const table = try parsePrefix(prefix[0..pn], st.size);
const ext = table.extent(.checkout) orelse {
try verifyStream(io, file, table, null, &.{}); // absence is trusted only past the crc
return null;
};
if (ext.len > max_ck_payload) return error.Corrupt;
try verifyStream(io, file, table, ext, out_buf);
return try parseCk(out_buf[0..@intCast(ext.len)]);
}
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][special_op_len u16 LE]
/// [special_op][manifest_flags_len u16 LE][manifest_flags]` and returns the
/// written slice. Five TLVs are ALWAYS emitted (an empty field is `[00 00]`).
/// Fixed bounds, no unbounded copy. Public so a caller composing a
/// multi-section `updateSections` splice (undo's restore, update's commit
/// point) can supply the checkout payload alongside its siblings.
pub 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);
std.debug.assert(s.special_op.len <= max_ck_str);
std.debug.assert(s.manifest_flags.len <= max_ck_str);
const vl: usize = s.version.len;
const bl: usize = s.branch.len;
const ml: usize = s.merge_parent.len;
const sl: usize = s.special_op.len;
const fl: usize = s.manifest_flags.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);
std.mem.writeInt(u16, buf[6 + vl + bl + ml ..][0..2], @intCast(sl), .little);
@memcpy(buf[8 + vl + bl + ml ..][0..sl], s.special_op);
std.mem.writeInt(u16, buf[8 + vl + bl + ml + sl ..][0..2], @intCast(fl), .little);
@memcpy(buf[10 + vl + bl + ml + sl ..][0..fl], s.manifest_flags);
return buf[0 .. 10 + vl + bl + ml + sl + fl];
}
/// 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;
}
// Fourth TLV (special_op) is likewise read WHEN PRESENT. A three-field
// record written before special_op existed ends here and parses as "no
// pending operation" — same errors-out-of-existence tolerance as above.
var special_op: []const u8 = "";
if (pos != payload.len) {
if (payload.len - pos < 2) return error.Corrupt;
const slen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += 2;
if (slen > max_ck_str) return error.Corrupt;
if (payload.len - pos < slen) return error.Corrupt;
special_op = payload[pos..][0..slen];
pos += slen;
}
// Fifth TLV (manifest_flags), same when-present tolerance: a record written
// before the manifest opt-in existed ends here and parses as "write no
// manifest files" — which is exactly what such a checkout was doing.
var manifest_flags: []const u8 = "";
if (pos != payload.len) {
if (payload.len - pos < 2) return error.Corrupt;
const flen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += 2;
if (flen > max_ck_str) return error.Corrupt;
if (payload.len - pos < flen) return error.Corrupt;
manifest_flags = payload[pos..][0..flen];
pos += flen;
}
if (pos != payload.len) return error.Corrupt; // no trailing bytes
return .{
.version = version,
.branch = branch,
.merge_parent = merge_parent,
.special_op = special_op,
.manifest_flags = manifest_flags,
};
}
// ---------------------------------------------------------------------------
// Tests (storage-track-plan.md item E; no RFC section behind this file).
// ---------------------------------------------------------------------------
test "checkout plan E: write then read round-trips version branch merge_parent and special_op" {
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, .special_op = "", .manifest_flags = "" };
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);
try std.testing.expectEqualStrings("", got.special_op);
// A pending special-merge op round-trips independently of merge_parent.
const cp: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "+" ++ "d" ** 64, .manifest_flags = "" };
try write(io, tmp.dir, cp);
const got2 = (try read(io, tmp.dir, &buf)).?;
try std.testing.expectEqualStrings("", got2.merge_parent);
try std.testing.expectEqualStrings("+" ++ "d" ** 64, got2.special_op);
// The manifest opt-in round-trips independently of every other field.
const mf: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "rut" };
try write(io, tmp.dir, mf);
const got3 = (try read(io, tmp.dir, &buf)).?;
try std.testing.expectEqualStrings("rut", got3.manifest_flags);
}
test "checkout plan E: an empty merge_parent and special_op round-trip as no pending op" {
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 = "", .special_op = "", .manifest_flags = "" });
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);
try std.testing.expectEqualStrings("", got.special_op);
}
test "checkout plan E: legacy two-field and three-field records parse with empty trailing fields" {
// A record written before merge_parent existed carries only version+branch
// and ends after the branch. parseCk must yield merge_parent/special_op ==
// "" rather than reject it — errors-out-of-existence, §E.
const legacy2 = [_]u8{ 0x01, 0x00, 'v', 0x05, 0x00, 't', 'r', 'u', 'n', 'k' };
const got2 = try parseCk(&legacy2);
try std.testing.expectEqualStrings("v", got2.version);
try std.testing.expectEqualStrings("trunk", got2.branch);
try std.testing.expectEqualStrings("", got2.merge_parent);
try std.testing.expectEqualStrings("", got2.special_op);
try std.testing.expectEqualStrings("", got2.manifest_flags);
// A three-field record (version+branch+merge_parent, no special_op) parses
// with special_op == "".
const legacy3 = [_]u8{ 0x01, 0x00, 'v', 0x05, 0x00, 't', 'r', 'u', 'n', 'k', 0x01, 0x00, 'm' };
const got3 = try parseCk(&legacy3);
try std.testing.expectEqualStrings("m", got3.merge_parent);
try std.testing.expectEqualStrings("", got3.special_op);
try std.testing.expectEqualStrings("", got3.manifest_flags);
// A four-field record (no manifest_flags) parses as "write no manifest
// files" — the behaviour such a checkout already had.
const legacy4 = [_]u8{ 0x01, 0x00, 'v', 0x05, 0x00, 't', 'r', 'u', 'n', 'k', 0x00, 0x00, 0x00, 0x00 };
const got4 = try parseCk(&legacy4);
try std.testing.expectEqualStrings("v", got4.version);
try std.testing.expectEqualStrings("", got4.manifest_flags);
}
test "checkout plan 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 = "", .special_op = "", .manifest_flags = "" });
try write(io, tmp.dir, .{ .version = "2" ** 40, .branch = "release", .merge_parent = "", .special_op = "", .manifest_flags = "" });
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 plan 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 plan 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 = "", .special_op = "", .manifest_flags = "" });
// 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 = "", .special_op = "", .manifest_flags = "" });
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 plan E: parseCk rejects malformed checkout records" {
// TLV bounds: one rejection per condition (storage-track-plan.md item 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 }, // truncated special_op_len after merge_parent
&[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 'a' }, // special_op_len 5 exceeds remaining
&[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09 }, // truncated manifest_flags_len after special_op
&[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 'a' }, // manifest_flags_len 5 exceeds remaining
&[_]u8{ 0x01, 0x00, 'v', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09 }, // trailing byte after manifest_flags
};
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 plan 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
// (four 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, .special_op = "s" ** max_ck_str, .manifest_flags = "f" ** 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);
try std.testing.expectEqual(@as(usize, max_ck_str), ok.special_op.len);
try std.testing.expectEqual(@as(usize, max_ck_str), ok.manifest_flags.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, .special_op = "s" ** max_ck_str, .manifest_flags = "f" ** 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));
}
// ---------------------------------------------------------------------------
// Container v2 tests (consolidation DESIGN §5.1). Two invariants, modelled on
// the repocache suite: a valid container round-trips every section
// byte-exactly, and every framing violation is error.Corrupt — never partial
// trust.
// ---------------------------------------------------------------------------
fn writeRawTestFile(io: std.Io, dir: std.Io.Dir, name: []const u8, bytes: []const u8) !void {
var f = try dir.createFile(io, name, .{ .truncate = true });
defer f.close(io);
try f.writePositionalAll(io, bytes, 0);
}
fn testFileExists(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
var f = dir.openFile(io, name, .{}) catch return false;
f.close(io);
return true;
}
test "container: updateSections round-trips every section byte-exactly" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
const gpa = std.testing.allocator;
const state: CheckoutState = .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" };
try write(io, tmp.dir, state);
const managed_payload = "\x01\x00\x00\x00" ++ "\x05\x00" ++ "a.txt"; // any bytes: the container frames, never interprets
const undo_payload = "UNDO-JOURNAL-BYTES";
const bisect_payload = "BISECT-RECORD";
try updateSections(io, tmp.dir, &.{
.{ .tag = .managed, .payload = managed_payload },
.{ .tag = .undo, .payload = undo_payload },
.{ .tag = .bisect, .payload = bisect_payload },
});
// The file is a container (byte-1 dispatch) and every section reads back
// byte-for-byte, including the checkout carried across the second splice.
const raw = try tmp.dir.readFileAlloc(io, filename, gpa, .limited(1 << 16));
defer gpa.free(raw);
try std.testing.expect(raw.len > container_magic.len);
try std.testing.expectEqualSlices(u8, container_magic, raw[0..container_magic.len]);
try std.testing.expect(isContainer(raw));
var ckbuf: [max_ck_payload + 1]u8 = undefined;
const got = (try read(io, tmp.dir, &ckbuf)).?;
try std.testing.expectEqualStrings(state.version, got.version);
try std.testing.expectEqualStrings(state.branch, got.branch);
inline for (.{ .{ SectionTag.managed, managed_payload }, .{ SectionTag.undo, undo_payload }, .{ SectionTag.bisect, bisect_payload } }) |pair| {
const bytes = (try readSectionAlloc(gpa, io, tmp.dir, pair[0])).?;
defer gpa.free(bytes);
try std.testing.expectEqualSlices(u8, pair[1], bytes);
}
// Replacing one section leaves the others byte-identical.
try updateSections(io, tmp.dir, &.{.{ .tag = .undo, .payload = "SWAPPED" }});
const undo2 = (try readSectionAlloc(gpa, io, tmp.dir, .undo)).?;
defer gpa.free(undo2);
try std.testing.expectEqualStrings("SWAPPED", undo2);
const managed2 = (try readSectionAlloc(gpa, io, tmp.dir, .managed)).?;
defer gpa.free(managed2);
try std.testing.expectEqualSlices(u8, managed_payload, managed2);
const got2 = (try read(io, tmp.dir, &ckbuf)).?;
try std.testing.expectEqualStrings(state.version, got2.version);
}
test "container: absent file and absent section both read as null" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
const gpa = std.testing.allocator;
try std.testing.expectEqual(@as(?[]u8, null), try readSectionAlloc(gpa, io, tmp.dir, .undo));
try write(io, tmp.dir, .{ .version = "v", .branch = "b", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try std.testing.expectEqual(@as(?[]u8, null), try readSectionAlloc(gpa, io, tmp.dir, .bisect));
}
test "container: removing the last section deletes the file" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
try write(io, tmp.dir, .{ .version = "v", .branch = "b", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try updateSections(io, tmp.dir, &.{.{ .tag = .checkout, .payload = null }});
var buf: [max_ck_payload + 1]u8 = undefined;
try std.testing.expectEqual(@as(?CheckoutState, null), try read(io, tmp.dir, &buf));
// Not just "no checkout section" — the file itself is gone.
try std.testing.expect(!testFileExists(io, tmp.dir, filename));
}
test "container: every framing violation is Corrupt, never partial trust" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
const gpa = std.testing.allocator;
try write(io, tmp.dir, .{ .version = "v" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try updateSections(io, tmp.dir, &.{.{ .tag = .undo, .payload = "JOURNAL" }});
const good = try tmp.dir.readFileAlloc(io, filename, gpa, .limited(1 << 16));
defer gpa.free(good);
const Case = struct { name: []const u8, mutate: *const fn (bytes: []u8) void };
const cases = [_]Case{
.{ .name = "wrong magic", .mutate = struct {
fn f(b: []u8) void {
b[0] = 'X';
}
}.f },
.{ .name = "wrong version", .mutate = struct {
fn f(b: []u8) void {
b[8] = 99;
}
}.f },
.{ .name = "nonzero reserved", .mutate = struct {
fn f(b: []u8) void {
b[11] = 1;
}
}.f },
.{ .name = "zero section count", .mutate = struct {
fn f(b: []u8) void {
b[10] = 0;
}
}.f },
.{ .name = "oversized section count", .mutate = struct {
fn f(b: []u8) void {
b[10] = 5;
}
}.f },
.{
.name = "unknown tag",
.mutate = struct {
fn f(b: []u8) void {
b[container_header_len] = 7; // first table entry's tag
}
}.f,
},
.{
.name = "duplicate tag (breaks strict ascent)",
.mutate = struct {
fn f(b: []u8) void {
b[container_header_len + table_entry_len] = b[container_header_len]; // second tag = first tag
}
}.f,
},
.{ .name = "unordered tags", .mutate = struct {
fn f(b: []u8) void {
std.mem.swap(u8, &b[container_header_len], &b[container_header_len + table_entry_len]);
}
}.f },
.{
.name = "zero-length section",
.mutate = struct {
fn f(b: []u8) void {
@memset(b[container_header_len + 1 ..][0..8], 0); // first entry's len = 0
}
}.f,
},
.{
.name = "table len exceeds file",
.mutate = struct {
fn f(b: []u8) void {
b[container_header_len + 1 + 7] = 0xff; // first entry's len high byte
}
}.f,
},
.{ .name = "flipped payload byte (crc)", .mutate = struct {
fn f(b: []u8) void {
b[b.len - crc_len - 1] ^= 0xff;
}
}.f },
.{ .name = "flipped crc byte", .mutate = struct {
fn f(b: []u8) void {
b[b.len - 1] ^= 0xff;
}
}.f },
};
var ckbuf: [max_ck_payload + 1]u8 = undefined;
for (cases) |case| {
const bad = try gpa.dupe(u8, good);
defer gpa.free(bad);
case.mutate(bad);
try writeRawTestFile(io, tmp.dir, filename, bad);
// Both readers reject; neither returns data or "absent".
try std.testing.expectError(error.Corrupt, read(io, tmp.dir, &ckbuf));
try std.testing.expectError(error.Corrupt, readSectionAlloc(gpa, io, tmp.dir, .undo));
// And a splice refuses to carry anything forward from it.
try std.testing.expectError(error.Corrupt, updateSections(io, tmp.dir, &.{.{ .tag = .bisect, .payload = "B" }}));
}
// Truncation and trailing garbage break the exact byte accounting.
try writeRawTestFile(io, tmp.dir, filename, good[0 .. good.len - 1]);
try std.testing.expectError(error.Corrupt, read(io, tmp.dir, &ckbuf));
const extended = try gpa.alloc(u8, good.len + 1);
defer gpa.free(extended);
@memcpy(extended[0..good.len], good);
extended[good.len] = 0;
try writeRawTestFile(io, tmp.dir, filename, extended);
try std.testing.expectError(error.Corrupt, read(io, tmp.dir, &ckbuf));
try std.testing.expectError(error.Corrupt, readSectionAlloc(gpa, io, tmp.dir, .undo));
}
test "container migration: a legacy checkout folds its siblings in on first write, then the siblings are dead" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
const gpa = std.testing.allocator;
// A pre-container checkout: legacy TLV record + three sibling files.
var lbuf: [max_ck_payload]u8 = undefined;
const legacy_rec = encodeCk(&lbuf, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try writeRawTestFile(io, tmp.dir, filename, legacy_rec);
try writeRawTestFile(io, tmp.dir, legacyName(.managed).?, "MANAGED-IMAGE");
try writeRawTestFile(io, tmp.dir, legacyName(.undo).?, "UNDO-IMAGE");
try writeRawTestFile(io, tmp.dir, legacyName(.bisect).?, "BISECT-IMAGE");
// Legacy reads work before any write: the siblings ARE the sections.
{
const m = (try readSectionAlloc(gpa, io, tmp.dir, .managed)).?;
defer gpa.free(m);
try std.testing.expectEqualStrings("MANAGED-IMAGE", m);
}
// First write migrates: container materialized, every sibling folded in
// byte-exactly, then deleted.
try write(io, tmp.dir, .{ .version = "2" ** 64, .branch = "feature", .merge_parent = "", .special_op = "", .manifest_flags = "" });
const raw = try tmp.dir.readFileAlloc(io, filename, gpa, .limited(1 << 16));
defer gpa.free(raw);
try std.testing.expect(isContainer(raw));
inline for (.{ .{ SectionTag.managed, "MANAGED-IMAGE" }, .{ SectionTag.undo, "UNDO-IMAGE" }, .{ SectionTag.bisect, "BISECT-IMAGE" } }) |pair| {
const bytes = (try readSectionAlloc(gpa, io, tmp.dir, pair[0])).?;
defer gpa.free(bytes);
try std.testing.expectEqualStrings(pair[1], bytes);
// The sibling file is gone.
try std.testing.expect(!testFileExists(io, tmp.dir, legacyName(pair[0]).?));
}
var ckbuf: [max_ck_payload + 1]u8 = undefined;
const got = (try read(io, tmp.dir, &ckbuf)).?;
try std.testing.expectEqualStrings("2" ** 64, got.version);
// Once a container exists a (re-planted) sibling is dead: never read.
try writeRawTestFile(io, tmp.dir, legacyName(.undo).?, "STALE-NEVER-READ");
const u = (try readSectionAlloc(gpa, io, tmp.dir, .undo)).?;
defer gpa.free(u);
try std.testing.expectEqualStrings("UNDO-IMAGE", u);
// ...and the next write sweeps it away.
try updateSections(io, tmp.dir, &.{.{ .tag = .bisect, .payload = "B2" }});
try std.testing.expect(!testFileExists(io, tmp.dir, legacyName(.undo).?));
}
test "container migration: migration is idempotent and preserves bytes across repeated writes" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
const gpa = std.testing.allocator;
var lbuf: [max_ck_payload]u8 = undefined;
const legacy_rec = encodeCk(&lbuf, .{ .version = "1" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try writeRawTestFile(io, tmp.dir, filename, legacy_rec);
try writeRawTestFile(io, tmp.dir, legacyName(.undo).?, "UNDO-IMAGE");
// The same logical write applied twice (as a crash-replay would) yields
// the same container bytes.
try updateSections(io, tmp.dir, &.{.{ .tag = .bisect, .payload = "B" }});
const first = try tmp.dir.readFileAlloc(io, filename, gpa, .limited(1 << 16));
defer gpa.free(first);
try updateSections(io, tmp.dir, &.{.{ .tag = .bisect, .payload = "B" }});
const second = try tmp.dir.readFileAlloc(io, filename, gpa, .limited(1 << 16));
defer gpa.free(second);
try std.testing.expectEqualSlices(u8, first, second);
}
test "container migration: a corrupt legacy record refuses to migrate" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const io = std.testing.io;
try writeRawTestFile(io, tmp.dir, filename, &[_]u8{ 0xff, 0x00, 0xaa }); // version_len 0xff, no bytes behind it
try std.testing.expectError(error.Corrupt, updateSections(io, tmp.dir, &.{.{ .tag = .undo, .payload = "J" }}));
// A zero-byte sibling is corrupt in every absorbed format: refuse to
// launder it into a valid-looking absence.
var tmp2 = std.testing.tmpDir(.{});
defer tmp2.cleanup();
var lbuf: [max_ck_payload]u8 = undefined;
const legacy_rec = encodeCk(&lbuf, .{ .version = "v", .branch = "b", .merge_parent = "", .special_op = "", .manifest_flags = "" });
try writeRawTestFile(io, tmp2.dir, filename, legacy_rec);
try writeRawTestFile(io, tmp2.dir, legacyName(.managed).?, "");
try std.testing.expectError(error.Corrupt, updateSections(io, tmp2.dir, &.{.{ .tag = .undo, .payload = "J" }}));
}
test "container v3: a v2 container reads and migrates to v3 on first splice" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Hand-build a v2 container: header(version=2) + one checkout section +
// crc. Byte layout per the v2 shipped format; this test IS the pin that
// v3 readers keep folding it.
var ckbuf: [max_ck_payload]u8 = undefined;
const ck = encodeCk(&ckbuf, .{ .version = "v2v", .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = "" });
var v2: std.ArrayList(u8) = .empty;
defer v2.deinit(gpa);
try v2.appendSlice(gpa, container_magic);
try v2.appendSlice(gpa, &.{ 2, 0 }); // version u16 LE = 2
try v2.appendSlice(gpa, &.{ 1, 0 }); // count, reserved
try v2.append(gpa, 1); // tag: checkout
var len8: [8]u8 = undefined;
std.mem.writeInt(u64, &len8, ck.len, .little);
try v2.appendSlice(gpa, &len8);
try v2.appendSlice(gpa, ck);
var crc = crc32.Crc32.init();
crc.update(v2.items);
var crc4: [4]u8 = undefined;
std.mem.writeInt(u32, &crc4, crc.final(), .little);
try v2.appendSlice(gpa, &crc4);
try writeRawTestFile(io, tmp.dir, filename, v2.items);
// Reads fold the v2 generation.
const got = (try readSectionAlloc(gpa, io, tmp.dir, .checkout)).?;
defer gpa.free(got);
try std.testing.expectEqualSlices(u8, ck, got);
// A splice rewrites as v3, carrying the v2 section byte-exactly.
try updateSections(io, tmp.dir, &.{.{ .tag = .cone, .payload = "CONE" }});
var file = try tmp.dir.openFile(io, filename, .{});
defer file.close(io);
var head: [10]u8 = undefined;
_ = try file.readPositionalAll(io, &head, 0);
try std.testing.expectEqual(container_version, std.mem.readInt(u16, head[8..10], .little));
const carried = (try readSectionAlloc(gpa, io, tmp.dir, .checkout)).?;
defer gpa.free(carried);
try std.testing.expectEqualSlices(u8, ck, carried);
const cone_payload = (try readSectionAlloc(gpa, io, tmp.dir, .cone)).?;
defer gpa.free(cone_payload);
try std.testing.expectEqualSlices(u8, "CONE", cone_payload);
}
test "container v3: version negative space — v2 with a v3 tag, and a future version, both refuse" {
const io = std.testing.io;
const gpa = std.testing.allocator;
const Case = struct { version: u8, tag: u8 };
// An older table may not smuggle a newer tag; a future container
// version refuses loudly rather than misparses. Same gate an old
// binary hits on meeting any newer version.
for ([_]Case{ .{ .version = 2, .tag = 5 }, .{ .version = 3, .tag = 7 }, .{ .version = 5, .tag = 1 } }) |case_| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var bytes: std.ArrayList(u8) = .empty;
defer bytes.deinit(gpa);
try bytes.appendSlice(gpa, container_magic);
try bytes.appendSlice(gpa, &.{ case_.version, 0, 1, 0 });
try bytes.append(gpa, case_.tag);
var len8: [8]u8 = undefined;
std.mem.writeInt(u64, &len8, 1, .little);
try bytes.appendSlice(gpa, &len8);
try bytes.append(gpa, 'X');
var crc = crc32.Crc32.init();
crc.update(bytes.items);
var crc4: [4]u8 = undefined;
std.mem.writeInt(u32, &crc4, crc.final(), .little);
try bytes.appendSlice(gpa, &crc4);
try writeRawTestFile(io, tmp.dir, filename, bytes.items);
try std.testing.expectError(error.Corrupt, readSectionAlloc(gpa, io, tmp.dir, .checkout));
}
}
test "container v3: store_location is carried verbatim through splices it does not name" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Reserve issue #59's seat: the payload is opaque here and must survive
// unrelated splices byte-identically.
const opaque_loc = "/somewhere/else/.zit\x00binary\xffgoo";
try updateSections(io, tmp.dir, &.{.{ .tag = .store_location, .payload = opaque_loc }});
try updateSections(io, tmp.dir, &.{.{ .tag = .undo, .payload = "JOURNAL" }});
try updateSections(io, tmp.dir, &.{.{ .tag = .undo, .payload = null }});
const got = (try readSectionAlloc(gpa, io, tmp.dir, .store_location)).?;
defer gpa.free(got);
try std.testing.expectEqualSlices(u8, opaque_loc, got);
}
// 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 plan 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;
var sbuf: [max_ck_str]u8 = undefined;
var fbuf: [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);
const sn = random.uintAtMost(usize, max_ck_str);
const fn_ = 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]);
random.bytes(sbuf[0..sn]);
random.bytes(fbuf[0..fn_]);
var enc: [max_ck_payload]u8 = undefined;
const bytes = encodeCk(&enc, .{ .version = vbuf[0..vn], .branch = bbuf[0..bn], .merge_parent = mbuf[0..mn], .special_op = sbuf[0..sn], .manifest_flags = fbuf[0..fn_] });
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);
try std.testing.expectEqualSlices(u8, sbuf[0..sn], rt.special_op);
try std.testing.expectEqualSlices(u8, fbuf[0..fn_], rt.manifest_flags);
// 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 => {},
}
}
}