Artifact d7b6ec8caa95993ce01c4ed9ddd0cda327db823bdbde4e27dff4966591be25f5:
- File
src/extras.zig
— part of check-in
[1aeb531cac]
at
2026-07-19 15:07:32
on branch blockers
— add/addremove/commit: refuse symlinks consistently (blocker B3)
A symlink anywhere in the checkout permanently wedged `commit`. `extras.zig` classifies a symlink as an extra (B-extras-9), and the recursive `add DIR` / `addremove` paths reuse that sweep as their add set without re-applying the kind test that explicit `zit add sym.txt` and `commit` both apply. The recursive path therefore TRACKED exactly what the direct path REFUSED: after `zit add .`, every subsequent `zit commit` died with "cannot commit 'sym.txt': not a regular file", `zit status` called an on-disk file missing, and the only way out (`zit rm sym.txt`) was named nowhere. `add .` is the documented whole-tree workflow, so any repo containing a symlink was stuck.
WHAT CHANGED
Consistent refusal: 0.1.0 does not version symlinks, on every path.
1. `extras.Extras` entries widen from a bare path to `Entry{path, is_symlink}`. The sweep already lstat-classified every entry (`resolveKind`); the verdict now rides out on the entry instead of being re-derived by callers. That is the point of the shape: `add` and `addremove` do NOT re-stat swept items, so the two paths cannot drift from the deep module's classification again — which is the mechanism of this bug. Ordering is unchanged (sort is by `path`; `is_symlink` never participates), and every postcondition assert is preserved.
2. The five `items()` consumers split by role. `extras` and `clean` ignore the tag — enumerating verbs do not judge, and a symlink stays a listable, deletable extra. `add`'s directory sweep and `addremove`'s insert loop filter on it.
3. `classifyPath`'s catch-all `else => .not_regular` splits: `.sym_link` gets its own `Outcome` and the wording `symlink (not versioned in 0.1.0)`; a fifo/socket/device keeps "not a regular file". Those are not the same refusal and must not read as one. The wording is one shared const so the explicit refusal and the recursive skip notice cannot diverge.
4. `commit`'s `Capture.not_regular` carries whether the entry was a symlink, and `commitNotRegular` renders the recovery for that case: "run 'zit rm PATH' to stop tracking it". This is how repos already wedged by a pre-fix `add .` get out; there is no migration code and none is needed.
REPORTING DECISION (stated, not implied)
`add` prints each skip to stderr, alongside its existing already-tracked notice: add's contract is per-argument feedback. `addremove` withholds symlinks SILENTLY. Its report is a two-channel ADDED/REMOVED ledger of state transitions, and a skipped symlink is a non-transition; growing a third notice channel there would change the shape of a report other tooling reads. `zit extras` is the verb that shows the symlink is still present and untracked.
`status` needed no change: post-fix the symlink is simply untracked.
TESTS
Five e2e regressions, marker `// REGRESSION B3:`: the breakdown doc's repro chain verbatim (init/add ./commit/status/extras); an `addremove` variant with symlinks sorting BOTH before and after the regular file the sweep also yields, so a filter that latched off the previous item's kind fails one of the two; explicit `add sym.txt` refusing with the symlink wording and tracking nothing; the wedge-recovery chain (pre-tracked symlink -> commit names `zit rm` -> rm -> commit succeeds -> status clean); and a `clean --dry-run` contract-partner guard that was green before the fix and stays green through it, proving clean still lists a symlink as an extra after the `Extras` type change. A unit assertion in `extras.zig` pins the tag itself, and `commitNotRegular`'s renderer test covers both branches.
Two pre-existing e2e expectations changed, both because they encoded the bug or its wording:
- `cli addremove B-addremove-2` asserted the ADDED set was byte-identical to `zit extras`, symlink included. That identity IS the defect. It now asserts the add set is the extras set MINUS symlinks, and additionally that the symlink remains an extra. - `cli add B-add-5` asserted the fifo-generic wording for a named symlink. It now asserts the symlink-specific wording.
Four `commit` invocations inside the new B3 tests use `runCommitEnv` with an explicit user rather than bare `runZit`; the isolated e2e environment has no resolvable author, which is a harness requirement, not a behavior change.
TEST EVIDENCE
In this worktree at the committed tree: `zig fmt --check .`, `zig build`, `zig build test`, `zig build e2e` (417 tests) and `zig build dst` each exit 0 — full battery, no filtering. Pre-fix the four B3 repro tests failed and the `clean --dry-run` guard passed, as designed.
No borrowed technique: the entry-tag shape is original to this change. No performance claim is made. (user: dmestas, size: 28787) [annotate] [blame] [check-ins using] [more...]
//! The extras enumeration query: the ONE deep sweep module behind `zit extras` //! (contracts/extras.md, B-extras-1..18). It walks the checkout tree once with //! explicit traversal bounds, collects every on-disk regular file or symlink //! whose root-relative path is neither tracked (the managed set) nor a //! never-tracked control entry, and returns them sorted ascending by bytes. //! //! Deep-module boundary (B-extras-18): the sweep takes an injected `std.Io`, an //! allocator, an opened ITERABLE checkout-root dir, and the managed set — no //! ambient filesystem, no `std.http`, no clock or randomness. It reads only the //! tree it is handed and yields the sorted list; the verb layer only renders, //! and `zit clean` consumes the SAME sweep unchanged (a clean that re-walks the //! tree is an automatic revise). const std = @import("std"); const zit = @import("zit"); const managed = zit.managed; const checkout = zit.checkout; const update = zit.update; const bisect = zit.bisect; const assert = std.debug.assert; /// `.zit-pack-tmp`, the pack-scratch name. Owned here so the single control /// predicate (`isControlName`) and the pack writer name it from one place; /// `main.pack_tmp_filename` references this const (B-extras-5). pub const pack_tmp_filename: []const u8 = ".zit-pack-tmp"; /// `.zit-cache`, the repo-cache name. The authoritative definition lives in the /// library-internal `repocache.zig` (`repocache.cache_filename`), which is not /// re-exported through the public `zit` surface, so the never-tracked predicate /// mirrors the byte value here rather than importing across the CLI/library /// boundary. Both spellings are one literal; a change to either is a review /// tripwire (B-extras-5). pub const cache_filename: []const u8 = ".zit-cache"; /// Total directory entries the walk may EXAMINE before it gives up. Reuses the /// managed set's entry ceiling: a checkout with more loose entries than the set /// could ever track is not one extras enumerates silently. Over this bound the /// sweep RETURNS `error.TooMany` (a handled user-content condition, never an /// abort) — the same shape `managed.zig` uses (B-extras-7 TigerStyle bound). const max_entries: usize = managed.max_entries; /// Descent-depth ceiling. Deeper than any real checkout; a legitimately deeper /// tree fails cleanly with `error.TooDeep`, it does not recurse without a bound /// (B-extras-7). The DEPTH is the number of directory levels below the root. const max_depth: usize = 1024; /// The iterable, no-follow open options every descended directory uses — the /// same flags the checkout-tree walkers elsewhere open with, so a symlinked /// directory is never entered and the walk stays inside the real tree. const iter_open_opts: std.Io.Dir.OpenOptions = .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }; /// Closed sweep error set. There is deliberately NO "empty"/"not-found" /// variant: an empty checkout and a checkout with no extras both yield an empty /// `Extras` (B-extras-10). The ceilings are HANDLED errors, not asserts — a /// legitimately deep or huge checkout is ordinary user content, not corruption, /// and must never abort `zit`. pub const SweepError = error{ /// A readdir/openDir/stat failed. Storage, /// Allocation failed. OutOfMemory, /// Entries examined exceeded `max_entries`. TooMany, /// Descent depth exceeded `max_depth`. TooDeep, }; /// One swept extra: its root-relative path plus the ONE bit of the walk's /// classification a caller cannot cheaply recover — whether the on-disk entry /// is a symlink. The walk already lstat-classified every entry (`resolveKind`); /// carrying the verdict here keeps kind classification inside this deep module /// instead of forcing `add`/`addremove` to re-stat and re-decide (B-add-5 and /// commit's symlink refusal must agree with this bit, not re-derive it). /// Readers that treat every extra alike (`extras`, `clean`) ignore the tag. pub const Entry = struct { /// Root-relative, clean (no leading `/`, no `./`, no trailing `/`). path: []const u8, /// The entry is a symlink; 0.1.0 does not version symlinks, so the /// tracking verbs skip these while the enumerating verbs still list them. is_symlink: bool, }; /// A sorted, unique, owned list of root-relative extras. Owns every path byte; /// the caller calls `deinit`. Empty is a value, never an error (B-extras-10). pub const Extras = struct { gpa: std.mem.Allocator, /// Strictly ascending by `path` bytes, unique. Each path heap-owned. entries: std.ArrayList(Entry), /// An empty result. Allocates nothing. pub fn init(gpa: std.mem.Allocator) Extras { return .{ .gpa = gpa, .entries = .empty }; } /// Frees every owned path and the backing array. pub fn deinit(self: *Extras) void { for (self.entries.items) |e| self.gpa.free(e.path); self.entries.deinit(self.gpa); self.* = undefined; } /// The sorted extras, ascending by path bytes. Borrows `self`. pub fn items(self: *const Extras) []const Entry { return self.entries.items; } }; /// The authoritative never-tracked set (single-component root names, B-extras-5). /// A STRICT SUPERSET of the shipped add/rm control-file refusal (`.zit`, /// `.zit-checkout`, `.zit-managed`, `.zit-undo`), extended with the two /// transient artifacts `.zit-cache` and `.zit-pack-tmp` and the checkout-local /// bisection state `.zit-bisect`. Each name references its defining const so the /// tracked/never-tracked boundary cannot drift; the two artifact names are owned /// above. Pure; no I/O, no allocation. pub fn isControlName(name: []const u8) bool { return std.mem.eql(u8, name, zit.repo_filename) or // .zit std.mem.eql(u8, name, checkout.filename) or // .zit-checkout std.mem.eql(u8, name, managed.filename) or // .zit-managed std.mem.eql(u8, name, update.journal.filename) or // .zit-undo std.mem.eql(u8, name, bisect.filename) or // .zit-bisect std.mem.eql(u8, name, cache_filename) or // .zit-cache std.mem.eql(u8, name, pack_tmp_filename); // .zit-pack-tmp } /// Walk `root` (an opened ITERABLE checkout-root dir), collecting every on-disk /// regular file or symlink whose root-relative path is neither in `tracked` nor /// a root-level never-tracked control entry, sorted ascending by bytes. /// Read-only; all I/O via `io`. The public entry uses the module ceilings; the /// bounds are a separate parameter only so the ceiling tests can lower them. pub fn sweep( gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, tracked: *const managed.ManagedSet, ) SweepError!Extras { return sweepBounded(gpa, io, root, tracked, max_entries, max_depth); } /// The walk context, threaded down the recursion so each frame carries the /// shared allocator, io, tracked set, output list, and the running entry /// counter plus the two ceilings. Keeps `sweepDir` a pure structural walk. const Context = struct { gpa: std.mem.Allocator, io: std.Io, tracked: *const managed.ManagedSet, list: *std.ArrayList(Entry), visited: usize, entry_ceiling: usize, depth_ceiling: usize, }; fn sweepBounded( gpa: std.mem.Allocator, io: std.Io, root: std.Io.Dir, tracked: *const managed.ManagedSet, entry_ceiling: usize, depth_ceiling: usize, ) SweepError!Extras { var result = Extras.init(gpa); errdefer result.deinit(); var ctx: Context = .{ .gpa = gpa, .io = io, .tracked = tracked, .list = &result.entries, .visited = 0, .entry_ceiling = entry_ceiling, .depth_ceiling = depth_ceiling, }; try sweepDir(&ctx, root, "", 0); std.mem.sort(Entry, result.entries.items, {}, lessThanPath); // Postconditions (positive AND negative space, B-extras-2/3/4/5): the list // is strictly ascending and unique, every path is a clean root-relative // form, none is tracked, and no depth-0 name is a control entry. assert(strictlyAscending(result.entries.items)); for (result.entries.items) |e| { const p = e.path; assert(p.len != 0); assert(p[0] != '/'); assert(p[p.len - 1] != '/'); assert(!std.mem.startsWith(u8, p, "./")); assert(!tracked.contains(p)); assert(!isControlName(firstComponent(p))); } return result; } /// One directory level. Iterates `dir`'s entries, classifying each: a file or /// symlink is collected (its own root-relative path), a directory is descended /// (unless a root-level control dir), a special file is neither. The `visited` /// ceiling bounds the loop; `depth` bounds the descent (B-extras-6/7/8/9). fn sweepDir(ctx: *Context, dir: std.Io.Dir, prefix: []const u8, depth: usize) SweepError!void { if (depth >= ctx.depth_ceiling) return error.TooDeep; var it = dir.iterate(); while (it.next(ctx.io) catch return error.Storage) |entry| { ctx.visited += 1; if (ctx.visited > ctx.entry_ceiling) return error.TooMany; const full = joinRootRel(ctx.gpa, prefix, entry.name) catch return error.OutOfMemory; var keep = false; defer if (!keep) ctx.gpa.free(full); // Root-level control entries are neither emitted nor descended: control // files are excluded and control directories pruned (B-extras-5/-6). // Below the root the names carry no special meaning. if (depth == 0 and isControlName(entry.name)) continue; const kind = try resolveKind(ctx, dir, entry); switch (kind) { // A regular file OR a symlink is an extra iff its root-relative path // is not tracked (B-extras-4): the contract's extras set subtracts // the managed set from BOTH kinds. A tracked path never appears even // when the on-disk entry is a symlink whose name matches a managed // key. A symlink is listed by its own path but never followed — no // descent, no target resolution (B-extras-9). .file, .sym_link => { if (ctx.tracked.contains(full)) continue; ctx.list.append( ctx.gpa, .{ .path = full, .is_symlink = kind == .sym_link }, ) catch return error.OutOfMemory; keep = true; }, .directory => { var child = dir.openDir(ctx.io, entry.name, iter_open_opts) catch return error.Storage; defer child.close(ctx.io); try sweepDir(ctx, child, full, depth + 1); // The directory itself is never emitted (B-extras-8); only files // within it can appear. `full` is freed by the defer above. }, // A block/char/fifo/socket special file is not an extra by the // contract's definition (a regular file or symlink) and not a // directory to descend — a correct-by-definition exclusion. else => {}, } } } /// The entry's kind, trusting readdir only for the three kinds it names /// reliably. On a filesystem whose readdir returns `.unknown` (DT_UNKNOWN), a /// real file or symlink is stat-classified with a no-follow lstat rather than /// dropped — silently skipping it would violate B-extras-1/-9 and TigerStyle's /// no-silent-truncation. A stat failure surfaces as `error.Storage`. fn resolveKind(ctx: *Context, dir: std.Io.Dir, entry: std.Io.Dir.Entry) SweepError!std.Io.File.Kind { if (entry.kind == .file or entry.kind == .sym_link or entry.kind == .directory) return entry.kind; const st = dir.statFile(ctx.io, entry.name, .{ .follow_symlinks = false }) catch return error.Storage; // A stat that still cannot name the kind is an impossible platform state — // a real file/symlink is already classified by lstat. assert(st.kind != .unknown); return st.kind; } /// Builds a root-relative path from a parent `prefix` and an entry `name` /// (B-extras-3): a root file (empty prefix) is its bare name — no separator /// prepended — a nested file is `prefix ++ "/" ++ name`. `/` separators, no /// leading `./`, no leading `/`. Owned by the caller. fn joinRootRel(gpa: std.mem.Allocator, prefix: []const u8, name: []const u8) error{OutOfMemory}![]u8 { if (prefix.len == 0) return gpa.dupe(u8, name); const out = try gpa.alloc(u8, prefix.len + 1 + name.len); @memcpy(out[0..prefix.len], prefix); out[prefix.len] = '/'; @memcpy(out[prefix.len + 1 ..], name); return out; } /// The first `/`-delimited component of a root-relative path — the depth-0 name. /// For a root file this is the whole path; for `sub/dir/name` it is `sub`. fn firstComponent(path: []const u8) []const u8 { const slash = std.mem.indexOfScalar(u8, path, '/') orelse return path; return path[0..slash]; } fn lessThanPath(_: void, a: Entry, b: Entry) bool { return std.mem.lessThan(u8, a.path, b.path); } /// Whether `entries` is strictly ascending by path bytes (hence sorted and /// unique). The `is_symlink` tag never participates in the order. fn strictlyAscending(entries: []const Entry) bool { if (entries.len < 2) return true; for (entries[1..], entries[0 .. entries.len - 1]) |cur, prev| { if (std.mem.order(u8, prev.path, cur.path) != .lt) return false; } return true; } // --------------------------------------------------------------------------- // Tests. Oracle: contracts/extras.md (B-extras-N). Expected values derive from // the clause text and inline ASCII byte values, never from running fossil. // Tests bind `std.testing.io` + a `tmpDir`; the sweep is handed an iterable // re-open of the tmp root. // --------------------------------------------------------------------------- const testing = std.testing; /// Opens `tmp`'s root as an iterable, no-follow handle for the sweep — the same /// shape the verb hands `sweep` after re-opening `probeRoot`'s non-iterable /// handle. Caller closes. fn openIterable(io: std.Io, tmp: std.Io.Dir) !std.Io.Dir { return tmp.openDir(io, ".", iter_open_opts); } /// An empty managed set (nothing tracked) for the common case. fn emptyManaged() managed.ManagedSet { return managed.ManagedSet.init(testing.allocator); } /// Asserts the sweep's items equal `expected` exactly, in order. fn expectItems(result: *const Extras, expected: []const []const u8) !void { try testing.expectEqual(expected.len, result.items().len); for (expected, result.items()) |want, got| try testing.expectEqualStrings(want, got.path); } test "extras B-extras-2/3: collect-then-sort yields byte-lexicographic order" { // On disk: file `b`, file `a.txt`, dir `a/` holding file `z`; none tracked. // ASCII: `a.txt` vs `a/z` differ at index 1, `.`=0x2E < `/`=0x2F ⇒ `a.txt` // sorts before `a/z`; both precede `b` (0x62). A naive DFS that descends `a` // before emitting the sibling `a.txt` would emit `a/z` first — the wrong, // enumeration-order-dependent answer this ordering rules out. const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try touchAt(io, tmp.dir, "b"); try touchAt(io, tmp.dir, "a.txt"); try tmp.dir.createDir(io, "a", .default_dir); try touchAt(io, tmp.dir, "a/z"); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try expectItems(&result, &.{ "a.txt", "a/z", "b" }); } test "extras B-extras-4: a tracked file on disk is never collected" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try touchAt(io, tmp.dir, "keep"); try touchAt(io, tmp.dir, "loose"); var set = emptyManaged(); defer set.deinit(); _ = try set.insert("keep"); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try expectItems(&result, &.{"loose"}); } test "extras B-extras-5: root control entries are excluded" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // Every never-tracked control FILE at the root, plus one real file. for ([_][]const u8{ ".zit-cache", ".zit-managed", ".zit-checkout", ".zit-undo", ".zit-bisect", "real" }) |name| try touchAt(io, tmp.dir, name); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try expectItems(&result, &.{"real"}); } test "extras B-extras-6: control directories are pruned, not descended" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // `.zit/` and `.zit-pack-tmp/`, each holding a file that must never leak. try tmp.dir.createDir(io, ".zit", .default_dir); try touchAt(io, tmp.dir, ".zit/object"); try tmp.dir.createDir(io, ".zit-pack-tmp", .default_dir); try touchAt(io, tmp.dir, ".zit-pack-tmp/scratch"); try touchAt(io, tmp.dir, "visible"); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try expectItems(&result, &.{"visible"}); } test "extras B-extras-7: files at depth are found" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "sub/dir"); try touchAt(io, tmp.dir, "sub/dir/name"); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try expectItems(&result, &.{"sub/dir/name"}); } test "extras B-extras-8: an empty directory contributes nothing" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDir(io, "d", .default_dir); // wholly empty var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try testing.expectEqual(@as(usize, 0), result.items().len); } test "extras B-extras-9: a symlink is listed but never followed" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // A real dir `target/` holding `inner`, and a symlink `ln` → `target`. try tmp.dir.createDir(io, "target", .default_dir); try touchAt(io, tmp.dir, "target/inner"); tmp.dir.symLink(io, "target", "ln", .{}) catch |e| switch (e) { // A platform/sandbox that cannot create symlinks cannot exercise this. error.AccessDenied, error.PermissionDenied => return error.SkipZigTest, else => return e, }; var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); // `ln` is listed (its own path); the walk never descends it, so `ln/inner` // never appears. `target/inner` (a real file) does. try expectItems(&result, &.{ "ln", "target/inner" }); // The walk's kind verdict rides out on the entry so `add`/`addremove` can // skip symlinks without re-stat'ing (B-add-5 / 0.1.0 symlink refusal): the // symlink carries the tag, the real file does not. try testing.expect(result.items()[0].is_symlink); try testing.expect(!result.items()[1].is_symlink); } test "extras B-extras-4: a tracked symlink on disk is never collected" { // The managed set is a set of path strings, never re-validated against the // on-disk kind: a path tracked while it was a regular file stays tracked if // the user later replaces it with a symlink of the same name. The extras set // subtracts the managed set from symlinks as well as files (B-extras-4), so // the tracked symlink must not appear and must not trip the postcondition. const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try touchAt(io, tmp.dir, "target"); // a link target so the entry resolves tmp.dir.symLink(io, "target", "keep", .{}) catch |e| switch (e) { error.AccessDenied, error.PermissionDenied => return error.SkipZigTest, else => return e, }; try touchAt(io, tmp.dir, "loose"); var set = emptyManaged(); defer set.deinit(); _ = try set.insert("keep"); // tracked, though on disk it is now a symlink var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); // `keep` (tracked symlink) is excluded; `target` and `loose` remain. try expectItems(&result, &.{ "loose", "target" }); } test "extras B-extras-10: no extras yields an empty list" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // A checkout whose only on-disk files are all tracked ⇒ zero extras. try touchAt(io, tmp.dir, "a"); try touchAt(io, tmp.dir, "b"); var set = emptyManaged(); defer set.deinit(); _ = try set.insert("a"); _ = try set.insert("b"); var root = try openIterable(io, tmp.dir); defer root.close(io); var result = try sweep(gpa, io, root, &set); defer result.deinit(); try testing.expectEqual(@as(usize, 0), result.items().len); } test "extras B-extras-7: max_entries ceiling returns error.TooMany, not a panic" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); for ([_][]const u8{ "f0", "f1", "f2", "f3" }) |name| try touchAt(io, tmp.dir, name); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); // A ceiling below the entry count is a clean handled error, never a crash. try testing.expectError(error.TooMany, sweepBounded(gpa, io, root, &set, 2, max_depth)); } test "extras B-extras-7: max_depth ceiling returns error.TooDeep, not a panic" { const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "a/b/c"); try touchAt(io, tmp.dir, "a/b/c/deep"); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); // A ceiling shallower than the tree fails cleanly (exit 1 at the verb). try testing.expectError(error.TooDeep, sweepBounded(gpa, io, root, &set, max_entries, 2)); } test "extras isControlName: names the full never-tracked superset" { // The STRICT SUPERSET of add/rm's isControlFile: the four already-refused // names plus the two transient artifacts. Proves the delegation preserves // every add/rm refusal AND adds `.zit-cache`/`.zit-pack-tmp` (B-extras-5). const control = [_][]const u8{ ".zit", ".zit-checkout", ".zit-managed", ".zit-undo", ".zit-bisect", ".zit-cache", ".zit-pack-tmp", }; for (control) |name| try testing.expect(isControlName(name)); // A bare `zit`, a nested control-looking path's non-root component, and a // plain name are NOT control (the predicate is a single-component root test). const ordinary = [_][]const u8{ "zit", "sub/.zit-cache", "sub", ".zit-other", "" }; for (ordinary) |name| try testing.expect(!isControlName(name)); } test "extras B-extras-9: an unknown-kind entry is stat-classified, not dropped" { // `resolveKind` maps a readdir kind that is not one of the three trusted // kinds through a no-follow lstat rather than dropping it. A real file whose // `entry.kind` is forced to `.unknown` must still resolve to `.file`. const gpa = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try touchAt(io, tmp.dir, "u"); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); var ctx: Context = .{ .gpa = gpa, .io = io, .tracked = &set, .list = undefined, .visited = 0, .entry_ceiling = max_entries, .depth_ceiling = max_depth, }; const forced: std.Io.Dir.Entry = .{ .name = "u", .kind = .unknown, .inode = 0 }; try testing.expectEqual(std.Io.File.Kind.file, try resolveKind(&ctx, root, forced)); } fn sweepNoLeak(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, set: *const managed.ManagedSet) !void { var result = try sweep(gpa, io, dir, set); defer result.deinit(); } test "extras sweep: allocation failure propagates without leaking" { const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try touchAt(io, tmp.dir, "a.txt"); try tmp.dir.createDir(io, "sub", .default_dir); try touchAt(io, tmp.dir, "sub/b"); try touchAt(io, tmp.dir, "c"); var set = emptyManaged(); defer set.deinit(); var root = try openIterable(io, tmp.dir); defer root.close(io); try testing.checkAllAllocationFailures(testing.allocator, sweepNoLeak, .{ io, root, &set }); } test "extras sweep fuzz: never crashes and always returns a valid or declared-error result" { // Seeded-deterministic fuzz over random real trees: whatever shape the // filesystem takes (nested dirs, control-looking names, symlink loops back // to the root, mixed kinds), the bounded walk must yield a sorted+unique // extras list or a declared `SweepError` — never a crash, and never an // unbounded descent (symlinks are listed, never followed — B-extras-2/-7/-9). const gpa = testing.allocator; const io = testing.io; var prng = std.Random.DefaultPrng.init(0x2117_e547_a500); const rand = prng.random(); const names = [_][]const u8{ "a", "b", "c", "a.txt", ".zit-cache", ".hidden", "d" }; var iter: usize = 0; while (iter < 64) : (iter += 1) { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // Build a small random tree: at most a handful of dirs and files, plus // an occasional symlink pointing back up (a cycle the walk must not // follow). Every loop here has an explicit fixed bound. var dir_i: usize = 0; const dir_count = rand.intRangeAtMost(usize, 0, 3); while (dir_i < dir_count) : (dir_i += 1) { const d = names[rand.intRangeLessThan(usize, 0, names.len)]; tmp.dir.createDir(io, d, .default_dir) catch {}; const f = names[rand.intRangeLessThan(usize, 0, names.len)]; var buf: [64]u8 = undefined; const path = std.fmt.bufPrint(&buf, "{s}/{s}", .{ d, f }) catch continue; touchAt(io, tmp.dir, path) catch {}; } var file_i: usize = 0; const file_count = rand.intRangeAtMost(usize, 0, 4); while (file_i < file_count) : (file_i += 1) { const f = names[rand.intRangeLessThan(usize, 0, names.len)]; touchAt(io, tmp.dir, f) catch {}; } if (rand.boolean()) tmp.dir.symLink(io, ".", "loop", .{}) catch {}; var set = emptyManaged(); defer set.deinit(); var root = openIterable(io, tmp.dir) catch continue; defer root.close(io); if (sweepBounded(gpa, io, root, &set, max_entries, max_depth)) |res_val| { var res = res_val; defer res.deinit(); try testing.expect(strictlyAscending(res.items())); for (res.items()) |e| { const p = e.path; try testing.expect(p.len != 0); try testing.expect(p[0] != '/'); try testing.expect(!std.mem.startsWith(u8, p, "./")); } } else |err| switch (err) { error.Storage, error.OutOfMemory, error.TooMany, error.TooDeep => {}, } } } /// Creates an empty regular file at `path` under `dir` (parents must exist). fn touchAt(io: std.Io, dir: std.Io.Dir, path: []const u8) !void { var f = try dir.createFile(io, path, .{}); f.close(io); }