//! Storage §E: the per-checkout MANAGED-PATH SET, kept OUT of the shared log.
//!
//! This is the working-copy set of repo-relative paths the user has asked zit
//! to version-control. Like `.zit-checkout` (see checkout.zig) it is
//! per-working-copy state materialized atomically into `.zit-managed` beside
//! the working tree; it is NOT the content log (`.zit`) and NOT repostate
//! (which derives over committed artifacts). It never enters the shippable
//! container.
//!
//! No RFC section behind this file: native storage-engine design, the same
//! oracle as checkout.zig / logstore.zig (storage-track-plan.md §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`.
//!
//! The set is held sorted-canonical in memory (ascending by path bytes,
//! deduplicated), so `iterate` is O(1), `contains` is O(log n), and the
//! persisted byte image is a pure function of the set — building the same set
//! in any insertion order yields identical bytes (§E order-independence).
const std = @import("std");
const assert = std.debug.assert;
/// The device failed, the record on disk is not a `.zit-managed` this reader
/// can parse, or an input violated a fixed bound. Closed at the boundary so a
/// CLI caller can switch exhaustively.
pub const ManagedError = error{
/// A filesystem operation failed (open/create/write/sync/rename/stat).
Storage,
/// The on-disk record is malformed (bad length, wrong order, trailing
/// bytes, over-count). Never a crash — parse of arbitrary bytes yields
/// this or a value.
Corrupt,
/// A single path exceeds `max_path_len`.
TooLong,
/// The set is full: adding would exceed `max_entries` or `max_file_bytes`.
TooMany,
/// Allocation failed.
OutOfMemory,
};
/// The managed-set file, resolved relative to the injected checkout dir.
pub const filename: []const u8 = ".zit-managed";
/// Per-path upper bound. PATH_MAX, not checkout's `max_ck_str` (255 =
/// NAME_MAX): a repo-relative path spans many components and legitimately
/// reaches PATH_MAX, so 255 would reject valid deep paths.
pub const max_path_len: usize = 4096;
/// Fixed upper bound on the in-memory / on-disk entry count. A u32 count field
/// carries it (21 bits); the array is capped here.
pub const max_entries: usize = 1 << 20;
/// Authoritative aggregate on-disk bound. Enforced symmetrically: on insert
/// growth (so `write` can never exceed it) and on `read` before allocation (so
/// a lying file cannot force an oversized allocation).
pub const max_file_bytes: usize = 64 << 20;
/// Serialized size of the empty set: just the u32 count prefix.
const header_bytes: usize = 4;
/// Per-entry serialized overhead: the u16 length prefix.
const entry_overhead: usize = 2;
/// Serialized overhead of a pending-rename record: two u16 name-length prefixes
/// (old then new). The record is an OPTIONAL trailer after the entry table.
const record_overhead: usize = 2 * entry_overhead;
/// A pending OLD→NEW rename recorded by `zit mv`, consumed by the next
/// `zit commit` to emit the §4.7.6 four-token old-name F card. Both names are
/// heap-owned repo-relative keys. At most one is pending at a time.
pub const Rename = struct {
old: []const u8,
new: []const u8,
};
/// Serialized size a `Rename` adds to the persisted image.
fn renameBytes(rn: Rename) usize {
return record_overhead + rn.old.len + rn.new.len;
}
/// A sorted, deduplicated set of heap-owned repo-relative paths, with atomic
/// self-describing persistence. Owns every byte of every path it holds.
pub const ManagedSet = struct {
gpa: std.mem.Allocator,
/// Sorted strictly-ascending by bytes, unique, each element heap-owned.
paths: std.ArrayList([]const u8),
/// Running serialized size (== the byte length `write`/`encode` produce and
/// `read`/`parse` consume, record included). Invariant: `bytes <=
/// max_file_bytes`.
bytes: usize,
/// The one pending rename `zit mv` records for the next commit to consume,
/// or null when none is pending (the common case; a pre-mv file and every
/// post-clear file decode to null). Names are heap-owned.
rename: ?Rename = null,
/// An empty set. Allocates nothing.
pub fn init(gpa: std.mem.Allocator) ManagedSet {
return .{ .gpa = gpa, .paths = .empty, .bytes = header_bytes };
}
/// Frees every owned path, the pending-rename names, and the backing array.
pub fn deinit(self: *ManagedSet) void {
for (self.paths.items) |p| self.gpa.free(p);
self.paths.deinit(self.gpa);
if (self.rename) |rn| {
self.gpa.free(rn.old);
self.gpa.free(rn.new);
}
self.* = undefined;
}
/// Adds `path`. Idempotent: returns `true` when newly inserted, `false`
/// when already present (never an error on a duplicate — the common case
/// is defined error-free). `error.TooLong` if the path exceeds
/// `max_path_len`; `error.TooMany` if the set is full (count or aggregate
/// bytes); `error.OutOfMemory` on allocation failure. Bound violations are
/// errors, not asserts: they guard untrusted INPUT and must be testable.
pub fn insert(self: *ManagedSet, path: []const u8) ManagedError!bool {
if (path.len > max_path_len) return error.TooLong;
const loc = self.locate(path);
if (loc.found) return false;
if (self.paths.items.len >= max_entries) return error.TooMany;
const grown = self.bytes + entry_overhead + path.len;
if (grown > max_file_bytes) return error.TooMany;
const owned = self.gpa.dupe(u8, path) catch return error.OutOfMemory;
errdefer self.gpa.free(owned);
self.paths.insert(self.gpa, loc.idx, owned) catch return error.OutOfMemory;
self.bytes = grown;
assert(self.paths.items.len <= max_entries); // count bound holds
assert(self.bytes <= max_file_bytes); // aggregate bound holds
assert(self.sortedUnique()); // invariant preserved
return true;
}
/// Membership test. Pure, O(log n).
pub fn contains(self: *const ManagedSet, path: []const u8) bool {
return self.locate(path).found;
}
/// Removes `path`. Returns whether it was present (absent is a no-op
/// returning `false` — no error). O(n) shift; not a hot path.
pub fn remove(self: *ManagedSet, path: []const u8) bool {
const loc = self.locate(path);
if (!loc.found) return false;
const owned = self.paths.orderedRemove(loc.idx);
assert(self.bytes >= header_bytes + entry_overhead + owned.len);
self.bytes -= entry_overhead + owned.len;
self.gpa.free(owned);
assert(self.sortedUnique());
return true;
}
/// Stable, sorted-ascending view of the set for `status`/`commit`. The
/// slice borrows the set and is invalidated by the next mutation.
pub fn iterate(self: *const ManagedSet) []const []const u8 {
return self.paths.items;
}
/// Records a pending OLD→NEW rename for the next commit to emit as a §4.7.6
/// old-name F card. One-shot and at-most-one: any prior pending rename is
/// replaced, so the record never grows without bound. Precondition (mv has
/// already swapped the entry): NEW is tracked and OLD is not — the record
/// stays STRUCTURAL, never a second copy of the entry membership. Names are
/// duped (heap-owned). `error.TooMany` if the aggregate would exceed
/// `max_file_bytes`; `error.OutOfMemory` on allocation failure.
pub fn recordRename(self: *ManagedSet, old: []const u8, new: []const u8) ManagedError!void {
assert(old.len >= 1 and old.len <= max_path_len); // bounded, non-empty
assert(new.len >= 1 and new.len <= max_path_len);
assert(self.contains(new)); // mv inserted NEW before recording (positive space)
assert(!self.contains(old)); // mv removed OLD before recording (negative space)
// Post-replace total: drop any existing record's bytes, add the new record.
var base = self.bytes;
if (self.rename) |cur| base -= renameBytes(cur);
const grown = base + renameBytes(.{ .old = old, .new = new });
if (grown > max_file_bytes) return error.TooMany;
const old_owned = self.gpa.dupe(u8, old) catch return error.OutOfMemory;
errdefer self.gpa.free(old_owned);
const new_owned = self.gpa.dupe(u8, new) catch return error.OutOfMemory;
if (self.rename) |cur| {
self.gpa.free(cur.old);
self.gpa.free(cur.new);
}
self.rename = .{ .old = old_owned, .new = new_owned };
self.bytes = grown;
assert(self.bytes <= max_file_bytes); // aggregate bound holds
}
/// The pending rename, or null when none is recorded. The ONE query
/// `runCommit` uses to stamp the renamed capture's old-name (§4.7.6). Borrows
/// the set; invalidated by `clearRename`/`recordRename`/`deinit`.
pub fn pendingRename(self: *const ManagedSet) ?Rename {
return self.rename;
}
/// Consumes the pending rename: frees its names and restores the byte image
/// to the no-record form. Idempotent (a no-op when none is pending), so the
/// commit that carried the rename clears it exactly once.
pub fn clearRename(self: *ManagedSet) void {
if (self.rename) |cur| {
assert(self.bytes >= header_bytes + renameBytes(cur));
self.bytes -= renameBytes(cur);
self.gpa.free(cur.old);
self.gpa.free(cur.new);
self.rename = null;
}
assert(self.rename == null);
}
/// Binary-search location of `path`: `.found` marks an exact hit at `.idx`;
/// otherwise `.idx` is the sorted insertion point. Bounded loop — at most
/// log2(max_entries) = 20 iterations.
fn locate(self: *const ManagedSet, path: []const u8) struct { idx: usize, found: bool } {
var lo: usize = 0;
var hi: usize = self.paths.items.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
switch (std.mem.order(u8, self.paths.items[mid], path)) {
.lt => lo = mid + 1,
.gt => hi = mid,
.eq => return .{ .idx = mid, .found = true },
}
}
return .{ .idx = lo, .found = false };
}
/// Invariant check for assertions: the array is strictly ascending (hence
/// sorted and unique). No side effects.
fn sortedUnique(self: *const ManagedSet) bool {
if (self.paths.items.len < 2) return true;
for (self.paths.items[1..], self.paths.items[0 .. self.paths.items.len - 1]) |cur, prev| {
if (std.mem.order(u8, prev, cur) != .lt) return false;
}
return true;
}
/// Encodes the set into `buf` (which must hold `self.bytes`) as
/// `[count u32 LE]` then per entry `[len u16 LE][path bytes]`, entries in
/// the array's ascending order. Returns the written slice. Because the set
/// is canonical, the byte image is a pure function of the set.
fn encode(self: *const ManagedSet, buf: []u8) []const u8 {
assert(buf.len >= self.bytes);
assert(self.paths.items.len <= max_entries);
var pos: usize = 0;
std.mem.writeInt(u32, buf[pos..][0..4], @intCast(self.paths.items.len), .little);
pos += 4;
for (self.paths.items) |p| {
assert(p.len <= max_path_len);
std.mem.writeInt(u16, buf[pos..][0..2], @intCast(p.len), .little);
pos += 2;
@memcpy(buf[pos..][0..p.len], p);
pos += p.len;
}
// Optional trailing pending-rename record: `old_len old new_len new`.
// Absence writes nothing (byte-identical to a pre-mv image).
if (self.rename) |rn| {
assert(rn.old.len <= max_path_len and rn.new.len <= max_path_len);
std.mem.writeInt(u16, buf[pos..][0..2], @intCast(rn.old.len), .little);
pos += 2;
@memcpy(buf[pos..][0..rn.old.len], rn.old);
pos += rn.old.len;
std.mem.writeInt(u16, buf[pos..][0..2], @intCast(rn.new.len), .little);
pos += 2;
@memcpy(buf[pos..][0..rn.new.len], rn.new);
pos += rn.new.len;
}
assert(pos == self.bytes); // running counter matches the encoding
return buf[0..pos];
}
/// Parses an `encode` record into an owned set. Every length is
/// bounds-checked against the remaining slice before use, each entry is
/// capped at `max_path_len`, entries must be strictly ascending (which
/// rejects both unsorted records and duplicates in one check), and total
/// consumption must be exact. Any violation is `error.Corrupt`; arbitrary
/// bytes never crash. Paths are duped, so the transient read buffer frees
/// cleanly. The count-driven loop is a bounded `for`, never a
/// `while`+mutable-state.
fn parse(gpa: std.mem.Allocator, payload: []const u8) ManagedError!ManagedSet {
var self = init(gpa);
errdefer self.deinit();
if (payload.len < header_bytes) return error.Corrupt;
const count: usize = std.mem.readInt(u32, payload[0..4], .little);
if (count > max_entries) return error.Corrupt;
var pos: usize = header_bytes;
var prev: ?[]const u8 = null;
for (0..count) |_| {
if (payload.len - pos < entry_overhead) return error.Corrupt;
const len: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += entry_overhead;
if (len > max_path_len) return error.Corrupt;
if (payload.len - pos < len) return error.Corrupt;
const entry = payload[pos..][0..len];
pos += len;
if (prev) |pv| {
if (std.mem.order(u8, pv, entry) != .lt) return error.Corrupt;
}
prev = entry;
const owned = gpa.dupe(u8, entry) catch return error.OutOfMemory;
errdefer gpa.free(owned);
self.paths.append(gpa, owned) catch return error.OutOfMemory;
self.bytes += entry_overhead + len;
}
assert(self.paths.items.len == count);
// Optional trailing pending-rename record (§4.7.6 mv). Absence == exact
// entry-table consumption (a pre-mv file, and every post-clear file);
// presence == an `old_len old new_len new` frame that must consume the
// rest exactly. Every length is bounds-checked against the remaining
// slice before use; a framing failure is `error.Corrupt`, never a crash
// (one stray byte cannot frame a u16, so the old "trailing byte" reject
// still holds).
if (pos != payload.len) {
if (payload.len - pos < entry_overhead) return error.Corrupt;
const old_len: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += entry_overhead;
if (old_len == 0 or old_len > max_path_len) return error.Corrupt;
if (payload.len - pos < old_len) return error.Corrupt;
const old = payload[pos..][0..old_len];
pos += old_len;
if (payload.len - pos < entry_overhead) return error.Corrupt;
const new_len: usize = std.mem.readInt(u16, payload[pos..][0..2], .little);
pos += entry_overhead;
if (new_len == 0 or new_len > max_path_len) return error.Corrupt;
if (payload.len - pos < new_len) return error.Corrupt;
const new = payload[pos..][0..new_len];
pos += new_len;
if (pos != payload.len) return error.Corrupt; // no bytes past the record
const old_owned = gpa.dupe(u8, old) catch return error.OutOfMemory;
errdefer gpa.free(old_owned);
const new_owned = gpa.dupe(u8, new) catch return error.OutOfMemory;
self.rename = .{ .old = old_owned, .new = new_owned };
self.bytes += record_overhead + old_len + new_len;
}
assert(self.bytes == payload.len);
assert(self.sortedUnique());
return self;
}
/// Materializes the set atomically into `filename`: encode into a
/// heap buffer (sized by `self.bytes` — the only deviation from
/// checkout.zig's stack buffer, since the payload is variable-size), then
/// the proven write-temp + fsync + rename-replace sequence. A crash
/// mid-write leaves the prior file intact. Every I/O failure collapses to
/// `error.Storage`.
pub fn write(self: *const ManagedSet, io: std.Io, dir: std.Io.Dir) ManagedError!void {
assert(self.paths.items.len <= max_entries);
assert(self.bytes <= max_file_bytes);
const buf = self.gpa.alloc(u8, self.bytes) catch return error.OutOfMemory;
defer self.gpa.free(buf);
const bytes = self.encode(buf);
var af = dir.createFileAtomic(io, filename, .{ .replace = true }) catch return error.Storage;
defer af.deinit(io);
af.file.writePositionalAll(io, bytes, 0) 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
}
/// Reads and parses `filename` into an owned set. An absent file reads as
/// the empty set (`FileNotFound` is absence, not an error, §E). The file
/// size is checked against `max_file_bytes` BEFORE allocation, so a lying
/// file cannot force an oversized allocation. A malformed record is
/// `error.Corrupt`; any other I/O failure is `error.Storage`.
pub fn read(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) ManagedError!ManagedSet {
var file = dir.openFile(io, filename, .{}) catch |e| switch (e) {
error.FileNotFound => return init(gpa),
else => return error.Storage,
};
defer file.close(io);
const st = file.stat(io) catch return error.Storage;
if (st.size > max_file_bytes) return error.Corrupt; // reject before alloc
const size: usize = @intCast(st.size);
const buf = gpa.alloc(u8, size) catch return error.OutOfMemory;
defer gpa.free(buf);
const n = file.readPositionalAll(io, buf, 0) catch return error.Storage;
return parse(gpa, buf[0..n]);
}
};
// ---------------------------------------------------------------------------
// Tests (storage-track-plan.md §E; no RFC section behind this file). Expected
// values come from the format spec above, never from running fossil.
// ---------------------------------------------------------------------------
const testing = std.testing;
/// Asserts two sets hold exactly the same paths in the same (sorted) order.
fn expectSameSet(a: *const ManagedSet, b: *const ManagedSet) !void {
const ia = a.iterate();
const ib = b.iterate();
try testing.expectEqual(ia.len, ib.len);
for (ia, ib) |pa, pb| try testing.expectEqualSlices(u8, pa, pb);
}
test "managed §E: insert, contains, and remove form a coherent lifecycle" {
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
try testing.expect(!s.contains("src/main.zig"));
try testing.expect(try s.insert("src/main.zig"));
try testing.expect(s.contains("src/main.zig"));
try testing.expect(!s.contains("README"));
try testing.expect(s.remove("src/main.zig"));
try testing.expect(!s.contains("src/main.zig"));
}
test "managed §E: insert is idempotent (a duplicate is not an error)" {
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
try testing.expect(try s.insert("a/b"));
try testing.expect(!(try s.insert("a/b"))); // already present -> false, no error
try testing.expectEqual(@as(usize, 1), s.iterate().len);
}
test "managed §E: remove of an absent path is a no-op returning false" {
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
try testing.expect(!s.remove("never/added"));
try testing.expect(try s.insert("x"));
try testing.expect(!s.remove("y")); // present set, absent path
try testing.expectEqual(@as(usize, 1), s.iterate().len);
}
test "managed §E: iterate is sorted ascending by bytes regardless of insert order" {
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
_ = try s.insert("zeta");
_ = try s.insert("alpha");
_ = try s.insert("mid");
_ = try s.insert("Alpha"); // 'A' (0x41) sorts before 'a' (0x61)
const it = s.iterate();
try testing.expectEqual(@as(usize, 4), it.len);
try testing.expectEqualStrings("Alpha", it[0]);
try testing.expectEqualStrings("alpha", it[1]);
try testing.expectEqualStrings("mid", it[2]);
try testing.expectEqualStrings("zeta", it[3]);
}
test "managed §E: two insertion orders yield byte-identical persisted images (order-independence)" {
var a = ManagedSet.init(testing.allocator);
defer a.deinit();
var b = ManagedSet.init(testing.allocator);
defer b.deinit();
const items = [_][]const u8{ "src/z.zig", "README", "a/b/c", "src/a.zig", "docs/x" };
for (items) |p| _ = try a.insert(p);
var i = items.len;
while (i > 0) {
i -= 1;
_ = try b.insert(items[i]);
}
try testing.expectEqual(a.bytes, b.bytes);
const bufa = try testing.allocator.alloc(u8, a.bytes);
defer testing.allocator.free(bufa);
const bufb = try testing.allocator.alloc(u8, b.bytes);
defer testing.allocator.free(bufb);
try testing.expectEqualSlices(u8, a.encode(bufa), b.encode(bufb));
}
test "managed §E: write then read round-trips a bounded set (paths may embed NUL)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
_ = try s.insert("README");
_ = try s.insert(&[_]u8{ 'a', 0x00, 'b' }); // NUL is a legal filename byte (rule 4)
_ = try s.insert("src/main.zig");
_ = try s.insert("" ** 0 ++ "z"); // ordinary
try s.write(io, tmp.dir);
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try expectSameSet(&s, &got);
try testing.expectEqual(s.bytes, got.bytes);
}
test "managed §E: read of an absent managed file returns the empty set" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try testing.expectEqual(@as(usize, 0), got.iterate().len);
try testing.expectEqual(header_bytes, got.bytes);
}
test "managed §E: a second write atomically replaces the set (last wins)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var first = ManagedSet.init(testing.allocator);
defer first.deinit();
_ = try first.insert("old/one");
_ = try first.insert("old/two");
try first.write(io, tmp.dir);
var second = ManagedSet.init(testing.allocator);
defer second.deinit();
_ = try second.insert("new/only");
try second.write(io, tmp.dir);
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try expectSameSet(&second, &got);
}
test "managed §E: a partial write that never materializes leaves the prior set intact" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var good = ManagedSet.init(testing.allocator);
defer good.deinit();
_ = try good.insert("keep/me");
_ = try good.insert("keep/too");
try good.write(io, tmp.dir);
// Begin a fresh atomic file, write a DIFFERENT set's bytes, then drop it
// WITHOUT replace: deinit deletes the temp, a reader only sees `filename`.
{
var doomed = ManagedSet.init(testing.allocator);
defer doomed.deinit();
_ = try doomed.insert("should/vanish");
const buf = try testing.allocator.alloc(u8, doomed.bytes);
defer testing.allocator.free(buf);
const bytes = doomed.encode(buf);
var af = try tmp.dir.createFileAtomic(io, filename, .{ .replace = true });
defer af.deinit(io);
try af.file.writePositionalAll(io, bytes, 0);
// no af.replace(io): the temp is abandoned
}
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try expectSameSet(&good, &got);
}
test "managed §E: parse rejects malformed records (one case per condition)" {
const gpa = testing.allocator;
// count = (1<<20)+1, little-endian, as a bare 4-byte header.
const over_count = comptime blk: {
var b: [4]u8 = undefined;
std.mem.writeInt(u32, &b, @as(u32, max_entries) + 1, .little);
break :blk b;
};
const cases = [_][]const u8{
&[_]u8{ 0x00, 0x00, 0x00 }, // truncated count (3 of 4 bytes)
&over_count, // count exceeds max_entries
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x00 }, // truncated entry len (1 of 2)
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x10 }, // entry len 4097 > max_path_len
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x05, 0x00, 'a', 'b' }, // len 5 exceeds remaining
&[_]u8{ 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 'b', 0x01, 0x00, 'a' }, // non-ascending
&[_]u8{ 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01, 0x00, 'a' }, // duplicate (not < )
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0xff }, // trailing byte
};
for (cases) |c| try testing.expectError(error.Corrupt, ManagedSet.parse(gpa, c));
}
test "managed §E: a file larger than max_file_bytes is rejected before allocation" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
// Sparse write of one byte at offset max_file_bytes => size = max_file_bytes+1,
// without materializing 64 MiB of data.
{
var f = try tmp.dir.createFile(io, filename, .{});
defer f.close(io);
try f.writePositionalAll(io, &[_]u8{0x00}, @as(u64, max_file_bytes));
}
try testing.expectError(error.Corrupt, ManagedSet.read(testing.allocator, io, tmp.dir));
}
test "managed §E: insert of an over-long path is rejected with TooLong" {
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
const ok = "a" ** max_path_len;
try testing.expect(try s.insert(ok)); // exactly max_path_len is accepted
const too_long = "b" ** (max_path_len + 1);
try testing.expectError(error.TooLong, s.insert(too_long));
}
test "managed §E fuzz: parse never crashes and round-trips encode" {
const gpa = testing.allocator;
var prng = std.Random.DefaultPrng.init(0x2AA9ED);
const random = prng.random();
var iter: usize = 0;
while (iter < 300) : (iter += 1) {
// Build a random bounded set, encode it, parse it back, compare.
var s = ManagedSet.init(gpa);
defer s.deinit();
const n = random.uintAtMost(usize, 16);
var made: usize = 0;
while (made < n) : (made += 1) {
var pbuf: [40]u8 = undefined;
const pl = random.uintAtMost(usize, pbuf.len);
random.bytes(pbuf[0..pl]); // random bytes, NUL included, legal
_ = try s.insert(pbuf[0..pl]);
}
// Half the iterations also seed a pending rename so the record framing
// is fuzzed alongside the entry table. `new` is an actual member (so
// `contains(new)` holds); `old` is 41 bytes — longer than any random
// member (capped at 40), so `!contains(old)` holds by construction.
if (s.iterate().len > 0 and s.iterate()[0].len >= 1 and random.boolean()) {
const old_unique = "z" ** 41;
try s.recordRename(old_unique, s.iterate()[0]);
}
const buf = try gpa.alloc(u8, s.bytes);
defer gpa.free(buf);
const bytes = s.encode(buf);
var rt = try ManagedSet.parse(gpa, bytes);
defer rt.deinit();
try expectSameSet(&s, &rt);
if (s.pendingRename()) |rn| {
const rrn = rt.pendingRename() orelse return error.NoRename;
try testing.expectEqualSlices(u8, rn.old, rrn.old);
try testing.expectEqualSlices(u8, rn.new, rrn.new);
} else try testing.expect(rt.pendingRename() == null);
// Arbitrary garbage: a value or error.Corrupt, never a crash.
var garbage: [80]u8 = undefined;
const gn = random.uintAtMost(usize, garbage.len);
random.bytes(garbage[0..gn]);
if (ManagedSet.parse(gpa, garbage[0..gn])) |*maybe| {
var v = maybe.*;
v.deinit();
} else |err| switch (err) {
error.Corrupt => {},
error.OutOfMemory, error.TooLong, error.TooMany, error.Storage => unreachable,
}
}
}
test "managed §E: a path embedding NUL round-trips through write and read (rule 4)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
const nul_path = [_]u8{ 'd', 'i', 'r', 0x00, 'f', 'i', 'l', 'e' };
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
_ = try s.insert(&nul_path);
try s.write(io, tmp.dir);
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try testing.expectEqual(@as(usize, 1), got.iterate().len);
try testing.expectEqualSlices(u8, &nul_path, got.iterate()[0]);
try testing.expect(got.contains(&nul_path));
}
test "managed §E: a recorded rename round-trips through write/read (B-mv-18)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
_ = try s.insert("README");
_ = try s.insert("docs/new.md"); // NEW is tracked (mv already inserted it)
try s.recordRename("docs/old.md", "docs/new.md");
try s.write(io, tmp.dir);
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try expectSameSet(&s, &got);
try testing.expectEqual(s.bytes, got.bytes);
const rn = got.pendingRename() orelse return error.NoRename;
try testing.expectEqualStrings("docs/old.md", rn.old);
try testing.expectEqualStrings("docs/new.md", rn.new);
}
test "managed §E: a set written with no rename reads back with an empty record (B-mv-18)" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var s = ManagedSet.init(testing.allocator);
defer s.deinit();
_ = try s.insert("a");
_ = try s.insert("b");
try s.write(io, tmp.dir);
var got = try ManagedSet.read(testing.allocator, io, tmp.dir);
defer got.deinit();
try testing.expect(got.pendingRename() == null); // pre-mv byte image ⇒ no record
}
test "managed §E: garbage in the record region is error.Corrupt, never a crash (B-mv-18)" {
const gpa = testing.allocator;
// Every case is a valid one-entry table (`count=1`, entry 'a') followed by a
// malformed record trailer — the record framing must reject each as Corrupt.
const cases = [_][]const u8{
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01 }, // truncated old_len (1 of 2)
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x00, 0x00 }, // old_len == 0
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01, 0x10 }, // old_len 4097 > max_path_len
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x05, 0x00, 'o' }, // old_len 5 exceeds remaining
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01, 0x00, 'o' }, // no new_len after old
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01, 0x00, 'o', 0x00, 0x00 }, // new_len == 0
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01, 0x00, 'o', 0x03, 0x00, 'n' }, // new_len 3 exceeds remaining
&[_]u8{ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 'a', 0x01, 0x00, 'o', 0x01, 0x00, 'n', 0xff }, // trailing byte past record
};
for (cases) |c| try testing.expectError(error.Corrupt, ManagedSet.parse(gpa, c));
}
test "managed §E: recordRename then clearRename restores the no-record byte image (B-mv-8)" {
const gpa = testing.allocator;
var s = ManagedSet.init(gpa);
defer s.deinit();
_ = try s.insert("x");
_ = try s.insert("y");
const before = s.bytes;
const buf0 = try gpa.alloc(u8, s.bytes);
defer gpa.free(buf0);
const image0 = try gpa.dupe(u8, s.encode(buf0));
defer gpa.free(image0);
try s.recordRename("x-old", "y"); // y tracked, x-old not
try testing.expect(s.bytes > before);
s.clearRename();
try testing.expectEqual(before, s.bytes);
try testing.expect(s.pendingRename() == null);
const buf1 = try gpa.alloc(u8, s.bytes);
defer gpa.free(buf1);
try testing.expectEqualSlices(u8, image0, s.encode(buf1)); // byte-identical to no-record
}
test "managed §E: recordRename holds at most one pending rename (B-mv-18 bound)" {
const gpa = testing.allocator;
var s = ManagedSet.init(gpa);
defer s.deinit();
_ = try s.insert("n1");
try s.recordRename("o1", "n1");
_ = s.remove("n1");
_ = try s.insert("n2");
try s.recordRename("o2", "n2"); // replaces, does not accumulate
const rn = s.pendingRename() orelse return error.NoRename;
try testing.expectEqualStrings("o2", rn.old);
try testing.expectEqualStrings("n2", rn.new);
// The image carries exactly one record: it round-trips through parse.
const buf = try gpa.alloc(u8, s.bytes);
defer gpa.free(buf);
var rt = try ManagedSet.parse(gpa, s.encode(buf));
defer rt.deinit();
const rrn = rt.pendingRename() orelse return error.NoRename;
try testing.expectEqualStrings("o2", rrn.old);
try testing.expectEqualStrings("n2", rrn.new);
try testing.expectEqual(s.bytes, rt.bytes);
}
fn insertNoLeak(gpa: std.mem.Allocator) !void {
var s = ManagedSet.init(gpa);
defer s.deinit();
_ = try s.insert("src/main.zig");
_ = try s.insert("README");
_ = try s.insert("docs/spec.txt");
}
test "managed §E: insert propagates allocation failure without leaking" {
try testing.checkAllAllocationFailures(testing.allocator, insertNoLeak, .{});
}
fn readNoLeak(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void {
var s = try ManagedSet.read(gpa, io, dir);
defer s.deinit();
}
test "managed §E: read propagates allocation failure without leaking" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
var seed = ManagedSet.init(testing.allocator);
defer seed.deinit();
_ = try seed.insert("one");
_ = try seed.insert("two");
_ = try seed.insert("three");
try seed.write(io, tmp.dir);
try testing.checkAllAllocationFailures(testing.allocator, readNoLeak, .{ io, tmp.dir });
}
fn writeNoLeak(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) !void {
var s = ManagedSet.init(gpa);
defer s.deinit();
_ = try s.insert("alpha");
_ = try s.insert("beta");
try s.write(io, dir);
}
test "managed §E: write propagates allocation failure without leaking" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const io = testing.io;
try testing.checkAllAllocationFailures(testing.allocator, writeNoLeak, .{ io, tmp.dir });
}