Artifact 4761c7c8d6d4e2731ce552c5f6e7f18361531e76fe86d0939455a8138d58eb79:
- File
src/checkout.zig
— part of check-in
[b7e077e430]
at
2026-09-07 01:37:49
on branch trunk
— settings: read the checkout the way fossil does (zit#175)
Fossil-dialect globs treat ! as a class member; only ^ negates. open reads a versioned-only manifest setting out of the check-in and remembers --manifest=off so update cannot re-enable stamps. add --force adds a named ignore-glob match; directory expansion always honours ignore and clean globs. .fos root names compare case-sensitively on Linux. (user: dmestas, size: 65112) [annotate] [blame] [check-ins using]
//! 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 = 6 * (2 + max_ck_str); // = 1542 /// 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"; /// zit#175 item 3 -- the stored `manifest_flags` meaning "stamps explicitly /// OFF for this checkout" (`open --manifest=off`). Distinct from "", never /// chosen, which lets the project's `manifest` setting supply the selectors /// at the next update; this value refuses it. Not a selector letter, so a /// reader scanning for `r`/`u`/`t` sees no stamps without knowing about it. pub const manifest_off: []const u8 = "-"; /// The selectors a stored `manifest_flags` actually writes: none for the /// explicit-off sentinel, the value itself otherwise. pub fn manifestSelectors(stored: []const u8) []const u8 { return if (std.mem.eql(u8, stored, manifest_off)) "" else stored; } /// 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, /// zit#164 `merge --integrate`: the hash of the merged-in leaf the next /// commit closes; "" when none. Encoded only when set, so a record /// without one is byte-identical to the five-field form. integrate: []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. /// #59 — where this checkout's store lives. Null means "beside the /// checkout", which is every checkout `init` or `clone` produces; a detached /// worktree carries the store root's absolute path in its `store_location` /// section, and this is the ONE place that path is interpreted, so a /// checkout whose pointer dangles fails identically everywhere: /// `error.Storage`, the same answer as a deleted `.zit`. /// /// The caller owns closing the returned directory handle. Reads the section /// with a fixed buffer — a path beyond the OS maximum could not open anyway. pub fn storeDirOf(io: std.Io, root_dir: std.Io.Dir) error{ Storage, Corrupt }!?std.Io.Dir { var buf: [std.fs.max_path_bytes + 256]u8 = undefined; var fixed = std.heap.FixedBufferAllocator.init(&buf); const payload = readSectionAlloc(fixed.allocator(), io, root_dir, .store_location) catch |e| switch (e) { // A pointer too long to be a real path is a corrupt section, not OOM. error.OutOfMemory => return error.Corrupt, error.Storage => return error.Storage, error.Corrupt => return error.Corrupt, } orelse return null; if (payload.len == 0 or payload[0] != '/') return error.Corrupt; if (std.mem.indexOfScalar(u8, payload, 0) != null) return error.Corrupt; return std.Io.Dir.cwd().openDir(io, payload, .{}) catch return error.Storage; } 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); const base = 10 + vl + bl + ml + sl + fl; if (s.integrate.len == 0) return buf[0..base]; std.debug.assert(s.integrate.len <= max_ck_str); const il: usize = s.integrate.len; std.mem.writeInt(u16, buf[base..][0..2], @intCast(il), .little); @memcpy(buf[base + 2 ..][0..il], s.integrate); return buf[0 .. base + 2 + il]; } /// 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; } var integrate: []const u8 = ""; if (pos != payload.len) { if (payload.len - pos < 2) return error.Corrupt; const ilen: usize = std.mem.readInt(u16, payload[pos..][0..2], .little); pos += 2; if (ilen > max_ck_str) return error.Corrupt; if (payload.len - pos < ilen) return error.Corrupt; integrate = payload[pos..][0..ilen]; pos += ilen; } 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, .integrate = integrate, }; } // --------------------------------------------------------------------------- // 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, .integrate = "i" ** 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); try std.testing.expectEqual(@as(usize, max_ck_str), ok.integrate.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, .integrate = "i" ** 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 => {}, } } }