diff --git a/CHANGELOG.md b/CHANGELOG.md index 18564acd..e8f1f183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -657,6 +657,43 @@ below are both part of that coordination, and the latter BLOCKS this release. ### Fixed +- **Every scan-issue row now states a condition that is true for the + producer that emitted it** (fn-4.12 — the PR #459 codex r13 sweep, run + over every OTHER scanner's producers; the app's visible row label is + derived from `scanner_errors[].kind` alone, so a kind shared with a + different condition prints a false diagnosis). All ADDITIONS to the same + extensible enumeration (`schema_version` stays 4); no wire STRING is + renamed, but the kind a given condition reports under moves, so consumers + keying kinds to conditions must re-key: **(1)** a configured dev root + refused by the search-root safety policy — persisted, or via `--dev-root` + — is now `"policy_refused_root"` ("refused by the search-root safety + policy"); it was `"container_refused"`, whose label "not a configured + search root" contradicted the row's own detail ("configured dev root + refused: …"). **(2)** a configured dev root with a volume mounted exactly + at it is now `"mounted_volume_root"`, whose label names the one remedy a + re-scan honors (the walk re-reads the kernel mount table every scan). + **(3)** a dev root or `~/Library/Caches` sweep root standing as a regular + file, FIFO, socket or device is now `"non_directory_root"`; it was + `"symlink_root"`, which sent the user hunting for a link that was not + there — `"symlink_root"` now means a symlink and nothing else, in every + scanner. **(4)** a git worktree or repository admin directory withheld + because git's cleanup would modify paths not all inside ONE configured + dev root is the NEW `"mutation_scope_refused"` ("git cleanup is not + contained in one dev root — not offered"); it was `"container_refused"` + while the worktree in question IS inside a configured root. A worktree + outside EVERY configured root keeps `"container_refused"` — there the + label is exactly the condition. **(5)** a BARE-errno EPERM (raw + `lstat`/`open` probes) is now neutral `"unreadable"` everywhere, with the + detail saying the cause could not be established; it was `"tcc_denied"` + in the dev-root walk and the orphaned-caches sweep — printing the "Grant + access…" (Full Disk Access) remedy on a guess — while the temp scanner + already classified the same errno as unknowable (a bare errno carries no + provenance; TCC, SIP and other filesystem refusals are indistinguishable + in it). A chain-proven EPERM — recovered from a Cocoa error's + `NSUnderlyingErrorKey` chain — still reports `"tcc_denied"` with the + grant hint, which is the one place the claim is establishable. The two + scanners that disagreed on bare EPERM now share one recorded rule + (`DirectorySizer.denial(forFailedProbe:)`). - **A scan could hang before it started, with the spinner up and no way to stop it.** The first thing a scan does after marking itself in progress is refresh the free-space figures in the header. That refresh was unbounded: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 814ac52c..5a32153c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,15 @@ CacheCategory( `--confirm` — preview safely with `--cli clean --dry-run`) 5. Submit a PR with a clear description +> **A green tally is not a green run.** A trapping construct (`as!`, `try!`, +> a force-unwrap, an out-of-range subscript) kills the whole test process, +> and every `Executed N tests … 0 failures` line printed BEFORE the kill +> stays in the log — one truncated run showed a passing tally while ~26 +> later suites never executed (fn-4.14). When reading a `swift test` log, +> trust only the process EXIT CODE and the final executed COUNT compared +> against the expected baseline, never a greppable `0 failures` line. +> `StrandFenceTests` fences the trapping shapes out of test sources. + ## Documentation Full technical documentation is in [docs/v1/](docs/v1/): diff --git a/PROTOCOL.md b/PROTOCOL.md index 84d6da1f..722295d6 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -246,8 +246,8 @@ refreshes. "scanner_errors": [ { "scanner_id": "build_artifacts", - "kind": "container_refused", - "detail": "dev root is not a usable container: the filesystem root", + "kind": "policy_refused_root", + "detail": "configured dev root refused: the filesystem root", "path": "/" }, { @@ -292,7 +292,7 @@ refreshes. | Field | Type | Required | Description | |-------|------|----------|-------------| | `scanner_id` | string | yes | Which scanner reported (or failed validation) | -| `kind` | string | yes | One of: `"container_refused"`, `"mounted_volume_root"`, `"mounted_volume_root_at_registration"`, `"policy_refused_root"`, `"symlink_root"`, `"non_directory_root"`, `"tcc_denied"`, `"permission_denied"`, `"unreadable"`, `"enumeration_truncated"`, `"config_invalid"`, `"tool_unavailable"`, `"malformed_outcome"`, `"scan_did_not_finish"`. The list is EXTENSIBLE — consumers must tolerate unknown kinds | +| `kind` | string | yes | One of: `"container_refused"`, `"mounted_volume_root"`, `"mounted_volume_root_at_registration"`, `"policy_refused_root"`, `"mutation_scope_refused"`, `"symlink_root"`, `"non_directory_root"`, `"tcc_denied"`, `"permission_denied"`, `"unreadable"`, `"enumeration_truncated"`, `"config_invalid"`, `"tool_unavailable"`, `"malformed_outcome"`, `"scan_did_not_finish"`. The list is EXTENSIBLE — consumers must tolerate unknown kinds | | `detail` | string | yes | Human-readable description | | `path` | string | conditional | Present for the FILESYSTEM kinds; ABSENT for the NON-FILESYSTEM kinds — `"malformed_outcome"`, `"config_invalid"`, `"tool_unavailable"` and `"scan_did_not_finish"` — where no filesystem location exists and a fake path is therefore never invented | | `grant_hint` | string | no | Present only when `kind == "tcc_denied"` — the same user-side remedy (Full Disk Access) as category and `scanner_items` rows, since macOS denies CLI processes silently | @@ -309,16 +309,18 @@ scan outcome while the corrupt value persists — the fallback is never silent. It carries no `path` because a config parse failure has no honest filesystem location. A configured root that was REJECTED by policy (the filesystem root, a volume root/mount point, `$HOME`) is a different thing -and reports honestly WITH its offending path, under `container_refused` for -the dev-root scanners and under `policy_refused_root` for `ephemeral_tmp`. +and reports honestly WITH its offending path, under `policy_refused_root` +for every scanner that resolves configured roots (dev-root scanners since +fn-4.12; `ephemeral_tmp` since PR #459). A `mounted_volume_root` row means a REGISTERED root has another volume mounted exactly at its path, so whatever is there belongs to that volume rather than to the root. It is deliberately NOT `container_refused`: the root is configured and admissible, nothing rejected it, and the condition is one the user clears — eject or unmount the volume, then re-scan. Emitted -today by `ephemeral_tmp`, which answers from the kernel's mount table before -any syscall touches the root. +today by `ephemeral_tmp` and (since fn-4.12) by the dev-root walk behind +`build_artifacts`/`git_worktrees`; each answers from the kernel's mount +table, re-read every scan, before any syscall touches the root. A `mounted_volume_root_at_registration` row means the same condition was already true when the runtime was CONSTRUCTED, so that root was never @@ -336,18 +338,33 @@ deliberately NOT `container_refused`: a scanner builds its guard from its own roots, so a root that reaches this refusal was configured, and `container_refused` reads as "you did not configure this". `detail` names the clause that fired; there is no single remedy across the clauses. -Emitted today by `ephemeral_tmp`, whose roots (`/private/tmp` and the two -per-user `confstr` containers) are not user-configurable at all. +Emitted today by `ephemeral_tmp` (whose roots — `/private/tmp` and the two +per-user `confstr` containers — are not user-configurable at all) and, +since fn-4.12, by dev-root resolution and the dev-root walk behind +`build_artifacts`/`git_worktrees`, which previously spelled the same +refusals `container_refused` against their own contradicting details. + +A `mutation_scope_refused` row means a DISCOVERED deletable candidate (a +git worktree, or a repository's orphaned worktree admin data) was withheld +because the destructive git operation's whole mutation scope — the paths +git itself would modify plus the parent repository whose records name them +— is not contained in ONE configured dev root. The candidate itself is +often INSIDE a configured root, which is why this is deliberately NOT +`container_refused`: that kind's fixed row label ("not a configured search +root") was a false diagnosis for these producers. `path` names the withheld +candidate; `detail` names which path broke the containment. No remedy is +claimed — where the out-of-scope data sits is the user's layout. Emitted +today by `git_worktrees` (fn-4.12). A `non_directory_root` row means a search root EXISTS and is not a symlink, but is not a directory either — a regular file, FIFO, socket or device stands where a directory is required, and nothing was traversed. It is deliberately NOT `symlink_root`: that kind renders as the fixed sentence "symlinked — not searched", which sends the user hunting for a link that is -not there. `detail` names the object's actual kind. Emitted today by -`ephemeral_tmp`, whose `symlink_root` is therefore now a symlink and -nothing else; the other scanners still spell both conditions -`symlink_root`. +not there. `detail` names the object's actual kind. Emitted by every scanner with a +root gate (`ephemeral_tmp` since PR #459; the dev-root walk, +`orphaned_caches` and dev-root resolution since fn-4.12), so `symlink_root` +now means a symlink and nothing else, everywhere it is emitted. A `tool_unavailable` row means the scan could not run an external tool it depends on, so it produced NO results — today: `git_worktrees` could not diff --git a/Sources/Cacheout/CLIHandler.swift b/Sources/Cacheout/CLIHandler.swift index d1ed1ab0..4a43d96c 100644 --- a/Sources/Cacheout/CLIHandler.swift +++ b/Sources/Cacheout/CLIHandler.swift @@ -474,7 +474,7 @@ struct CLIHandler { context: ScanContext ) async -> CollectedScanEvents { var collected = CollectedScanEvents() - let session = runtime.scanValidatedSession( + let session = await runtime.scanValidatedSession( scannerIDs: scannerIDs, context: context ) collected.snapshot = session.snapshot @@ -1140,8 +1140,10 @@ struct CLIHandler { // The policy's own verdict, surfaced as a usage error (the CLI // attack case: `--dev-root /`). `.configInvalid` cannot occur on the // replacement path — nothing was parsed out of the defaults suite. + // `.policyRefusedRoot` since fn-4.12 — the resolution pipeline's + // refusal kind for a CONFIGURED (here: flag-declared) root. if let refused = resolution.issues.first( - where: { $0.kind == .containerRefused } + where: { $0.kind == .policyRefusedRoot } ) { return .failure(CLIAddressError(message: "\(devRootFlag) \(refused.url?.path ?? "") is not a usable " @@ -2896,8 +2898,33 @@ struct CLIHandler { // Replace the process image; argv[0] becomes the resolved path so the // re-exec'd process' Bundle.main is the real app bundle. - var argv: [UnsafeMutablePointer?] = CommandLine.arguments.map { strdup($0) } - argv[0] = strdup(resolved) + // + // NIL IS argv's TERMINATOR, SO A FAILED COPY IS NOT A LOST ARGUMENT — + // IT IS A DIFFERENT COMMAND (PR #461 merge gate r4, P1). This is the + // same defect the spawn path carried, in the sibling exec path, and + // the fence that now guards `GitCommandRunner` could not see it + // because that fence reads one file. `map { strdup($0) }` wrote a + // failed allocation's nil straight into the vector: a failed copy of + // element k truncates the command there, so `cacheout install-helper` + // invoked through the documented Homebrew symlink re-execs into a + // no-subcommand `cacheout` — and because the process image is already + // replaced by then, nothing can report it. + // + // The `defer` is registered BEFORE the vector is filled, so a failure + // part-way frees what was already copied; on a successful `execv` it + // never runs, because there is no longer a process to run it in. + var argv: [UnsafeMutablePointer?] = [] + defer { argv.forEach { free($0) } } + for text in [resolved] + CommandLine.arguments.dropFirst() { + guard let copy = strdup(text) else { + printError( + "Warning: could not re-exec bundled binary at \(resolved): " + + "out of memory copying arguments — not re-exec'd" + ) + return + } + argv.append(copy) + } argv.append(nil) execv(resolved, argv) // execv only returns on failure — continue and let SMAppService report. diff --git a/Sources/Cacheout/Cleaner/CacheCleaner.swift b/Sources/Cacheout/Cleaner/CacheCleaner.swift index d9c7249a..d5a9217e 100644 --- a/Sources/Cacheout/Cleaner/CacheCleaner.swift +++ b/Sources/Cacheout/Cleaner/CacheCleaner.swift @@ -343,7 +343,16 @@ actor CacheCleaner { ) { self.home = home self.provider = provider - self.sizer = DirectorySizer(provider: provider) + // UNCAPPED on purpose (fn-4.15): this is the DELETE-TIME verification + // sizer, and the mount-doctrine consumers below read the report's + // `mountBoundaries` as "the whole tree was swept" — a capped walk + // could not claim that, so a cap here would have to fail closed, and + // a deterministic cap that blocks deletion is a permanent strand + // (the same tree re-measures to the same cap forever). The pass + // stays bounded by the tree the caller is about to delete over the + // same entries anyway, and it is cancellable per entry; a CANCELLED + // (partial) report is refused by every consumer, not consumed. + self.sizer = DirectorySizer(provider: provider, entryCap: nil) self.pathGuard = PathGuard( home: home, containerRoots: containerRoots, provider: provider ) @@ -798,11 +807,16 @@ actor CacheCleaner { // revalidator does not consider applicable (a sweep entry whose // classifier declined the clean promise, which reaches a deletion // ONLY through conscious per-item confirmation against DISPLAYED - // caution evidence). Neither has an inspection verdict to bind a - // disposal to, so neither gets one — the guards they rest on are the - // container admission, the containment chain, the deny list and the - // mount doctrine. This is not a way to skip the binding; it is the - // absence of anything to bind to. + // caution evidence). Neither has an inspection VERDICT to bind a + // disposal to, so neither gets one. The sentence that used to + // continue here — the guards they rest on are the container + // admission, the containment chain, the deny list and the mount + // doctrine, full stop — under-claimed since fn-4.21: a verdict is + // not the only bindable thing, and the deletion now also binds the + // OBJECT standing at the name when it first reads it + // (`removeGuardedItem`'s `boundTarget`; `deleteGuardedChild`'s + // `boundChild`). What `.unestablished` withholds is any promise + // about the object's CONTENT — only a revalidator can make one. guard let revalidator = preDeleteRevalidators[item.scannerID] else { return .proceed(inspected: .unestablished) } guard item.requiresPreDeleteRevalidation @@ -1127,11 +1141,40 @@ actor CacheCleaner { return .failed(error.localizedDescription) } - // Already gone = skip. Decided by OUR probe: an absent leaf yields an - // EMPTY SizeReport indistinguishable from an empty directory, so - // "already gone" must never be inferred from the report. - if provider.probeKind(of: child) == .absent { + // THE LEAF BINDING, TAKEN AT THE PIPELINE'S FIRST READ OF THE CHILD + // (fn-4.21). Contents mode runs no probe, so no VERDICT about this + // child exists — but an OBJECT does, and this is the moment the + // pipeline first reads it, so this is where what-is-measured and + // what-is-destroyed must be pinned to each other. The child's kind + // and inode are read by `TrashDisposal.boundLeaf` under the SAME + // admitted container the removal is later proved against (the r18 + // `BoundObject` shape, through the same function, so two readings + // cannot disagree), and the permanent arm below re-proves the pair + // on the far side of the queue hop. Measured before this binding + // existed, on the deterministic swap cell: a stranger renamed onto + // the child's own path after the measurement was destroyed with + // `expecting: nil` and the measured tree's 4096 bytes reported + // freed, `errors=[]` + // (`testContentsModeChildSwappedInsideTheWindowIsRefused`). + // + // It also answers "already gone = skip", and answers it from a + // descriptor-relative read rather than a path `lstat`: an absent + // leaf yields an EMPTY SizeReport indistinguishable from an empty + // directory, so "already gone" must never be inferred from the + // report — and an ENOENT here (the leaf, or the whole enumerated + // root, vanished since the listing) is the same skip the path probe + // used to produce. + let boundChild: FileSystemIdentityProvider.ChildFacts + do { + boundChild = try TrashDisposal.boundLeaf( + of: child, containedIn: admittedParent, provider: provider + ) + } catch let failure as DepthSafeRemoval.Failure + where failure.cause == .posix(ENOENT) + { return .skippedAlreadyGone + } catch { + return refusedChild(error, child: child, label: label) } // Measure → register (Phase 1) BEFORE deletion. Known inodes @@ -1142,6 +1185,20 @@ actor CacheCleaner { knownInodes: await registry.knownIdentities ) + // A CANCELLED measurement is a PARTIAL one (fn-4.15): the mount + // check below is only sound over a report that swept the whole tree, + // and a partial claims registration would under-account. Fail closed + // — and say honestly that a retry CAN differ (cancellation is a + // caller act, never a property of this tree). Bound where the fact + // is first read: this is the first consumer of `report`. + if report.cancelled { + let detail = "\(child.path): measurement was cancelled before " + + "the tree was fully inspected — refused, not deleted " + + "(cleaning again re-measures; this refusal is not permanent)" + logRefusal(label: label, tag: "measurement_cancelled", detail: detail) + return .failed(detail) + } + // ANY mount boundary in the measured tree — the child itself, or a // mounted subtree nested anywhere beneath it — refuses the deletion. // The sizer records-and-skips boundaries for SIZING, but `removeItem` @@ -1181,6 +1238,21 @@ actor CacheCleaner { // container, binds the child under that descriptor, and // proves what the disposal actually took on the far side of // a call it cannot be given a descriptor for. + // + // RESIDUAL, STATED RATHER THAN IMPLIED (fn-4.21): this + // binding is taken at the DISPOSAL'S entry, not at the + // pipeline's first read of the child the way the permanent + // arm's now is — `boundChild` above is deliberately NOT + // handed to it. A child swapped between the measurement and + // this call is therefore admitted (`.whateverStandsThere`) + // and MOVED, with the measured bytes reported. What bounds + // that residual is the disposal itself: the move is into the + // user's REVERSIBLE Trash and the after-proof + rollback + // hold, so the cost is a recoverable wrong entry — never the + // unrecoverable destruction the permanent arm's binding + // exists to refuse. Feeding `boundChild` through would need + // a new `LeafAdmission` shape and its own cells; recorded at + // fn-4.21 as out of that task's five-site scope. try await TrashDisposal.dispose( child, containedIn: admittedParent, provider: provider, via: { try await self.trash($0, provingImmediatelyBefore: $1) } @@ -1189,58 +1261,87 @@ actor CacheCleaner { // NO INSPECTION VERDICT TO BIND TO, and the call site says // so. Contents mode runs no user-data probe (only the // orphaned-caches sweep carries a clean promise), so there - // is no inspected object here — which is precisely why the - // CONTAINER binding below is not optional for this arm: with - // `expecting: nil` there is nothing else that can notice the - // folder these children were enumerated from being swapped. + // is no inspected object here — `expecting: nil` is a + // statement of that absence, never a licence. The CONTAINER + // binding is not optional for this arm, and since fn-4.21 it + // is not alone: what `nil` leaves unproved — WHICH OBJECT + // stands at the child's name — is carried by `boundChild`, + // captured at this pipeline's first read of the child. + // + // The claim that stood here — "every proposition this arm + // carries is the container binding, and `DepthSafeRemoval` + // re-proves exactly that on the far side, so nothing further + // crosses the hop" — was the same reasoning PR #460 r18 + // measured FALSE for the worktree seam, and it was false + // here the same way: measured on the deterministic swap + // cell, a stranger renamed onto the child's path inside the + // window was destroyed with the measured tree's bytes + // reported. So this arm's OWN proposition — the leaf it + // measured is the leaf it destroys — rides across in the + // proof closure, re-read by the SAME `boundLeaf` under the + // SAME admitted container immediately before the removal. try await Self.removeItemConcurrently( at: child, expecting: nil, provider: provider, containedIn: admittedParent, - // NOTHING FURTHER TO PROVE PAST THE HOP, STATED RATHER - // THAN DEFAULTED (PR #460 codex r7, D1). Every - // proposition this arm carries is the container binding, - // and `DepthSafeRemoval` re-proves exactly that from a - // descriptor on the far side. The worktree arm passes a - // real closure because it carries three propositions — - // which checkout, the lock, HEAD — that live outside that - // file's vocabulary. - provingImmediatelyBefore: LastInstantProof.nothingFurther.run + provingImmediatelyBefore: Self.provedStillTheBoundLeaf( + boundChild, at: child, + containedIn: admittedParent, provider: provider + ) ) } } catch { - if error is PathGuardError { - logRefusal( - label: label, tag: Self.refusalTag(error), - detail: "\(child.path): \(error.localizedDescription)" - ) - } else if let failure = error as? DepthSafeRemoval.Failure, - failure.cause == .notTheAdmittedContainer { - // The category root these children were enumerated from was - // replaced under the loop. Same tag item mode uses for the - // same event, so the cleanup log has ONE word for it. - logRefusal( - label: label, tag: "container-drift", - detail: "\(child.path): \(error.localizedDescription)" - ) - } else if let failure = error as? TrashDisposal.Failure { - // The swap landed inside `trashItem`'s own resolution, so it - // was caught AFTER the move and undone. Same event as the one - // above, one disposal over — and the same tag item mode uses - // for it, INCLUDING the container case (r13). - logRefusal( - label: label, tag: Self.trashRefusalTag(failure), - detail: "\(child.path): \(error.localizedDescription)" - ) - } // Failed deletions never accept — their registrations remain for // siblings to transfer later (R8). - return .failed(error.localizedDescription) + return refusedChild(error, child: child, label: label) } // Phase 2: transfer canonical bytes exactly once, after success only. return .accepted(await registry.acceptSuccessful(token)) } + /// ONE place that turns a throw from the guarded-child pipeline into the + /// child's outcome (fn-4.21) — the binding's refusal and the deletion's + /// refusal must not be able to word or tag the same event differently. + private func refusedChild( + _ error: Error, child: URL, label: String + ) -> ChildOutcome { + if error is PathGuardError { + logRefusal( + label: label, tag: Self.refusalTag(error), + detail: "\(child.path): \(error.localizedDescription)" + ) + } else if let failure = error as? DepthSafeRemoval.Failure, + failure.cause == .notTheInspectedObject { + // The child at this name is not the object this pipeline bound + // when it first read it (fn-4.21's leaf binding, or a leaf + // binding one disposal down). Same tag item mode uses for the + // same event, so the log has ONE word for a swapped LEAF. + logRefusal( + label: label, tag: "content-drift", + detail: "\(child.path): \(error.localizedDescription)" + ) + } else if let failure = error as? DepthSafeRemoval.Failure, + failure.cause == .notTheAdmittedContainer { + // The category root these children were enumerated from was + // replaced under the loop. Same tag item mode uses for the + // same event, so the cleanup log has ONE word for it. + logRefusal( + label: label, tag: "container-drift", + detail: "\(child.path): \(error.localizedDescription)" + ) + } else if let failure = error as? TrashDisposal.Failure { + // The swap landed inside `trashItem`'s own resolution, so it + // was caught AFTER the move and undone. Same event as the one + // above, one disposal over — and the same tag item mode uses + // for it, INCLUDING the container case (r13). + logRefusal( + label: label, tag: Self.trashRefusalTag(failure), + detail: "\(child.path): \(error.localizedDescription)" + ) + } + return .failed(error.localizedDescription) + } + // MARK: - Item mode (.removeItem, R15) /// Does `target` STILL name the object the pre-delete probe inspected? @@ -1364,15 +1465,75 @@ actor CacheCleaner { return (nil, [Self.itemError(item, error.localizedDescription)]) } - // Deliberately NO already-gone skip here (the frozen ENOENT - // asymmetry): a missing ("ghost") target surfaces as an ITEM-KEYED - // error — its absent leaf measures as an empty report and the - // deletion below reports the ENOENT. The ENOENT skip exists ONLY - // for category children in contents mode. - let report = sizer.measure( - at: target, mode: .deletionTarget, - knownInodes: await registry.knownIdentities - ) + do { + // BOUND BEFORE MEASURED — the order contents mode has always + // used, and item mode had backwards (PR #461 codex r2). + // + // The leaf binding used to be taken AFTER `sizer.measure`. For a + // permanently deleted item whose scanner registers no + // revalidator — every shipped scanner without one — that left a + // window the whole measurement wide: rename the target away and + // install a stranger at the same name, and the binding recorded + // the STRANGER. The far-side proof then succeeded (it proved the + // stranger against itself), the stranger was destroyed, and the + // report credited the ORIGINAL tree's bytes. Measuring one object + // and binding another is exactly the "a path is not an identity" + // class, arrived at through ordering rather than through a path + // check. + // + // `admittedParent` moves up with it, because the leaf is read + // under that descriptor. Taken here it covers strictly MORE than + // it did — everything after the capture is what the binding + // covers, and the measurement is now inside that. + // + // Taken UNCONDITIONALLY, unlike the verdict-shaped binding below. + // `probedObject` cannot be hoisted with it: the revalidation seam + // must run AFTER the mount-boundary check, so it is not known + // yet. The cost is one extra descriptor open for items that DO + // carry a verdict, and one behaviour change for them, stated + // rather than glossed: a ghost target now raises its ENOENT here + // instead of at the removal. That is the same frozen-ENOENT + // reasoning this arm already recorded — the removal's own leaf + // open would have raised the identical + // `Failure(.posix(ENOENT))` a moment later — now applying to + // both arms instead of one. + let admittedParent = try DepthSafeRemoval.admittedParent( + directory: target.deletingLastPathComponent(), + displayPath: target.path, provider: provider + ) + // `try?`, and the fallback below is why. A leaf that cannot be + // bound HERE — the ghost target, an unreadable one — must keep + // raising its failure at the ORIGINAL point, with the original + // error identity: the absent-target arms pin their refusal + // MESSAGE precisely so a fixture "cannot silently degrade into + // testing the other arm", and a hard `try` here re-tagged them. + // This binding therefore only ever ADDS a refusal; it never moves + // one. + let preMeasureLeaf = try? TrashDisposal.boundLeaf( + of: target, containedIn: admittedParent, provider: provider + ) + + // Deliberately NO already-gone skip here (the frozen ENOENT + // asymmetry): a missing ("ghost") target surfaces as an + // ITEM-KEYED error. The ENOENT skip exists ONLY for category + // children in contents mode. + let report = sizer.measure( + at: target, mode: .deletionTarget, + knownInodes: await registry.knownIdentities + ) + + // A CANCELLED measurement is a PARTIAL one (fn-4.15) — same + // fail-closed rule as the category-child arm above, for the same two + // reasons (an unswept mount check, an under-registered claim set), + // and the same honest retryability: a new clean re-measures. + if report.cancelled { + let detail = "\(target.path): measurement was cancelled before " + + "the tree was fully inspected — refused, not deleted " + + "(cleaning again re-measures; this refusal is not permanent)" + logRefusal(label: item.displayName, tag: "measurement_cancelled", + detail: detail) + return (nil, [Self.itemError(item, detail)]) + } // Same mount doctrine as category children (R15): a boundary // anywhere in the measured tree refuses the deletion — @@ -1429,9 +1590,8 @@ actor CacheCleaner { probedObject = inspected == .unestablished ? nil : inspected } - let token = await registry.registerObservations(report.claims) + let token = await registry.registerObservations(report.claims) - do { // WHICH FOLDER HOLDS THIS ITEM — READ FROM A DESCRIPTOR, HERE, // ON THIS SIDE OF THE QUEUE HOP (PR #458 review — the P1). // @@ -1445,15 +1605,70 @@ actor CacheCleaner { // `/proj/node_modules` shape the directory the deletion // actually opens (`proj`) is bound by nothing at all. // - // Taken FIRST, before the rechecks below, because everything - // after the capture is what the binding covers; taken last it - // would cover only the hop. It fails closed and costs nothing to - // do so — the removal performs the identical open a moment later, - // so an open that fails here would have failed there. - let admittedParent = try DepthSafeRemoval.admittedParent( - directory: target.deletingLastPathComponent(), - displayPath: target.path, provider: provider - ) + // Taken FIRST — and since the codex r2 fix above, taken before + // the MEASUREMENT too, which is where it now lives. This note + // stays here because this is where a reader looks for it. + // + // WITH NO LEAF VERDICT, BIND WHAT STANDS AT THE NAME (fn-4.21). + // When `probedObject` is nil no inspection ran, and until this + // binding existed the permanent arm's leaf open was proved + // against NOTHING — the same "the container binding is what + // covers the leaf" reasoning r18 measured false for the worktree + // seam, measured false here too: a stranger renamed onto the + // target's path after the rechecks below was destroyed with + // success reported + // (`testItemModeNilProbeTargetSwappedInsideTheWindowIsRefused`). + // So the object is read HERE — kind+inode under the container + // descriptor just proved, before the rechecks the binding must + // cover — and re-proved by the same read on the far side of the + // hop. What it pins is delete-time standing, not scan-time + // content: only a revalidator can promise content, and a scanner + // that registers none has nothing content-shaped to bind + // (`preDeleteOutcome`'s `.unestablished`). + // + // An ENOENT here is the ghost target's frozen behaviour, one + // read earlier: the removal's own leaf open would have raised + // the identical `Failure(.posix(ENOENT))` a moment later. + // When a verdict DOES exist, the verdict is the leaf binding — + // it travels in `expecting:` and is proved against the opened + // inode by the removal itself, so no second binding is taken. + // The binding taken BEFORE the measurement, so the object whose + // bytes were counted is the object the removal must prove. + // + // AND WHEN NOTHING COULD BE BOUND THEN, NOTHING MAY BE DELETED + // NOW (PR #461 merge gate r4, P4). The `??` that stood here read + // the leaf again at the original point and used whatever it + // found, and its comment claimed that read was "same call, same + // point, same failure". The third clause was false: the re-read + // can SUCCEED, on an object that arrived AFTER the measurement. + // Nothing inspected it, nothing measured it, and binding it + // proved it against itself — so it was destroyed and the item + // reported SUCCESS with no error at all. Measured live, on a + // ghost target with a no-revalidator scanner, before this fix. + // + // The re-read still happens, because a still-absent leaf must + // raise its ENOENT at the original point with the original + // identity — the absent-target arms pin that message so a + // fixture "cannot silently degrade into testing the other arm". + // What changes is what a SUCCESSFUL re-read means: it is a drift + // event, not a target. + let boundTarget: FileSystemIdentityProvider.ChildFacts? + if probedObject != nil { + boundTarget = nil + } else if let bound = preMeasureLeaf { + boundTarget = bound + } else { + // Throws the original failure when the leaf is still absent + // or still unbindable; returns only when something now + // stands where nothing stood. + _ = try TrashDisposal.boundLeaf( + of: target, containedIn: admittedParent, + provider: provider + ) + throw DepthSafeRemoval.Failure( + path: target.path, cause: .notTheInspectedObject, depth: 0 + ) + } // TOCTOU narrowing, immediately pre-delete: the SAME no-follow // + snapshot-identity admission re-runs (a container swapped // between the checks above and here is refused), then the @@ -1528,6 +1743,18 @@ actor CacheCleaner { // disposal binds the leaf under it. Once it does, this arm // rolls back like the other one — which is the second // reason the old note was wrong about itself. + // + // RESIDUAL, STATED (fn-4.21): `boundTarget`, captured + // before the rechecks, is deliberately NOT handed to + // this arm — the disposal re-binds at its own entry, so + // a target swapped between the capture and here is + // admitted and MOVED. Same bound as contents mode's + // Trash arm, for the same reason: the move is into the + // reversible Trash under the after-proof + rollback, so + // the residual's cost is a recoverable wrong entry, and + // closing it needs a new `LeafAdmission` shape with its + // own cells — recorded at fn-4.21 as out of that task's + // five-site scope. try await TrashDisposal.dispose( target, containedIn: admittedParent, provider: provider, @@ -1546,13 +1773,46 @@ actor CacheCleaner { try await Self.removeItemConcurrently( at: target, expecting: probedObject, provider: provider, containedIn: admittedParent, - // NOTHING FURTHER TO PROVE PAST THE HOP (PR #460 codex r7, - // D1): this arm's two propositions are the container - // binding and `probedObject`, and `DepthSafeRemoval` - // re-proves BOTH from descriptors on the far side. - provingImmediatelyBefore: LastInstantProof.nothingFurther.run + // WHAT CROSSES THE HOP DEPENDS ON WHICH BINDING THIS ARM + // HOLDS (fn-4.21; the claim that stood here — "this + // arm's two propositions are the container binding and + // `probedObject`, re-proved on the far side" — was true + // only when `probedObject` existed, and its nil case was + // the defect). With a verdict, the removal re-proves + // container AND leaf itself and nothing further rides + // across. With none, the leaf proposition is + // `boundTarget`, which only this caller can state — so + // it rides across and is re-proved immediately before + // the removal, the same way the worktree seam carries + // its own propositions. + provingImmediatelyBefore: boundTarget.map { + Self.provedStillTheBoundLeaf( + $0, at: target, + containedIn: admittedParent, provider: provider + ) + } ?? LastInstantProof.nothingFurther.run ) } + // ACCEPTED ONLY HERE, inside the same `do` as everything it + // accounts for: the token moved in with the block when the leaf + // binding was hoisted above the measurement (PR #461 codex r2), + // and a failure on any path above still reaches the catch with + // the token abandoned. + let accepted = await registry.acceptSuccessful(token) + logCleanup( + label: "\(item.scannerID)/\(item.displayName)", + bytesFreed: accepted.exactBytes + accepted.estimatedUpToBytes + ) + return ( + CleanupReport.Entry( + itemID: item.id, scannerID: item.scannerID, + displayName: item.displayName, + exactBytes: accepted.exactBytes, + estimatedUpToBytes: accepted.estimatedUpToBytes, + disposal: moveToTrash ? .trash : .permanent + ), + [] + ) } catch { if error is PathGuardError { logRefusal( @@ -1602,22 +1862,6 @@ actor CacheCleaner { // is produced, and no bytes are reported. return (nil, [Self.itemError(item, error.localizedDescription)]) } - - let accepted = await registry.acceptSuccessful(token) - logCleanup( - label: "\(item.scannerID)/\(item.displayName)", - bytesFreed: accepted.exactBytes + accepted.estimatedUpToBytes - ) - return ( - CleanupReport.Entry( - itemID: item.id, scannerID: item.scannerID, - displayName: item.displayName, - exactBytes: accepted.exactBytes, - estimatedUpToBytes: accepted.estimatedUpToBytes, - disposal: moveToTrash ? .trash : .permanent - ), - [] - ) } // MARK: - Composite item mode (.gitWorktreeReclaim, fn-5.4) @@ -1902,6 +2146,40 @@ actor CacheCleaner { } } + /// THE LEAF BINDING'S FAR SIDE, SPELLED ONCE (fn-4.21). For a deletion + /// with NO inspection verdict (`expecting: nil` — all of contents mode, + /// and every item whose scanner registers no revalidator), the caller + /// binds WHAT STANDS AT THE NAME when it first reads it + /// (`TrashDisposal.boundLeaf` under the proved container) and this + /// closure re-reads it THE SAME WAY, under the SAME `admittedParent`, + /// on the far side of the queue hop, immediately before the removal. + /// + /// This is the r18 mechanism (`WorktreeReclaimPerformer.BoundObject`, + /// re-proved in its `LastInstantProof`; `TrashDisposal.disposeBoundLeaf`, + /// step 2), reached through the same function — one reading on each side + /// of the hop, produced identically, so the two cannot disagree about + /// what the object IS. A difference is a swap inside the window and + /// nothing else, and it throws the SAME `.notTheInspectedObject` the + /// verdict-carrying arms throw for the same event, so the report and the + /// log word it identically (`content-drift`). + nonisolated private static func provedStillTheBoundLeaf( + _ bound: FileSystemIdentityProvider.ChildFacts, + at target: URL, + containedIn admittedParent: DepthSafeRemoval.AdmittedParent, + provider: FileSystemIdentityProvider + ) -> () throws -> Void { + { + let atTheInstant = try TrashDisposal.boundLeaf( + of: target, containedIn: admittedParent, provider: provider + ) + guard atTheInstant == bound else { + throw DepthSafeRemoval.Failure( + path: target.path, cause: .notTheInspectedObject, depth: 0 + ) + } + } + } + /// Move one URL to the Trash via the injectable seam (production: /// `FileManager.trashItem`, which requires the main actor), answering /// WHERE IT LANDED — `nil` when the disposal would not say. diff --git a/Sources/Cacheout/Cleaner/DepthSafeRemoval.swift b/Sources/Cacheout/Cleaner/DepthSafeRemoval.swift index 68b6fa98..67872451 100644 --- a/Sources/Cacheout/Cleaner/DepthSafeRemoval.swift +++ b/Sources/Cacheout/Cleaner/DepthSafeRemoval.swift @@ -320,26 +320,50 @@ enum DepthSafeRemoval { /// bind to; it is not a way to skip the check. /// /// THE PARAMETER HAS NO DEFAULT, AND EVERY CALL SITE STATES ITS `nil` - /// (PR #458 review; the enumeration RE-TAKEN at PR #460 codex r18). + /// (PR #458 review; the enumeration RE-TAKEN at PR #460 codex r18, and + /// AGAIN at fn-4.21, which found the r18 take listing three sites of + /// five). /// /// The r458 wording said "both call sites" and closed with "if a third /// call site appears, either it states its `nil` or this paragraph stops /// being true and must change with it". A third appeared at r7 — the /// worktree performer's `removeTree` seam — and the paragraph did not /// change with it, which is the shape it was written to prevent. The - /// standing enumeration, from `grep -rn "expecting:" Sources`: + /// standing enumeration, from `grep -rn "expecting:" Sources` (five call + /// sites; `TrashDisposal.dispose(_:expecting:…)` is the disposal's own + /// spelling of this parameter and binds its leaf itself, on both sides + /// of its own hop): /// - /// 1. `CacheCleaner.deleteGuardedChild` (contents mode) — a literal `nil` - /// under a paragraph saying contents mode runs no probe. SOUND: the - /// population is ENUMERATED CHILDREN of a folder this call opened and - /// proved, so no per-child verdict exists anywhere to bind to, and the - /// container binding (`containedIn`) is what covers the leaf. That is - /// why the container parameter is not optional for this arm. - /// 2. `CacheCleaner.removeGuardedItem` (item mode) — `probedObject`, - /// which is `nil` exactly when the item's scanner registers no - /// revalidator or the seam answered `.unestablished`. SOUND for the - /// same reason and under the same binding; when a verdict DOES exist - /// it is passed and proved here. + /// 1. `CacheCleaner.deleteGuardedChild` (contents mode) — a literal + /// `nil`: contents mode runs no probe, so no VERDICT exists for any + /// enumerated child. The claim that stood here — SOUND, because "the + /// container binding (`containedIn`) is what covers the leaf" — was + /// the same reasoning r18 measured FALSE for the worktree seam, and + /// fn-4.21 measured it false here the same way: a child renamed aside + /// at its own path inside the window, container untouched, left a + /// stranger that this removal destroyed with `expecting: nil` while + /// the measured tree's bytes were reported freed + /// (`testContentsModeChildSwappedInsideTheWindowIsRefused`, red + /// before the binding). No verdict exists, but an OBJECT does: the + /// caller now binds what stands at the name at its FIRST READ of the + /// child (`TrashDisposal.boundLeaf` under this same admitted + /// container) and re-proves it via `provingImmediatelyBefore` on the + /// far side of the hop (`CacheCleaner.provedStillTheBoundLeaf` — the + /// r18 `BoundObject` shape through the same function, so two readings + /// cannot disagree). + /// 2. `CacheCleaner.removeGuardedItem` (item mode, permanent arm) — + /// `probedObject`, which is `nil` exactly when the item's scanner + /// registers no revalidator or the seam answered `.unestablished`. + /// When a verdict exists it is passed and proved here; when it is + /// nil the caller takes the same fn-4.21 binding as contents mode — + /// captured with `admittedParent`, before the delete-time rechecks — + /// and re-proves it past the hop + /// (`testItemModeNilProbeTargetSwappedInsideTheWindowIsRefused`, red + /// before the binding). The item-mode TRASH arm's no-verdict case + /// does not pass through this parameter at all: it takes + /// `TrashDisposal.dispose(_:containedIn:…)`, whose leaf binding is + /// taken at the disposal's own entry and after-proved (a swap in the + /// window before that entry lands in the reversible Trash, not here). /// 3. `CacheCleaner`'s `removeTree` seam for `WorktreeReclaimPerformer` /// — a literal `nil`, because `git_worktrees` registers no /// revalidator either. SOUND ONLY SINCE r18, AND IT WAS NOT BEFORE. @@ -358,7 +382,23 @@ enum DepthSafeRemoval { /// rather than inside it, so what it leaves open is this function's /// own parent open — the identical residual the LOCKED and HEAD-MOVED /// propositions have carried since r7, for the identical reason: none - /// of the three is expressible in this file's vocabulary. + /// of the three is expressible in this file's vocabulary. Since + /// fn-4.21 the two `CacheCleaner` bindings above run in the same + /// place and carry the identical residual. + /// 4. `CacheCleaner.removeItemConcurrently` — the forwarder sites 1–3 + /// reach this file through. It states nothing of its own: it carries + /// its caller's `expecting:` and `provingImmediatelyBefore:` across + /// the queue hop unchanged, which is exactly why the no-verdict + /// bindings ride in the proof closure rather than in a second + /// parameter. + /// 5. `CacheCleaner.removeGuardedItem`'s Trash arm — + /// `TrashDisposal.dispose(_:expecting: probedObject, …)`, reached + /// only inside `if let probedObject`, so `expecting:` is never nil + /// there at runtime; the disposal proves the verdict on both sides of + /// its hop (the fn-4.21 spec's claim that this site passes nil "when + /// no revalidator applies" was retired against the source: the + /// no-revalidator case takes the `containedIn:` overload in the + /// `else` arm). /// /// `provider` answers the mount question, and it is the same object the /// scanner's walk asks, so the two cannot classify a boundary @@ -648,8 +688,15 @@ enum DepthSafeRemoval { provider: FileSystemIdentityProvider, displayPath: String ) throws { - // No inspection ran (contents mode): nothing to bind to. The caller - // states this explicitly. + // No inspection ran: no VERDICT to bind to, and the caller states + // that explicitly. It is not a proof holiday — each of the three + // nil-stating production callers carries its own leaf binding + // (fn-4.21's `boundLeaf` re-read for contents mode and the + // no-revalidator item arm; the worktree performer's `BoundObject`) + // in its `provingImmediatelyBefore` closure, run on this side of + // the hop immediately before `remove` — it cannot run HERE because + // what it binds is not expressible in this file's vocabulary (see + // the standing enumeration above). guard let inspected else { return } // Only a `.directory` verdict about THIS inode admits an opened // directory: `.noDirectoryTree`, `.nonDirectoryLeaf` and diff --git a/Sources/Cacheout/Cleaner/FileSystemIdentityProvider.swift b/Sources/Cacheout/Cleaner/FileSystemIdentityProvider.swift index fbc61b85..e3a8e0d6 100644 --- a/Sources/Cacheout/Cleaner/FileSystemIdentityProvider.swift +++ b/Sources/Cacheout/Cleaner/FileSystemIdentityProvider.swift @@ -533,6 +533,109 @@ class FileSystemIdentityProvider { /// overrides `identity(of:)` for a directory the walk actually OPENS /// must override this in step, or the re-proof sees a divergence /// production cannot produce and refuses. + /// The bytes and identity of a SMALL REGULAR file, read through ONE + /// no-follow descriptor (PR #461 codex r2). + /// + /// Three defects, all of them the same one: the call sites this replaces + /// asked `probeKind` about a PATH, then handed that PATH to + /// `String(contentsOf:)`/`Data(contentsOf:)`, which resolves it again and + /// FOLLOWS symlinks. A `HEAD` or `config` replaced by a symlink between + /// the two reads is then opened through the replacement, so a scan of a + /// dev root can be steered into a TCC-protected or unresponsive target + /// despite a check that just said "regular file". A path is not an + /// identity, and asking twice is asking about two objects. + /// + /// Second, the read was UNBOUNDED. Any directory under a dev root with + /// the cheap bare-repository shape but a multi-gigabyte `HEAD` was loaded + /// and UTF-8 decoded in full before anything decided it was not a + /// repository — a memory spike no scan deadline can cancel, because the + /// read is synchronous. `limit` is checked against the DESCRIPTOR's size + /// and the file is REFUSED rather than truncated: truncating would let a + /// huge file whose first bytes read `ref: refs/…` pass as a valid HEAD. + /// + /// Third, kind and identity now come from the same descriptor as the + /// bytes, so a caller that needs "these bytes belong to THAT object" gets + /// it without a second resolution. + /// + /// `O_NONBLOCK` because `O_NOFOLLOW` alone does not save an open of a + /// FIFO: a named pipe left at one of these names would park the opening + /// thread until a writer appeared. Non-regular kinds are refused by the + /// `fstat` below, but only if the open returns to run it. + /// The two sizes these readers use. A git pointer file — `HEAD`, + /// `gitdir`, `commondir`, a `.git` pointer — is tens of bytes; a repo + /// `config` can legitimately reach a few kilobytes. Both are generous by + /// orders of magnitude, and anything past them is refused, not truncated. + /// THE TWO LIMITS DO NOT FAIL THE SAME WAY, and saying they did was + /// false (PR #461 merge gate r4, P6). + /// + /// `gitConfigByteLimit` is read on ONE path, `bareRepositoryGitDirectory`, + /// whose single caller treats nil as "not a bare repository". A config + /// past that limit leaves the repository merely UNDISCOVERED — the same + /// silence every bare repository had before fn-4.28, and no issue at all. + /// + /// `gitPointerByteLimit` is different at two of its call sites, and both + /// are refusals whose printed remedy is a RE-SCAN: a `.git` pointer past + /// the limit yields `WorktreeReclaimPerformer`'s `ambiguous` string + /// ("Re-scan once that path is settled"), and a `gitdir` back-link past it + /// yields `.incomplete` and then "the prunable set is not provably + /// complete". THE LIMIT IS A FIXED CONSTANT, so for that one cause a + /// retry can never differ — it is a permanent strand wearing a retryable + /// message, the class this project refuses everywhere else. It is kept + /// because the messages are shared with genuinely transient causes and a + /// 64 KiB pointer file is not a shape any git writes; it is disclosed + /// here, and at both sites, rather than implied away. + static let gitPointerByteLimit = 64 * 1024 + static let gitConfigByteLimit = 1024 * 1024 + + func smallRegularFile( + at url: URL, limit: Int + ) -> (bytes: Data, identity: Identity)? { + url.withUnsafeFileSystemRepresentation { pathPointer in + guard let pathPointer else { return nil } + let descriptor = open( + pathPointer, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK + ) + guard descriptor >= 0 else { return nil } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_size >= 0, info.st_size <= limit + else { return nil } + let identity = Identity( + device: UInt64(bitPattern: Int64(info.st_dev)), + inode: UInt64(info.st_ino) + ) + var bytes = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while bytes.count <= limit { + let got = buffer.withUnsafeMutableBytes { + read(descriptor, $0.baseAddress, $0.count) + } + if got < 0 { + if errno == EINTR { continue } + return nil + } + if got == 0 { break } + bytes.append(contentsOf: buffer[0.. String? { + guard let found = smallRegularFile(at: url, limit: limit) else { + return nil + } + return String(data: found.bytes, encoding: .utf8) + } + func identity(ofDescriptor fd: Int32) -> Identity? { var st = stat() guard fstat(fd, &st) == 0 else { return nil } @@ -807,6 +910,63 @@ class FileSystemIdentityProvider { return String(cString: buffer) } + /// `content` as an absolute path, folded LEXICALLY — no syscall of any + /// kind. A relative target is joined to `link`'s own directory (which the + /// caller must hand over parent-canonical); `.` is dropped and `..` pops + /// a component in the STRING, because popping it against the filesystem + /// is precisely the resolution this avoids. + /// + /// Born as `EphemeralTempRoots.lexicalTargetPath` (fn-6.1, PR #459 codex + /// r12); hoisted here in fn-4.11 so the dev-root resolution, the + /// cross-scanner union, and the container-root policy share the ONE + /// folding rule with the temp-root resolution (that symbol now delegates + /// here). + /// + /// `nil` for anything that is not a usable comparison subject: empty + /// content, a `..` that walks off the root, and a target of `/` itself — + /// note the latter two both NAME the filesystem root, and a caller that + /// must refuse such a target (the container-root policy) treats `nil` + /// from non-empty content as exactly that. + static func lexicalTargetPath(ofLink link: URL, content: String) -> String? { + guard !content.isEmpty else { return nil } + let joined = content.hasPrefix("/") + ? content + : link.deletingLastPathComponent().path + "/" + content + var components: [String] = [] + for component in joined.split(separator: "/") { + switch component { + case ".": + continue + case "..": + guard !components.isEmpty else { return nil } + components.removeLast() + default: + components.append(String(component)) + } + } + guard !components.isEmpty else { return nil } + return "/" + components.joined(separator: "/") + } + + /// The absolute path a symlink's content NAMES, or `nil` when `url` is + /// not a readable symlink or the content is not a usable comparison + /// subject (see `lexicalTargetPath`). One `readlink(2)` of the link + /// itself plus the lexical fold above, positioned at the link's + /// PARENT-canonical spelling so a relative target and a canonically + /// declared sibling compare equal. The parent-chain `realpath(3)` never + /// names the destination — only the link's own ancestors. + /// + /// The result is a NAME, never a resolved location: callers compare it, + /// and must never register, walk, or open it (fn-4.11 — the whole point + /// is that `realpath(3)` on a symlink leaf is first contact with + /// whatever answers for the destination). + final func lexicalAliasTarget(of url: URL) -> String? { + guard let content = symlinkTarget(of: url) else { return nil } + let position = canonicalize(url.deletingLastPathComponent()) + .appendingPathComponent(url.lastPathComponent) + return Self.lexicalTargetPath(ofLink: position, content: content) + } + // MARK: - Location comparison /// Same filesystem object? Inode identity when both sides exist (immune to diff --git a/Sources/Cacheout/Cleaner/PathGuard.swift b/Sources/Cacheout/Cleaner/PathGuard.swift index 0025fefd..dc4e7673 100644 --- a/Sources/Cacheout/Cleaner/PathGuard.swift +++ b/Sources/Cacheout/Cleaner/PathGuard.swift @@ -151,7 +151,12 @@ struct CategoryAdmissionPolicy { /// a visible refusal naming the unmount remedy. A mount landing between /// this table read and a capture that already passed is the accepted /// racing residual — the capture's own lstat can then block; no table -/// re-read closes it. +/// re-read closes it, but since fn-4.19 that block costs the session its +/// CAPTURE DEADLINE rather than parking it: `captureBounded` runs the +/// whole loop off the calling thread under a wall-clock budget, and an +/// expiry is reported by the session, never swallowed (a root INSIDE a +/// hung mount — which the table preflight cannot see — is covered by the +/// same budget). struct ContainerSnapshot: Sendable { private let identities: [String: FileSystemIdentityProvider.Identity] @@ -182,6 +187,75 @@ struct ContainerSnapshot: Sendable { return ContainerSnapshot(identities: identities) } + /// A snapshot that captured NOTHING. Admits no container — every + /// delete-time lookup misses, which is the same fail-closed refusal an + /// absent root gets — and exists so the session a timed-out capture + /// produces (`scanValidatedSession`, fn-4.19) can still carry the + /// non-optional snapshot its shape requires without inventing an + /// identity nobody read. + static let empty = ContainerSnapshot(identities: [:]) + + /// The bounded capture's result. `.captured` and `.timedOut` are kept + /// apart — the `BoundedDiskInfo.Outcome` discipline — so a cell cannot + /// pass one while asserting the other. + enum BoundedCapture: Sendable { + case captured(ContainerSnapshot) + case timedOut + } + + /// `capture(roots:provider:)` under a wall-clock budget, OFF the calling + /// thread — the `BoundedDiskInfo.current(within:)` shape (PR #460 codex + /// r14, V2-1) applied to the capture fn-4.19 measured freezing the app: + /// the synchronous loop ran on the MainActor's thread and each root's + /// `lstat` was first contact with whatever answers for that path, so a + /// hung network mount or unresponsive FUSE volume under ANY session root + /// — including a root INSIDE a mount, which the `mountPointPaths()` + /// preflight cannot see — froze the app unbounded and unreported + /// (measured: a 6 s blocking `identity(of:)` gave a 6.03 s `scan` with + /// `isMainThread == true`). + /// + /// The loop now runs in a detached task racing a `ScanSessionClock` + /// timer — off the cooperative pool, because a `Task.sleep` deadline + /// cannot resume while the pool is the thing that is starved — and the + /// caller resumes on its own executor with whichever arrives first. The + /// detached band is `.utility`, the SAME band-separation decision the + /// session producer takes and for the same reason (PR #460 codex r13, + /// B): this is scan work, and cooperative-pool width is per-band, so a + /// saturated consumer band (the shape + /// `testScanIsNotParkedByItsOwnDiskInfoPreambleWhenTheBandIsSaturated` + /// drives) cannot stop the capture from even STARTING — measured in that + /// cell: with the band unspecified here, the capture queued behind the + /// holders and the scan rode this budget instead of finishing. A + /// saturated `.utility` band (concurrent sessions' own walks) is the + /// residual the timer still covers: “cannot start” reports exactly like + /// “started and hung”. + /// + /// WHAT IS NOT CLOSED, stated rather than glossed: a losing capture is + /// ABANDONED, not cancelled — `lstat` takes no deadline, so its thread + /// stays parked until the volume answers, exactly as `BoundedDiskInfo` + /// leaks its losing fetch. The bound converts the hang into a report; it + /// cannot cure the hang. CAN A RETRY DIFFER? Yes — a mount answers or + /// is unmounted, a saturated band frees — which is what makes reporting + /// the expiry as retryable honest (`scanValidatedSession` says how it is + /// reported). + static func captureBounded( + roots: [URL], provider: FileSystemIdentityProvider, + within budget: Duration + ) async -> BoundedCapture { + let rendezvous = FirstWinsRendezvous() + let timer = ScanSessionClock.schedule(after: budget) { + rendezvous.settle(.timedOut) + } + Task.detached(priority: .utility) { + rendezvous.settle( + .captured(capture(roots: roots, provider: provider)) + ) + } + let outcome = await rendezvous.wait() + timer.cancel() + return outcome + } + /// The captured identity for a registered root's declared path spelling; /// nil when the root was absent at capture (refused downstream). func identity( @@ -633,9 +707,31 @@ final class PathGuard { /// The ONE shared container-root admission policy: may `url` serve as a /// configured CONTAINER root (a dev root) at all? Rejects the dangerous /// containers — the filesystem root `/`, any volume root / mount point, - /// and `$HOME` itself — each in canonical AND alias spellings (the URL - /// is canonicalized BEFORE the check, so a symlink alias of `/` or of - /// home is caught; the `$HOME` check is inode identity). + /// and `$HOME` itself — each in canonical AND alias spellings. + /// + /// THE GATE ANSWERS BEFORE `realpath` (fn-4.11 — the fn-4.26 order at + /// this policy's scope). This runs synchronously inside runtime + /// construction, on the main thread, on paths the app does not control: + /// a same-UID process can point a persisted dev root at an unresponsive + /// mounted volume, and the previous canonicalize-first shape made + /// `realpath(3)` — a traversal of everything it resolves, destination + /// included — the app's first contact with that volume, freezing launch + /// before any window existed. So: + /// + /// 1. KERNEL-TABLE PREFLIGHT (`mountPointPaths` — `getfsstat(MNT_NOWAIT)`, + /// no filesystem contact): a `url` that IS an over-mounted path is + /// refused with the same `.deniedVolumeRoot` the canonical check + /// reaches for a healthy mount, and with ZERO calls naming it — + /// `lstat` or `realpath` OF a mount point is served by the mounted + /// filesystem (the r15 finding's mechanism). `/` is exempt: always in + /// the table, not foreign, and it keeps `.deniedFilesystemRoot`. + /// 2. PROBE AS SPELLED (`lstat`, no follow). Only a SYMLINK leaf can + /// make `realpath(3)` name a destination the spelling never wrote; + /// every other kind resolves over objects the probe or the parent + /// chain already touched, so those take the canonical check below + /// unchanged — same verdicts, same error paths. + /// 3. A symlink leaf takes `symlinkContainerRootDenyCheck` — the deny + /// core re-stated over the link's own CONTENT, never its destination. /// /// This is `denyCheck`'s core MINUS the protected-first-level-children /// clause: `~/Documents` and `~/Documents/dev` are LEGAL dev roots (the @@ -648,13 +744,71 @@ final class PathGuard { static func validateContainerRoot( _ url: URL, home: URL, provider: FileSystemIdentityProvider ) throws { - try coreDenyCheck( - provider.canonicalize(url), - resolvedHome: provider.canonicalize(home), - provider: provider + let mounted = Set(provider.mountPointPaths()) + if url.path != "/", mounted.contains(url.path) { + throw PathGuardError.deniedVolumeRoot(path: url.path) + } + guard provider.probeKind(of: url) == .kind(.symlink) else { + try coreDenyCheck( + provider.canonicalize(url), + resolvedHome: provider.canonicalize(home), + provider: provider + ) + return + } + try symlinkContainerRootDenyCheck( + url, home: home, provider: provider, mountTable: mounted ) } + /// The container-root deny core for a SYMLINK-LEAF spelling, decided + /// WITHOUT naming the destination (fn-4.11): one `readlink(2)` of the + /// link itself plus lexical folding at the link's parent-canonical + /// position (the fn-6 `EphemeralTempRoots` technique — + /// `FileSystemIdentityProvider.lexicalTargetPath` is the shared fold), + /// compared against `/`, the kernel mount table, and both spellings of + /// `$HOME`. + /// + /// What ACCEPTANCE means here is unchanged in effect: a symlink leaf can + /// never be walked (the walker's no-follow root gate refuses it), never + /// admits at delete time (`admitContainer`'s no-follow reality gate), + /// and is visibly classified at scan time — acceptance only defers its + /// classification to gates that already hold it inadmissible. + /// + /// RESIDUALS at measured scope, each fail-CLOSED for deletion by those + /// same gates: (a) content that names `/`, `$HOME` or a mount through a + /// spelling this fold cannot equate — a second symlink hop, a case or + /// normalization variant, an unresolved `/var`-style alias — is ACCEPTED + /// here where the old full resolution refused it; (b) a volume root + /// visible only to the device-id signal is not refused (never a real + /// mount — the table names every real mount; the signal exists for + /// injected test devices and the firmlink case, whose mounts the table + /// also names); (c) unreadable or empty link content classifies as + /// naming nothing — the old `canonicalize` ENOENT-fallback accepted + /// exactly the same way. + private static func symlinkContainerRootDenyCheck( + _ url: URL, home: URL, provider: FileSystemIdentityProvider, + mountTable: Set + ) throws { + guard let content = provider.symlinkTarget(of: url) else { return } + let position = provider.canonicalize(url.deletingLastPathComponent()) + .appendingPathComponent(url.lastPathComponent) + guard let target = FileSystemIdentityProvider.lexicalTargetPath( + ofLink: position, content: content + ) else { + // Non-empty content with no foldable target: `/` itself, or + // `..`s that walk off the root — both NAME the filesystem root, + // and this is the same refusal the resolved spelling carried. + throw PathGuardError.deniedFilesystemRoot(path: "/") + } + if mountTable.contains(target) { + throw PathGuardError.deniedVolumeRoot(path: target) + } + if target == home.path || target == provider.canonicalize(home).path { + throw PathGuardError.deniedHomeDirectory(path: target) + } + } + // MARK: - Deny list /// Refusals that apply regardless of any policy. `resolved` must already diff --git a/Sources/Cacheout/Cleaner/TrashDisposal.swift b/Sources/Cacheout/Cleaner/TrashDisposal.swift index 12ae85b0..d5484ada 100644 --- a/Sources/Cacheout/Cleaner/TrashDisposal.swift +++ b/Sources/Cacheout/Cleaner/TrashDisposal.swift @@ -687,14 +687,25 @@ enum TrashDisposal { } } - /// WHAT TO DO NEXT — A CLOSED SET (PR #460 codex r18, E). + /// WHAT TO DO NEXT — A CLOSED SET (PR #460 codex r18, E), AND + /// NOTHING BUT A SELECTOR (fn-4.22). /// /// The remedy used to be the one clause with no constraint on it at /// all: `contract(for: .theRemedyForThisRefusal)` had no `all` and no /// `any`, so it was free text bounded by two word lists, and /// `rescan`'s wording was shared by five of the six causes anyway. - /// It is an enumeration now, so "a remedy nobody wrote down" is not - /// a thing that can be said. + /// r18 made it an enumeration — but left the WORDS on the enum as a + /// `text` property whose only check compared the rendered clause + /// against the enum's OWN text, a tautology any prose satisfies. + /// MEASURED for fn-4.22's task: appending a placing claim ("The item + /// the Trash took is safe in the Trash.") to a remedy wording passed + /// the whole fence. + /// + /// So the enum now carries NO text at all. The remedy's wordings + /// live in `sentence(for:path:landed:remedy:)` with every other + /// proposition's — the one wording table the fence pins BYTE-EXACT — + /// and a remedy clause is exactly as unrepresentable-without-a-pin- + /// edit as any other clause. enum Remedy: String, CaseIterable, Sendable { /// The disposal was refused; the state on disk may have moved, so /// the only sound next step is to look again. @@ -702,16 +713,6 @@ enum TrashDisposal { /// …and for the one cause where the Trash itself is what could /// not be pinned down, the disposal that does not need it. case usePermanentDeleteInstead - - var text: String { - switch self { - case .rescan: - return "Refused; re-scan required." - case .usePermanentDeleteInstead: - return "Use permanent delete (turn off Move to Trash) for " - + "a disposal that proves the folder it acts on." - } - } } /// `default`-less, like every other per-cause table here. @@ -759,6 +760,25 @@ enum TrashDisposal { .theLandingWasNotReported, ] + /// THE RETIRED PROPOSITIONS — established by NO arm, spoken by + /// nobody, kept nameable so the fence can assert exactly that + /// (fn-4.22). + /// + /// This set used to live only in the TEST, where it could not know + /// about a case added after it — a new proposition simply skipped + /// every retired-case assertion. It is a production declaration now, + /// and the fence DERIVES the unspoken set from `established(for:)` + /// over every cause and requires it to equal this one: a new case + /// claimed by no cause must be added HERE (a visible retirement, + /// whose sentence must be nil), and a new case claimed by any cause + /// must survive the byte-exact vocabulary pin. Either way the fence + /// notices the extension without having predicted it. + static let retired: Set = [ + .theTrashHoldsWhatItTook, + .nothingWasFreedOnDisk, + .theTargetWasReplaced, + ] + /// **THE ONE WORDING OF ONE PROPOSITION** — and the reason the /// message is no longer written per cause (PR #460 codex r18, E). /// @@ -807,22 +827,30 @@ enum TrashDisposal { /// (a), (d), (e) and (f) go with the text inspection they belonged /// to. /// - /// ## THE RESIDUAL, WHICH IS REAL AND IS THE POINT OF THE SHAPE + /// ## THE RESIDUAL, DISCLOSED BY MECHANISM (fn-4.22) /// - /// Someone can still write a sentence HERE that asserts more than the - /// proposition it is filed under — this table is where the semantic - /// judgement now lives, and no test can check it. What changed is its - /// SIZE and its SHAPE: it is one wording per proposition, fourteen of - /// them, in one `default`-less `switch`, reviewed once and shared by - /// every cause — rather than one wording per (cause × clause), - /// written afresh at each of six call sites, which is where all nine - /// of this branch's false sentences were written. It is a smaller - /// surface that a reviewer can read end to end, not a proof. + /// Every wording in this switch — the remedy wordings included — is + /// pinned BYTE-EXACT by + /// `TrashDisposalHopProofTests.testTheVocabularyAndEveryWordingArePinnedSoExtensionFailsClosed`, + /// so editing a sentence here, grafting a clause into one, or adding + /// an `Established` case at all is a red cell until the pin is + /// re-stated in the fence's own diff. What survives that, stated as + /// mechanisms rather than as "some wordings may pass": /// - /// The second residual is unchanged and equally real: an author can - /// edit `established(for:)` to admit a proposition the code path does - /// not prove. That table is the derivation, and E3 found it already - /// wrong about its own code in the commit that introduced it. + /// 1. **A coordinated edit**: change a wording here AND its pin in + /// the same commit. The fence cannot judge whether the new + /// sentence asserts more than its proposition — that is a + /// semantic judgement — it can only force the sentence to appear + /// verbatim in the fence's diff, where a reviewer reads it. The + /// same holds for the wordings as originally pinned: the pin + /// freezes what was reviewed, it does not prove it. + /// 2. **The derivation table**: an author can edit + /// `established(for:)` to admit a proposition the code path does + /// not prove — E3 found that table already wrong about its own + /// code in the commit that introduced it. `placesTheItem(_:)` + /// narrows this for placing propositions (the fixture cells that + /// MEASURED where the item is assert their cause establishes no + /// placing fact), and nothing narrows it for the rest. /// /// `nil` means "this proposition is never spoken": the three no arm /// establishes, and any fact whose payload this cause does not carry @@ -884,7 +912,17 @@ enum TrashDisposal { // qualifier is load-bearing and has been attacked twice. return "No entry was written and nothing was reported freed." case .theRemedyForThisRefusal: - return remedy.text + // The remedy WORDS live here, not on `Remedy` (fn-4.22): + // this switch is inside the one wording table the fence pins + // byte-exact, so a remedy clause can no longer be edited + // past a check that compared it with itself. + switch remedy { + case .rescan: + return "Refused; re-scan required." + case .usePermanentDeleteInstead: + return "Use permanent delete (turn off Move to Trash) for " + + "a disposal that proves the folder it acts on." + } case .theTrashHoldsWhatItTook, .nothingWasFreedOnDisk, .theTargetWasReplaced: // NO ARM ESTABLISHES THESE, so they are never spoken. Each diff --git a/Sources/Cacheout/Cleaner/WorktreeReclaimPerformer.swift b/Sources/Cacheout/Cleaner/WorktreeReclaimPerformer.swift index 68b1da80..03a4bdf6 100644 --- a/Sources/Cacheout/Cleaner/WorktreeReclaimPerformer.swift +++ b/Sources/Cacheout/Cleaner/WorktreeReclaimPerformer.swift @@ -695,6 +695,18 @@ struct WorktreeReclaimPerformer { let report = measure( worktreePath, .deletionTarget, await registry.knownIdentities ) + // (3b) A CANCELLED measurement is a PARTIAL one (fn-4.15): the + // mount doctrine below is only sound over a report that swept the + // whole tree. Fail closed, first consumer of the report — and the + // refusal is honestly retryable (a new clean re-measures). + if report.cancelled { + let detail = "\(worktreePath.path): measurement was cancelled " + + "before the tree was fully inspected — refused, not " + + "deleted (cleaning again re-measures; this refusal is not " + + "permanent)" + logRefusal("measurement_cancelled", detail) + return failure(item, detail, tag: nil) + } // (4) Mount doctrine (`removeGuardedItem` parity): a boundary at the // target or nested anywhere beneath it refuses the deletion — the // removal would recurse straight through an inner mount. @@ -1198,6 +1210,18 @@ struct WorktreeReclaimPerformer { let report = measure( directory, .deletionTarget, await registry.knownIdentities ) + // (4b) A CANCELLED measurement is a PARTIAL one (fn-4.15) — + // same fail-closed rule as the worktree arm, before any claim + // is registered; honestly retryable. + if report.cancelled { + let detail = "refused: measurement of affected admin " + + "directory \(directory.path) was cancelled before the " + + "tree was fully inspected — nothing was pruned " + + "(cleaning again re-measures; this refusal is not " + + "permanent)" + logRefusal("measurement_cancelled", detail) + return failure(item, detail, tag: nil) + } // (5) MOUNT DOCTRINE (epic round 9): the removal is a // RECURSIVE filesystem mutation over these directories, so the // boundary-bearing-recursive-delete rule applies exactly as it @@ -1667,8 +1691,10 @@ struct WorktreeReclaimPerformer { /// prunable, so the next scan simply stops offering it. private func revivedCheckoutRefusal(for adminDirectory: URL) -> String? { let backlink = adminDirectory.appendingPathComponent("gitdir") - guard let backlinkText = try? String(contentsOf: backlink, encoding: .utf8) - else { return nil } + guard let backlinkText = provider.smallRegularFileText( + at: backlink, + limit: FileSystemIdentityProvider.gitPointerByteLimit + ) else { return nil } guard let dotGit = Self.gitdirTarget( backlinkText, relativeTo: adminDirectory, prefixed: false ) else { return nil } @@ -1696,8 +1722,17 @@ struct WorktreeReclaimPerformer { + "instant, so whether it is registered again could not be " + "established — nothing was pruned. Re-scan once that path is " + "settled." + // ONE CAUSE HERE IS PERMANENT, and `ambiguous` says "re-scan" (merge + // gate r4, P6): every other way this guard fails is transient, but a + // pointer file past `gitPointerByteLimit` is past a fixed constant, + // so re-scanning cannot change the answer. Disclosed rather than + // given its own message, because no git writes a 64 KiB `.git` file + // and splitting the vocabulary for it would cost more than it buys. guard kind == .kind(.regularFile), - let pointerText = try? String(contentsOf: dotGit, encoding: .utf8), + let pointerText = provider.smallRegularFileText( + at: dotGit, + limit: FileSystemIdentityProvider.gitPointerByteLimit + ), let named = Self.gitdirTarget( pointerText, relativeTo: dotGit.deletingLastPathComponent(), prefixed: true @@ -2990,10 +3025,15 @@ struct WorktreeReclaimPerformer { adminEntry: URL, substrate: HeadWitness.Substrate ) -> HeadWitness? { let file = adminEntry.appendingPathComponent(substrate.relativePath) - guard provider.probeKind(of: file) == .kind(.regularFile), - let identity = provider.identity(of: file), - let bytes = try? Data(contentsOf: file) - else { return nil } + // ONE DESCRIPTOR for kind, identity AND bytes (PR #461 codex r2). + // The three path reads this replaces resolved `file` three times, so + // the witness could pair one object's inode with another's bytes — + // and this witness is what the reclaim proves the far side against. + guard let found = provider.smallRegularFile( + at: file, limit: FileSystemIdentityProvider.gitPointerByteLimit + ) else { return nil } + let identity = found.identity + let bytes = found.bytes return HeadWitness( substrate: substrate, identity: identity, bytes: bytes ) diff --git a/Sources/Cacheout/Intervention/Tier2Interventions.swift b/Sources/Cacheout/Intervention/Tier2Interventions.swift index ac393c3e..496086a4 100644 --- a/Sources/Cacheout/Intervention/Tier2Interventions.swift +++ b/Sources/Cacheout/Intervention/Tier2Interventions.swift @@ -814,8 +814,15 @@ public final class SnapshotCleanup: Intervention { try? await Task.sleep(nanoseconds: 2_000_000_000) if process.isRunning { kill(process.processIdentifier, SIGKILL) - // Wait for child to be reaped before resuming. - process.waitUntilExit() + // Wait for the child to be reaped before resuming — + // BOUNDED (fn-4.20): `waitUntilExit()` is the primitive + // this repo retired after measuring it miss its + // termination wakeup under concurrent reaping, and a + // SIGKILLed child is reaped by the kernel promptly, so + // five seconds is orders of magnitude of headroom. If + // even that expires, the timeout below is resumed + // anyway — a leaked zombie beats a strand. + _ = process.waitForExit(within: 5) } resumer.resume(with: .failure(SnapshotError.timeout)) } @@ -864,8 +871,15 @@ public final class SnapshotCleanup: Intervention { try? await Task.sleep(nanoseconds: 2_000_000_000) if process.isRunning { kill(process.processIdentifier, SIGKILL) - // Wait for child to be reaped before resuming. - process.waitUntilExit() + // Wait for the child to be reaped before resuming — + // BOUNDED (fn-4.20): `waitUntilExit()` is the primitive + // this repo retired after measuring it miss its + // termination wakeup under concurrent reaping, and a + // SIGKILLed child is reaped by the kernel promptly, so + // five seconds is orders of magnitude of headroom. If + // even that expires, the timeout below is resumed + // anyway — a leaked zombie beats a strand. + _ = process.waitForExit(within: 5) } resumer.resume(with: .failure(SnapshotError.timeout)) } diff --git a/Sources/Cacheout/Models/DiskInfo.swift b/Sources/Cacheout/Models/DiskInfo.swift index 0c96152b..a0c95c67 100644 --- a/Sources/Cacheout/Models/DiskInfo.swift +++ b/Sources/Cacheout/Models/DiskInfo.swift @@ -71,8 +71,10 @@ struct DiskInfo { /// `scanValidatedSession` creates the stream, the producer, the watchdog and /// the grace timer. So it was covered by NO bound and could produce NO /// `.scanDidNotFinish` — the twelfth strand mechanism on this branch, and the -/// second one (after `ContainerSnapshot.capture`) that sits in front of the -/// session bound rather than inside it. +/// second one (after `ContainerSnapshot.capture`) that sat in front of the +/// session bound rather than inside it. (The capture has since been bounded +/// on this type's own shape — `ContainerSnapshot.captureBounded`, fn-4.19 — +/// so neither pre-session wait survives unbounded.) /// /// `Task.detached` with no stated priority runs on the Swift cooperative /// pool, in the unspecified band, so it needs a free worker to START. MEASURED @@ -149,7 +151,7 @@ enum BoundedDiskInfo { within budget: Duration, fetch: @escaping @Sendable () -> DiskInfo? = { DiskInfo.current() } ) async -> Outcome { - let rendezvous = Rendezvous() + let rendezvous = FirstWinsRendezvous() // OFF THE POOL, deliberately: see the type comment. A `Task.sleep` // here would need the very worker the fetch is waiting for. let timer = ScanSessionClock.schedule(after: budget) { @@ -166,40 +168,7 @@ enum BoundedDiskInfo { return outcome } - /// One-shot, first-writer-wins, lock-guarded — the two settlers are a - /// Dispatch timer body and a detached task, neither of which may suspend. - /// The wait is a plain `withCheckedContinuation` (the spelling that - /// ignores the caller's cancellation) for the same reason `OneShotGate` - /// uses it: the timer ALWAYS settles, so an uncancellable wait cannot - /// become an unbounded one, and a cancellation-aware wait would collapse - /// the budget to zero in a cancelled caller. - private final class Rendezvous: @unchecked Sendable { - private let lock = NSLock() - private var settled: Outcome? - private var waiter: CheckedContinuation? - - func settle(_ outcome: Outcome) { - lock.lock() - guard settled == nil else { lock.unlock(); return } - settled = outcome - let waiter = self.waiter - self.waiter = nil - lock.unlock() - waiter?.resume(returning: outcome) - } - - func wait() async -> Outcome { - await withCheckedContinuation { - (continuation: CheckedContinuation) in - lock.lock() - if let settled { - lock.unlock() - continuation.resume(returning: settled) - return - } - waiter = continuation - lock.unlock() - } - } - } + // The rendezvous itself is `FirstWinsRendezvous` — born here as a + // private class, extracted when `ContainerSnapshot.captureBounded` + // (fn-4.19) and `dockerPrune` (fn-4.20) needed the identical shape. } diff --git a/Sources/Cacheout/Models/FirstWinsRendezvous.swift b/Sources/Cacheout/Models/FirstWinsRendezvous.swift new file mode 100644 index 00000000..6fe5f4f9 --- /dev/null +++ b/Sources/Cacheout/Models/FirstWinsRendezvous.swift @@ -0,0 +1,192 @@ +/// # FirstWinsRendezvous — the bounded-await primitive +/// +/// One-shot, first-writer-wins, lock-guarded: two settlers race — typically +/// a `ScanSessionClock` Dispatch timer body and a detached task, neither of +/// which may suspend — and whichever arrives first decides the outcome; the +/// loser's settle is a no-op. The caller resumes on its own executor, so a +/// MainActor caller needs no cooperative worker to observe the result. +/// +/// Born as `BoundedDiskInfo`'s private `Rendezvous` (PR #460 codex r14, +/// V2-1 — the header refresh's bound) and extracted verbatim when +/// `ContainerSnapshot.captureBounded` (fn-4.19) and +/// `CacheoutViewModel.dockerPrune` (fn-4.20) needed the identical shape: +/// one spelling, so the three bounds cannot drift in their settle/wait +/// semantics. +/// +/// The wait is a plain `withCheckedContinuation` (the spelling that ignores +/// the caller's cancellation) for the same reason `OneShotGate` uses it: the +/// timer ALWAYS settles, so an uncancellable wait cannot become an unbounded +/// one, and a cancellation-aware wait would collapse the budget to zero in a +/// cancelled caller. +/// +/// ONE waiter per instance, by contract: every use in this repo creates the +/// rendezvous, arms the timer, launches the work, and awaits once. + +import Foundation + +final class FirstWinsRendezvous: @unchecked Sendable { + private let lock = NSLock() + private var settled: Outcome? + private var waiter: CheckedContinuation? + + func settle(_ outcome: Outcome) { + lock.lock() + guard settled == nil else { lock.unlock(); return } + settled = outcome + let waiter = self.waiter + self.waiter = nil + lock.unlock() + waiter?.resume(returning: outcome) + } + + func wait() async -> Outcome { + await withCheckedContinuation { + (continuation: CheckedContinuation) in + lock.lock() + if let settled { + lock.unlock() + continuation.resume(returning: settled) + return + } + waiter = continuation + lock.unlock() + } + } +} + +/// ONE-SHOT AUTHORITY TO START SOMETHING DESTRUCTIVE (PR #461 codex r1, P1). +/// +/// `FirstWinsRendezvous` decides which OUTCOME is reported. It cannot decide +/// whether the work ever STARTED, and for a destructive child those are +/// different questions: a detached task still queued when the off-pool timer +/// wins observes nothing, the timeout branch sees `process.isRunning == false` +/// and truthfully reports that nothing is running — and then the task is +/// scheduled and calls `run()`, launching an unowned `docker system prune -f` +/// after the operation was reported timed out, free to overlap a user retry. +/// +/// The starvation this timer exists to survive is exactly the condition that +/// keeps the task queued, so the window is widest when it matters most. +/// +/// Both sides claim through one lock, so the pair is decided once: whoever +/// arrives first wins and the loser is told. `begin()` false means DO NOT +/// START. `abandon()` false means it already started — the caller owns +/// stopping it. +final class LaunchClaim: @unchecked Sendable { + private let lock = NSLock() + private var decided = false + private var started = false + + /// Decide AND perform in one act: `body` runs while the lock is held, so + /// `abandon()` cannot interleave between the decision and the start. + /// + /// A first version of this type offered a bare `begin()` and left the + /// caller to start the work on the next statement. That MOVED the window + /// rather than closing it: with the timer firing between the two, + /// `abandon()` answered false ("already started"), the caller's timeout + /// branch read `didStart == true` with `isRunning == false` — because the + /// start had not run yet — terminated nothing, reported the work stopped, + /// and the work then began, unowned. Deciding and starting must be the + /// same act, so this type performs it. + /// + /// Returns `false` without running `body` if the work was already + /// abandoned. A throwing `body` leaves the claim DECIDED but not started: + /// the attempt is spent (never retried under the same claim) while + /// `didStart` stays false, because nothing is running to terminate. + @discardableResult + func begin(_ body: () throws -> Void) rethrows -> Bool { + lock.lock(); defer { lock.unlock() } + guard !decided else { return false } + decided = true + try body() + started = true + return true + } + + /// `true` if the work was stopped before it began. `false` means the + /// decision was already taken — and because `begin` performs the work + /// under this same lock, a false answer means the work has provably + /// STARTED (or its start threw), never that it is about to. + @discardableResult + func abandon() -> Bool { + lock.lock(); defer { lock.unlock() } + guard !decided else { return false } + decided = true + return true + } + + /// Whether the work was actually started — for a caller deciding whether + /// it has anything to terminate. + var didStart: Bool { + lock.lock(); defer { lock.unlock() } + return started + } +} + + +/// A `Process` that CANNOT be started except through its own launch claim. +/// +/// `LaunchClaim` closes the decide-then-start window inside the type, and +/// `LaunchClaimTests` proves it does. What nothing held was the CALLER: the +/// claim takes a closure, so `begin({})` is writable and the launch is free +/// to drift back out to the next statement — which is the original defect, +/// not a variant of it. The merge gate demonstrated exactly that (PR #461 +/// r3, P1): it restored the two-statement shape at `dockerPrune` and the +/// full 1667-cell suite stayed green, because every cell builds its own +/// claim and calls `begin` itself. +/// +/// A test cannot hold that boundary — the damage needs the timer to land in +/// a fork/exec-wide window, so any cell for it samples rather than proves. +/// The type can: this one OWNS the `Process`, builds it from its parts and +/// never hands it out, so there is no `process` in scope at the call site to +/// call `run()` on. The two-statement shape stops COMPILING — verified by +/// applying the gate's mutation verbatim, which now fails with +/// `cannot find 'process' in scope` rather than passing 1667 green cells. +/// +/// WHAT THIS DOES NOT PREVENT, stated rather than implied: a caller can still +/// construct its OWN `Process` and run it (measured — that compiles). But +/// that launches a DIFFERENT child than the one the claim guards, which is a +/// visible act rather than the silent drift of a launch out of the claim's +/// body, and `didStart` would then contradict it. The boundary this type +/// holds is "the claimed child cannot start unclaimed", not "no process may +/// ever be started here". +final class ClaimedProcess: @unchecked Sendable { + private let process = Process() + private let claim = LaunchClaim() + + init( + executableURL: URL, + arguments: [String], + environment: [String: String], + standardOutput: Pipe, + standardError: Pipe + ) { + process.executableURL = executableURL + process.arguments = arguments + process.environment = environment + process.standardOutput = standardOutput + process.standardError = standardError + } + + /// Decide and launch as ONE act. `false` means the launch was abandoned + /// before it began; the process is never started twice. + @discardableResult + func start() throws -> Bool { + try claim.begin { try self.process.run() } + } + + /// `true` if the launch was stopped before it began. `false` means the + /// work has provably started — see `LaunchClaim.abandon()`. + @discardableResult + func abandon() -> Bool { claim.abandon() } + + /// Whether anything was started, so a caller knows whether it has + /// something to terminate. + var didStart: Bool { claim.didStart } + + var isRunning: Bool { process.isRunning } + var terminationStatus: Int32 { process.terminationStatus } + func terminate() { process.terminate() } + func waitForExit(within seconds: TimeInterval) -> Bool { + process.waitForExit(within: seconds) + } +} diff --git a/Sources/Cacheout/Scanner/BuildArtifactsScanner.swift b/Sources/Cacheout/Scanner/BuildArtifactsScanner.swift index 778d5ff9..3d805926 100644 --- a/Sources/Cacheout/Scanner/BuildArtifactsScanner.swift +++ b/Sources/Cacheout/Scanner/BuildArtifactsScanner.swift @@ -540,20 +540,29 @@ struct BuildArtifactsScanner: @unchecked Sendable { } /// A one-denial report for a containment impediment, classified on the - /// SAME frozen taxonomy every other denial uses (EPERM → TCC, EACCES → - /// BSD permissions, everything else a metadata failure). + /// SAME rule every raw-errno probe uses (fn-4.12, + /// `DirectorySizer.denial(forFailedProbe:errno:)`): EACCES → BSD + /// permissions; a BARE EPERM is NEUTRAL `.metadata` — the errno here + /// comes from a raw `openat`, which carries no provenance, so `.tcc` + /// (and the `.tccDenied` grant link it becomes on the item row) may not + /// be asserted from it; everything else a metadata failure. private static func obstruction( at url: URL, errno code: Int32 = EIO, detail: String ) -> SizeReport { var report = SizeReport() let kind: SizeDenial.Kind + var caveat = "" switch code { - case EPERM: kind = .tcc + case EPERM: + kind = .metadata + caveat = " — the cause could not be established (a privacy " + + "denial and a filesystem refusal are indistinguishable " + + "in a bare errno)" case EACCES: kind = .permission default: kind = .metadata } report.denials.append( - SizeDenial(url: url, kind: kind, detail: detail) + SizeDenial(url: url, kind: kind, detail: detail + caveat) ) return report } @@ -722,11 +731,12 @@ struct BuildArtifactsScanner: @unchecked Sendable { /// descendant (review r3). The house rule VERBATIM, no third notion /// invented: device-id change against the ANCESTOR, plus the `statfs` /// mount-root check that catches the same-`st_dev` firmlink mounts a - /// device comparison is blind to (`DirectorySizer.swift:354-359`, - /// `ProjectTreeWalker.swift:529-532`, `ValuablesDetector.swift`). The sizer + /// device comparison is blind to (`DirectorySizer.swift:448-453`, + /// `ProjectTreeWalker.swift:695-698`, `ValuablesDetector.swift`). The sizer /// records the boundary and skips its subtree uncounted; the cleaner /// refuses any tree containing one whole - /// (`CacheCleaner.deleteGuardedChild`:1151 and `removeGuardedItem`:1371). + /// (`deleteGuardedChild`, `CacheCleaner.swift:1210`, and + /// `removeGuardedItem`, `CacheCleaner.swift:1543`). /// - **AN UNENUMERABLE DIRECTORY** on that same chain — the ancestor /// itself, or any directory strictly between it and the descendant /// (review r7). Mode `0111` is the field shape: SEARCHABLE, so a root @@ -1035,7 +1045,8 @@ struct BuildArtifactsScanner: @unchecked Sendable { /// DELETE-TIME REVALIDATION entry point (fn-4.8 wires it into the /// cleaner's chokepoint seam), following the - /// `OrphanedCachesScanner.preDeleteUserDataProbe` precedent (`:571`) + /// `OrphanedCachesScanner.preDeleteUserDataProbe` precedent + /// (`OrphanedCachesScanner.swift:816`) /// exactly: the SAME bounded core with the PRODUCTION caps, so scan-time /// and delete-time inspection bounds cannot drift. Reports the CURRENT /// probe's valuables (canonical order) + completeness — fn-4.8 compares diff --git a/Sources/Cacheout/Scanner/DevRootsStore.swift b/Sources/Cacheout/Scanner/DevRootsStore.swift index 3b551e58..1812c70a 100644 --- a/Sources/Cacheout/Scanner/DevRootsStore.swift +++ b/Sources/Cacheout/Scanner/DevRootsStore.swift @@ -31,8 +31,8 @@ struct DevRootsResolution: Equatable, Sendable { /// walker's `originRoot` carry these verbatim; validator origin binding /// needs them). let keptRoots: [URL] - /// Classified config issues: policy-rejected roots as the frozen - /// `.containerRefused` (with the offending declared path), whole-value + /// Classified config issues: policy-rejected roots as + /// `.policyRefusedRoot` (offending declared path; fn-4.12), whole-value /// parse failures as `.configInvalid` (url nil — no honest filesystem /// path exists). let issues: [ScanIssue] @@ -258,26 +258,30 @@ struct DevRootsStore { /// The R16 pipeline over already-formed declared URLs: /// - /// 1. **Container-root admission policy** on EVERY root, canonicalized - /// before the check (the shared `PathGuard.validateContainerRoot` — - /// ONE definition, epic R16). Rejected roots are EXCLUDED from the - /// kept set and carried as frozen `.containerRefused` issues with - /// their offending declared path. + /// 1. **Container-root admission policy** on EVERY root (the shared + /// `PathGuard.validateContainerRoot` — ONE definition, epic R16; + /// since fn-4.11 it answers from the as-spelled probe, the kernel + /// mount table, and — for a symlink leaf — the link's own content, + /// never the destination). Rejected roots are EXCLUDED from the + /// kept set and carried as `.policyRefusedRoot` issues with their + /// offending declared path (fn-4.12 — these roots ARE configured). /// 2. **Exact-canonical-duplicate dedupe ONLY** (no keep-ancestor drop, /// D7 — nested real roots remain independent walks). TWO values per - /// root: a normalized comparison KEY (canonical path — symlinks and - /// `..` resolved) used ONLY for duplicate comparison, and the - /// ORIGINAL declared URL preserved untouched in the kept set. Only - /// roots proven real directories by lstat NO-FOLLOW on the LEAF - /// participate (symlinked ANCESTORS are legal — `/var` → `/private/ - /// var` — and resolve into the key); symlink-LEAF, absent, and - /// non-directory roots are SET ASIDE and pass through verbatim — - /// the walk-time per-root gates classify them (symlink/non-directory - /// → classified issue; absent → honest no-item omission). - /// 3. **Alias suppression**: a set-aside root that resolves ONTO a kept - /// real-directory root is DROPPED with a classified issue instead of - /// passing through (see below) — the one case where "set aside" - /// would break the root it aliases rather than merely itself. + /// root: a normalized comparison KEY (canonical path — the leaf a + /// real directory, so nothing foreign is resolved) used ONLY for + /// duplicate comparison, and the ORIGINAL declared URL preserved + /// untouched in the kept set. Only roots proven real directories by + /// lstat NO-FOLLOW on the LEAF participate (symlinked ANCESTORS are + /// legal — `/var` → `/private/var` — and resolve into the key); + /// symlink-LEAF, absent, and non-directory roots are SET ASIDE and + /// pass through verbatim — the walk-time per-root gates classify + /// them (symlink/non-directory → classified issue; absent → honest + /// no-item omission). + /// 3. **Alias suppression**: a set-aside root whose own link content + /// NAMES a kept real-directory root is DROPPED with a classified + /// issue instead of passing through (see below) — the one case where + /// "set aside" would break the root it aliases rather than merely + /// itself. /// THIS LIST ONLY, by construction: dev roots resolve before any /// runtime exists, so a dev root aliasing ANOTHER SCANNER's root /// (`~/Library/Caches`, registered by the orphaned-caches sweep) is @@ -291,9 +295,11 @@ struct DevRootsStore { ) -> DevRootsResolution { var issues = parseIssues - // (1) Policy — on the CANONICAL root (alias doctrine): a symlink - // alias of `/`, of a volume root, or of $HOME is caught here because - // the policy canonicalizes before checking. + // (1) Policy (alias doctrine): a symlink alias of `/`, of a mounted + // volume root, or of $HOME is caught here from the link's own + // CONTENT — the policy probes as spelled and never resolves a + // symlink leaf's destination (fn-4.11; the canonical check still + // runs for every non-symlink spelling). var admissible: [URL] = [] for declared in declaredRoots { do { @@ -304,53 +310,88 @@ struct DevRootsStore { } catch { let reason = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + // `.policyRefusedRoot`, NOT `.containerRefused` (fn-4.12): + // this root IS configured — the detail below has always + // said so — and the GUI derives the visible row label from + // the kind alone, so the old kind rendered "not a + // configured search root" against a tooltip saying the + // opposite. WHICH policy clause refused rides the reason. issues.append(ScanIssue( url: declared, - kind: .containerRefused, + kind: .policyRefusedRoot, detail: "configured dev root refused: \(reason)" )) } } - // Probed ONCE per surviving root: the canonical comparison KEY, and - // whether the DECLARED spelling is itself a real directory (leaf - // lstat no-follow). The key resolves the leaf — it is a comparison + // Probed ONCE per surviving root, AS SPELLED FIRST (fn-4.11): the + // no-follow leaf lstat decides directory-ness, and only a spelling + // PROVEN a real directory is `realpath(3)`'d for its canonical + // comparison KEY — the resolved leaf then IS the directory the + // lstat touched, so no symlink destination is ever named. A + // symlink-leaf spelling contributes what its own CONTENT names + // instead (`FileSystemIdentityProvider.lexicalAliasTarget` — one + // `readlink(2)` + string folding, the fn-6 technique), which walks + // the link's ancestors and reads its data block but never contacts + // the destination. The previous shape canonicalized EVERY root here + // — leaf included — so a persisted symlink root aimed at an + // unresponsive mounted volume blocked runtime construction on the + // main thread before any window existed. The key is a comparison // value ONLY and never reaches the kept set, so the // `resolveTargetKeepingLeaf` doctrine is untouched. - let probed = admissible.map { declared in - (declared: declared, - key: provider.canonicalize(declared).path, - isDirectory: provider.probeKind(of: declared) == .kind(.directory)) + let probed = admissible.map { + declared -> (declared: URL, key: String?, aliasTarget: String?) in + switch provider.probeKind(of: declared) { + case .kind(.directory): + return (declared, provider.canonicalize(declared).path, nil) + case .kind(.symlink): + return (declared, nil, provider.lexicalAliasTarget(of: declared)) + default: + return (declared, nil, nil) + } + } + // The spellings a REAL-DIRECTORY root already covers — its canonical + // key and its declared path, each mapped to the covering root's + // declared path. A set-aside root whose link content NAMES one of + // these is a redundant ALIAS of it — and an ACTIVELY HARMFUL one: + // `PathGuard.matchConfiguredRoot` resolves both spellings, returns + // the FIRST configured root that matches, and `admitContainer`'s + // no-follow gate then refuses THAT spelling without trying the real + // root behind it — so an alias declared first makes every item the + // real root discovered fail to clean with `containerUnavailable`. + // The comparison is by NAME, never by resolution: a target written + // through a third spelling matches nothing and the alias passes + // through verbatim (the recorded fn-4.11 residual, pinned by + // `testAliasNamingItsTargetThroughAThirdSpellingKeepsBothRoots`). + var coveredByRealDirectory: [String: String] = [:] + for root in probed { + guard let key = root.key else { continue } + if coveredByRealDirectory[key] == nil { + coveredByRealDirectory[key] = root.declared.path + } + if coveredByRealDirectory[root.declared.path] == nil { + coveredByRealDirectory[root.declared.path] = root.declared.path + } } - // The canonical locations a REAL-DIRECTORY spelling already covers. - // A set-aside root resolving onto one of these is a redundant ALIAS - // of it — and an ACTIVELY HARMFUL one: `PathGuard.matchConfiguredRoot` - // resolves both spellings, returns the FIRST configured root that - // matches, and `admitContainer`'s no-follow gate then refuses THAT - // spelling without trying the real root behind it — so an alias - // declared first makes every item the real root discovered fail to - // clean with `containerUnavailable`. - let coveredByRealDirectory = Set( - probed.lazy.filter(\.isDirectory).map(\.key) - ) var kept: [URL] = [] var seenCanonicalKeys = Set() for root in probed { // (2) Exact-canonical-duplicate dedupe — real directories only. - guard root.isDirectory else { + guard let key = root.key else { // (3) Alias suppression. Dropping is strictly fail-CLOSED: // the alias could never be walked (the walker's lstat root // gate refuses it) nor admitted as a container, so it // contributes nothing but the shadow. Never a silent drop — // the same `.symlinkRoot` kind the walk-time gate would have // produced, naming the root that already covers it. - if coveredByRealDirectory.contains(root.key) { + if let target = root.aliasTarget, + let covering = coveredByRealDirectory[target] { issues.append(ScanIssue( url: root.declared, kind: .symlinkRoot, detail: "configured dev root is not a real directory " - + "and aliases \(root.key), which is configured " + + "and aliases \(covering), which is configured " + "separately — the alias was dropped" )) continue @@ -358,7 +399,7 @@ struct DevRootsStore { kept.append(root.declared) continue } - guard seenCanonicalKeys.insert(root.key).inserted else { + guard seenCanonicalKeys.insert(key).inserted else { continue // exact duplicate of an earlier declared root } kept.append(root.declared) diff --git a/Sources/Cacheout/Scanner/DirectorySizer.swift b/Sources/Cacheout/Scanner/DirectorySizer.swift index 2323275f..0424a18e 100644 --- a/Sources/Cacheout/Scanner/DirectorySizer.swift +++ b/Sources/Cacheout/Scanner/DirectorySizer.swift @@ -89,7 +89,12 @@ import Foundation /// otherwise (a real `du` on `~/Pictures` returned 8.0K for a multi-GB tree). struct SizeDenial: Equatable { enum Kind: Equatable { - /// EPERM(1) under the Cocoa error — macOS TCC (privacy) denial. + /// macOS TCC (privacy) denial — CHAIN-PROVEN ONLY (fn-4.12): + /// EPERM(1) recovered from a Cocoa error's `NSUnderlyingErrorKey` + /// chain (`classifyDenial`). The raw-errno classifier + /// (`denial(forFailedProbe:errno:)`) never produces this kind — a + /// bare errno carries no provenance, so a consumer mapping `.tcc` + /// to a "Grant access…" remedy is no longer amplifying a guess. case tcc /// EACCES(13) — classic BSD permission denial. case permission @@ -116,6 +121,13 @@ struct SizeDenial: Equatable { /// distinguished. What it is NOT any more is a reason the item /// cannot be DELETED: `DepthSafeRemoval` addresses this tree. case unaddressablePath + /// The walk hit this sizer's entry cap with entries still unread + /// (fn-4.15). DETERMINISTIC: the same tree re-measures to the same + /// cap, so no re-scan can ever clear this — the denial's `detail` + /// must state permanence (the only clearing act is shrinking the + /// tree) and must never promise a retry. Every byte/count figure in + /// a capped report is a FLOOR over the first `entryCap` entries. + case enumerationCapped /// Anything else — recorded, never swallowed. case other } @@ -185,6 +197,17 @@ struct SizeReport { /// child churn and lie about content age. `nil` when no regular file's /// date could be read. var newestContentDate: Date? + /// The walk observed task cancellation and STOPPED with entries still + /// unread (fn-4.15). Every figure in the report is then a floor over + /// whatever was measured before the stop — a caller may not consume a + /// cancelled report as a complete measurement (the delete-time mount + /// doctrine reads `mountBoundaries` as "the whole tree was swept", which + /// a cancelled walk cannot claim). Deliberately NOT a denial: nothing + /// refused the read, and a retry CAN differ — cancellation is a caller + /// act, not a property of the tree. Never set by a walk that ran out of + /// entries before it observed the cancellation: such a report IS + /// complete. + var cancelled: Bool = false /// The two byte components summed — what a scan row displays today. var measuredBytes: Int64 { exactAllocatedBytes + estimatedUpToBytes } @@ -200,11 +223,50 @@ struct DirectorySizer { case deletionTarget } + /// The default entry cap a sizing walk spends before it stops and + /// records `.enumerationCapped` (fn-4.15). THE VALUE IS A DESIGN + /// DECISION, sized against measurement, not folklore: this walk runs at + /// ~111k entries/s on a warm APFS volume (measured over a 20k-entry + /// fixture; the figure is printed by + /// `DirectorySizerTests.testAMidWalkCancelReturnsPromptly` on every + /// run), and the largest real trees this project has evidence for are + /// far below the cap — a full `.build` here holds 5,917 entries for + /// 481 MB, which extrapolates the 23G field-evidence worktrees tree + /// (`FIELD-EVIDENCE-2026-08-06.md`, scenario 2) to roughly 300k + /// entries. So 2,000,000 clears every evidenced real tree by better + /// than 6x while bounding a pathological one at ~18 s of walk, and + /// cancellation (checked every entry, 25 µs cancel-to-return measured) + /// covers the user who will not wait even that long. + /// + /// COMPOSITION (the product rule): this cap is PER MEASURE CALL. A + /// scanner that sizes N candidates spends at most N × cap entries per + /// scan — bounded per scan because every scanner's candidate list is + /// itself bounded (categories: fixed root list; orphaned-caches sweep: + /// one directory's children; build artifacts / worktrees: the walker's + /// own entry budget) — and the per-entry cancellation check is what + /// bounds the wait a user actually experiences. + static let defaultEntryCap = 2_000_000 + private let provider: FileSystemIdentityProvider + /// Entries one `enumerateTree` walk may read before stopping with + /// `.enumerationCapped`; `nil` = uncapped. `nil` is reserved for the + /// DELETE-TIME verification sizer (`CacheCleaner`'s): the mount doctrine + /// consumes `mountBoundaries` as proof the WHOLE tree was swept, so a + /// capped delete-time walk would have to fail closed — and a + /// deterministic cap that blocks deletion is a permanent strand (the + /// tree can never measure differently). Delete-time work stays bounded + /// anyway: the sizing pass is at most proportional to the deletion the + /// caller is about to perform over the same tree, and it is cancellable + /// per entry. + private let entryCap: Int? private let fileManager = FileManager.default - init(provider: FileSystemIdentityProvider = FileSystemIdentityProvider()) { + init( + provider: FileSystemIdentityProvider = FileSystemIdentityProvider(), + entryCap: Int? = DirectorySizer.defaultEntryCap + ) { self.provider = provider + self.entryCap = entryCap } /// Measure the tree (or leaf) at `url`. Pure function: never mutates @@ -321,7 +383,37 @@ struct DirectorySizer { let rootDevice = provider.deviceID(of: root) while let next = enumerator.nextObject() { + // CANCELLATION, between entries (fn-4.15) — the walk was + // unabortable before this: a scanner cancelled mid-measure kept + // walking to the end of the tree. Checked BEFORE the pulled + // entry is processed, so a cancelled walk never half-counts an + // entry; the granularity is one entry's processing (measured in + // `DirectorySizerTests.testAMidWalkCancelReturnsPromptly`). + // Reached only when the enumerator HANDED BACK another entry, so + // an exhausted walk that never observed the flag stays complete + // (`cancelled == false`) — see `SizeReport.cancelled`. + if Task.isCancelled { + report.cancelled = true + break + } guard let itemURL = next as? URL else { continue } + // THE ENTRY CAP (fn-4.15), spent only when a FURTHER entry + // actually exists: an exact-fit tree ends the loop above with no + // truncation claim (the `PrefilterBudget.wasCutShort` rule). The + // cap is deterministic over a static tree, so the disclosure + // states permanence and offers no retry — the + // deterministic-bound doctrine's one honest wording. + if let cap = entryCap, report.enumeratedEntries >= cap { + report.denials.append(SizeDenial( + url: root, kind: .enumerationCapped, + detail: "this folder holds more than \(cap) entries — " + + "more than a sizing walk will ever read, so every " + + "figure shown for it is a floor over the first " + + "\(cap); measuring again cannot reach further " + + "(only removing entries from the folder can)" + )) + break + } // The CENSUS, counted before any classification: every entry the // enumerator yielded, whatever it turns out to be and whether or // not it survives to contribute bytes. @@ -335,8 +427,8 @@ struct DirectorySizer { // race, not a denial. continue case .failed(let code): - // Classified by errno (EPERM → TCC, EACCES → permission) — - // never collapsed into a generic metadata failure (D6). + // Classified by errno (EACCES → permission; bare EPERM is + // NEUTRAL, fn-4.12) — never collapsed silently (D6). report.denials.append(Self.denial(forFailedProbe: itemURL, errno: code)) continue } @@ -537,18 +629,36 @@ struct DirectorySizer { return SizeDenial(url: url, kind: kind, detail: nsError.localizedDescription) } - /// Classify a raw failed `lstat` probe by errno: EPERM is TCC, EACCES is - /// BSD permissions, anything else a metadata failure. + /// Classify a raw failed `lstat` probe by errno. + /// + /// THE BARE-ERRNO RULE, decided ONCE for every producer on this + /// taxonomy (fn-4.12; the `EphemeralTempScanner` denial-classification + /// header measured and recorded the rationale): EACCES is unambiguous + /// BSD permissions; a BARE EPERM is NEUTRAL — a raw errno carries no + /// provenance, so neither a privacy (TCC) denial nor a filesystem + /// refusal (SIP, sticky semantics, an immutable flag) may be asserted + /// from it, and the `.tcc` this arm used to answer flowed into + /// `.tccDenied` rows and the GUI's "Grant access…" link — a remedy + /// claimed on a guess. Only the Cocoa `NSUnderlyingErrorKey` chain + /// (`classifyDenial` above) can prove TCC. `ProjectTreeWalker`, + /// `OrphanedCachesScanner` and this sizer's own walk all classify raw + /// probes through here; `EphemeralTempScanner.classify`'s raw-errno arm + /// states the same rule in its own switch. static func denial(forFailedProbe url: URL, errno code: Int32) -> SizeDenial { let kind: SizeDenial.Kind + var caveat = "" switch code { - case EPERM: kind = .tcc + case EPERM: + kind = .metadata + caveat = " — the cause could not be established (a privacy " + + "denial and a filesystem refusal are indistinguishable " + + "in a bare errno)" case EACCES: kind = .permission default: kind = .metadata } return SizeDenial( url: url, kind: kind, - detail: "lstat failed: \(String(cString: strerror(code)))" + detail: "lstat failed: \(String(cString: strerror(code)))" + caveat ) } } @@ -561,7 +671,11 @@ extension SizeDenial.Kind { switch self { case .tcc: return .tccDenied case .permission: return .permissionDenied - case .metadata, .other, .unaddressablePath: return .other + // The cap is neither a TCC nor a BSD denial and no grant lifts it; + // `.other` here, and the truncation-specific `ScanIssue` kind where + // an issue surface exists (`OrphanedCachesScanner.rootIssueKind`). + case .metadata, .other, .unaddressablePath, .enumerationCapped: + return .other } } } diff --git a/Sources/Cacheout/Scanner/EphemeralTempRoots.swift b/Sources/Cacheout/Scanner/EphemeralTempRoots.swift index b4578a1d..2ad21acd 100644 --- a/Sources/Cacheout/Scanner/EphemeralTempRoots.swift +++ b/Sources/Cacheout/Scanner/EphemeralTempRoots.swift @@ -51,7 +51,7 @@ /// ROOT, not a comparison value. `realpath(3)` resolves the leaf too, so a /// symlink standing where `C`/`T` should be would silently register its /// DESTINATION as a temp root — and the container-root policy only refuses -/// `/`, volume roots and `$HOME` itself (`PathGuard.swift:370` says so in +/// `/`, volume roots and `$HOME` itself (`PathGuard.swift:444` says so in /// as many words: "`~/Documents` can be a container while `admitDeletionRoot` /// refuses it"), so an arbitrary directory would be admitted, walked, listed /// as cache-container payload and deleted. Keeping the leaf means the @@ -110,7 +110,7 @@ /// thread while building its `@StateObject` /// (`CacheoutApp.swift:58` → `CacheoutViewModel.production()` → /// `CacheoutViewModel.swift:563` → `SpaceScannerRuntime.production`, -/// `SpaceScanner.swift:2129`), long +/// `SpaceScanner.swift:2267`), long /// before any trigger or `participates(in:)` gate exists to consult. The main /// thread is not an inference: `CacheoutViewModel` is `@MainActor` /// (`CacheoutViewModel.swift:264`), so its `production()` factory cannot be @@ -134,32 +134,33 @@ /// name, against the spellings resolution already holds. That is strictly /// weaker than an inode comparison, and the residual is recorded below. /// -/// This is where the file DIVERGES from the two dev-root precedents it -/// otherwise follows: `DevRootsStore.swift:322` and -/// `SpaceScannerRuntime.suppressingAliasShadows`' probe pair -/// (`SpaceScanner.swift:1992-1996`) -/// both still build their comparison key with `provider.canonicalize`, on -/// every root including non-directory ones, at the same construction time. -/// Neither has been changed here. -/// -/// ### RESIDUAL, at measured scope: the SECTION TITLE is about THIS FILE -/// -/// It is not a claim about `production()` as a whole, and the difference is -/// measured. A symlink root this resolution cannot place is KEPT, so it -/// reaches the runtime's cross-scanner union and -/// `suppressingAliasShadows`' probe pair (`SpaceScanner.swift:1992-1996`) -/// canonicalizes it there — one leaf-following -/// `realpath(3)` on the destination, still during construction. Measured -/// through the shipped `??` arm with the same fixture, before and after this -/// change: leaf-following canonicalizations of a symlinked `C` went 2 → 1, -/// and `production()` under a 0.75 s stall on calls naming the destination -/// went 3.02 s → 0.76 s. Replacing that one line's `provider.canonicalize( -/// root).path` with `root.path` takes both to 0 and 0.0026 s, which is how -/// the surviving contact was attributed — NOT a proposed fix: that key is -/// what suppresses a shadowing alias ACROSS scanners -/// (`suppressingAliasShadows`' doc, `SpaceScanner.swift:1941-1952`), and -/// weakening it trades one hazard for -/// another. Closing it needs its own change, on fn-4.5's contract. +/// This technique is no longer this file's alone. When it landed here (PR +/// #459 codex r12) the two dev-root precedents still built their comparison +/// key with `provider.canonicalize`, on every root including non-directory +/// ones, at the same construction time; fn-4.11 converged them onto this +/// file's rule — `DevRootsStore.resolve`'s probe pass +/// (`DevRootsStore.swift:342-352`), `suppressingAliasShadows`' probe +/// (`SpaceScanner.swift:2102-2120`), and the container-root policy +/// (`PathGuard.validateContainerRoot`) all now probe as spelled and read a +/// symlink leaf's own content, and the fold itself was hoisted to +/// `FileSystemIdentityProvider.lexicalTargetPath` (this file's +/// `lexicalTargetPath` delegates to it). +/// +/// ### The r12 residual is CLOSED (fn-4.11), and the history is kept +/// +/// This section used to record the one surviving contact: a symlink root +/// this resolution could not place was KEPT, and the union's probe pair +/// canonicalized it there — one leaf-following `realpath(3)` on the +/// destination, still during construction (measured then: leaf-following +/// canonicalizations of a symlinked `C` went 2 → 1 with this file's fix, +/// 3.02 s → 0.76 s under an injected 0.75 s stall; neutering the union's +/// key line took it to 0 and 0.0026 s, which ATTRIBUTED the survivor — +/// `SpaceScanner.swift`'s alias-suppression key). fn-4.11 closed it by +/// changing that key's derivation, not by weakening the suppression: the +/// union now compares a symlink root's folded link content by NAME +/// (`suppressingAliasShadows`, `SpaceScanner.swift:2089`), pinned by +/// `testProductionNeverContactsASymlinkDevRootsDestination` with an +/// instrumented provider that fails on any call naming the destination. /// /// ## Nor is a root that IS a mount contacted (PR #459 codex r15) /// @@ -187,9 +188,10 @@ /// DROPPED, not kept-and-skipped, and that is the whole difference between a /// fix and a relocation: the 2 of those 5 that `resolve` never made were /// `suppressingAliasShadows` canonicalizing and probing the same root in the -/// cross-scanner union (`suppressingAliasShadows`' probe pair, -/// `SpaceScanner.swift:1992-1996`), which every KEPT -/// root reaches. +/// cross-scanner union — measured against the union as it then stood; since +/// fn-4.11 its probe (`SpaceScanner.swift:2102-2120`) runs its own +/// kernel-table preflight first, so even a kept mounted root is no longer +/// contacted there. /// /// ### RESIDUAL, at measured scope: three cases this does not cover /// @@ -203,11 +205,12 @@ /// confstr spelling like `/var/folders//C`. Not the case in the /// finding, and not half-guarded here. /// - A declared root that is a SYMLINK to a mounted volume. The table names -/// the mount, not the link, so the link is kept — and the r12 residual -/// above is then the contact: `suppressingAliasShadows` canonicalizes it, -/// naming the destination. Re-measured at this tip: `production()` makes -/// exactly 1 call naming the destination and takes 0.76 s under the same -/// injected 0.75 s stall. Same out-of-scope line, same fn-4.5 contract. +/// the mount, not the link, so the link is kept — and until fn-4.11 the +/// union's probe then canonicalized it, naming the destination (measured +/// then: exactly 1 such call, 0.76 s under the injected 0.75 s stall). +/// CLOSED with the r12 residual above: the union reads the link's own +/// content instead, and the shared container-root policy additionally +/// refuses a dev-root link whose content names a table mount. /// /// `confstr(3)` itself is upstream of all of this by necessity — it is what /// produces the path, so no table check can precede it. @@ -215,20 +218,20 @@ /// ## De-dupe and alias suppression — two halves, cited one at a time /// /// One value is probed per declared root: whether the DECLARED spelling is -/// itself a real directory (`lstat` leaf, no follow), which is the -/// `isDirectory` half of the probe pair at `DevRootsStore.swift:320-324` and -/// `suppressingAliasShadows`' probe pair (`SpaceScanner.swift:1992-1996`). -/// The `key:` half of that pair is deliberately -/// NOT taken (see above). The two halves that consume the probe have +/// itself a real directory (`lstat` leaf, no follow) — since fn-4.11 the +/// same as-spelled-first probe the dev-root resolution +/// (`DevRootsStore.swift:342-352`) and `suppressingAliasShadows` +/// (`SpaceScanner.swift:2102-2120`) run: none of the three resolves a +/// non-directory leaf. The two halves that consume the probe have /// different precedents — do not read this as one pattern copied whole from /// either: /// /// - **De-dupe** — real directories only: a real-directory spelling of a /// location already kept is dropped. Precedent is `DevRootsStore.swift` -/// alone (:361-364, `seenCanonicalKeys.insert`). +/// alone (:396-398, `seenCanonicalKeys.insert`). /// `SpaceScannerRuntime.suppressingAliasShadows` does NOT do this half — it /// deliberately DECLINES it, and `suppressingAliasShadows` -/// (`SpaceScanner.swift:2002-2005`) says so: +/// (`SpaceScanner.swift:2130-2133`) says so: /// "Two real-directory spellings of one location are NOT touched: both pass /// the reality gate, so neither shadows the other, and dropping either would /// change which declared spelling the identity binding keys off for no @@ -237,7 +240,8 @@ /// /// The comparison here is INODE identity (`sameLocation`) of the declared /// spellings, where that precedent compares canonical paths as STRINGS -/// (`DevRootsStore.swift:322` builds `.path`). Comparing the declared +/// (`DevRootsStore.swift:346` builds `.path` — real directories only, +/// fn-4.11). Comparing the declared /// spellings is sound only because both sides are real DIRECTORIES, whose /// parent chain resolution already made them canonical: measured on this /// machine (Darwin 25.5), `realpath(dir)` and `realpath(parent) + "/" + @@ -263,22 +267,23 @@ /// real root is worse than useless — `PathGuard.matchConfiguredRoot` /// returns the FIRST configured root that matches and `admitContainer` /// refuses THAT spelling without trying the real one behind it. -/// `DevRootsStore.swift:326-332` names that shape "ACTIVELY HARMFUL"; -/// `suppressingAliasShadows`' doc (`SpaceScanner.swift:1941-1952`) +/// `DevRootsStore.swift:353-361` names that shape "ACTIVELY HARMFUL"; +/// `suppressingAliasShadows`' doc (`SpaceScanner.swift:2010-2021`) /// records the breakage it caused when the /// shadowed root came from another scanner. /// -/// BOTH files do this half — `DevRootsStore.swift:333-335` + :341-357 and -/// `suppressingAliasShadows` (`SpaceScanner.swift:1964-2004`) — but only +/// BOTH files do this half — `DevRootsStore.swift:366-375` + :379-401 and +/// `suppressingAliasShadows` (`SpaceScanner.swift:2089-2151`) — since +/// fn-4.11 by THIS file's name-compare rule in all three — but only /// `DevRootsStore` classifies the -/// drop. `suppressingAliasShadows` returns roots plus their canonical keys -/// and NO issue channel of its own (`SpaceScanner.swift:1987-1989`; the +/// drop. `suppressingAliasShadows` returns roots plus their comparison keys +/// and NO issue channel of its own (`SpaceScanner.swift:2089-2091`; the /// "bare `[URL]`" this sentence used to say stopped being true when the /// keys were carried out of the same probe, PR #460 codex r4); -/// `suppressingAliasShadows`' doc (`SpaceScanner.swift:1981-1986`) +/// `suppressingAliasShadows`' doc (`SpaceScanner.swift:2083-2088`) /// records what /// reports its drops instead. The `.symlinkRoot` issue raised here follows -/// `DevRootsStore.swift:349-355`, not that function. +/// `DevRootsStore.swift:390-396`, not that function. /// /// A non-directory spelling that NOTHING else covers passes through verbatim: /// scan time is where absence and denial are told apart, and the no-follow @@ -572,7 +577,7 @@ enum EphemeralTempRoots { // syscall blocks. And this is CONSTRUCTION, not scan time: // `EphemeralTempRoots.resolve` runs inside // `SpaceScannerRuntime.production` - // (`SpaceScannerRuntime.production` (`SpaceScanner.swift:2129`)), which + // (`SpaceScannerRuntime.production` (`SpaceScanner.swift:2267`)), which // the GUI calls from `CacheoutViewModel.production` // (`CacheoutViewModel.swift:555-574`) at the `@MainActor` view // model's construction (`CacheoutApp.swift:58`), so the block lands @@ -585,9 +590,11 @@ enum EphemeralTempRoots { // // The root is DROPPED, not kept-and-skipped, and that difference is // the fix: a kept root reaches the runtime's cross-scanner union, - // where `suppressingAliasShadows`' probe pair - // (`SpaceScanner.swift:1992-1996`) canonicalizes and probes it — - // the remaining 2 of those 5 — still during construction. Dropping + // whose probe (`SpaceScanner.swift:2102-2120`) — at the time of + // this fix — canonicalized and probed it, the remaining 2 of those + // 5, still during construction (since fn-4.11 the union preflights + // the same kernel table itself, a second line this drop no longer + // relies on). Dropping // is also fail-CLOSED in the same shape as alias suppression: the // root could not have been scanned (fn-6.2's own arm refuses it) and // nothing under it can be admitted for deletion. @@ -698,32 +705,19 @@ enum EphemeralTempRoots { } /// `content` as an absolute path, folded LEXICALLY — no syscall of any - /// kind. A relative target is joined to the link's own directory (already - /// parent-canonical, since `declared` came from `resolvedRoot`); `.` is - /// dropped and `..` pops a component in the STRING, because popping it - /// against the filesystem is precisely the resolution this avoids. - /// - /// `nil` for anything that is not a usable comparison subject: empty - /// content, a `..` that walks off the root, and a target of `/` itself. + /// kind. Since fn-4.11 this is a delegation: + /// `FileSystemIdentityProvider.lexicalTargetPath` is the ONE folding + /// rule (this file's r12 original, hoisted so the dev-root resolution, + /// the cross-scanner union, and the container-root policy share it). + /// The name stays because this file's callers and cells anchor on it — + /// and the contract is unchanged: a relative target joins the link's own + /// directory (already parent-canonical here, since `declared` came from + /// `resolvedRoot`); `nil` for anything that is not a usable comparison + /// subject (empty content, a `..` that walks off the root, `/` itself). static func lexicalTargetPath(ofLink link: URL, content: String) -> String? { - guard !content.isEmpty else { return nil } - let joined = content.hasPrefix("/") - ? content - : link.deletingLastPathComponent().path + "/" + content - var components: [String] = [] - for component in joined.split(separator: "/") { - switch component { - case ".": - continue - case "..": - guard !components.isEmpty else { return nil } - components.removeLast() - default: - components.append(String(component)) - } - } - guard !components.isEmpty else { return nil } - return "/" + components.joined(separator: "/") + FileSystemIdentityProvider.lexicalTargetPath( + ofLink: link, content: content + ) } /// The raw, un-normalized path for a source — `nil` when a confstr diff --git a/Sources/Cacheout/Scanner/EphemeralTempScanner.swift b/Sources/Cacheout/Scanner/EphemeralTempScanner.swift index d458c050..7cf60b20 100644 --- a/Sources/Cacheout/Scanner/EphemeralTempScanner.swift +++ b/Sources/Cacheout/Scanner/EphemeralTempScanner.swift @@ -156,17 +156,17 @@ /// establishable from a bare errno, so neither `.tccDenied` nor /// `.permissionDenied` may be asserted; anything else ⇒ `.unreadable`. /// - **(c) post-sizing `SizeDenial`s**: `.permission` ⇒ permission-denied; -/// `.tcc` ⇒ NEUTRAL `.other`-kind `ScanError` with the detail preserved, -/// because `SizeDenial.Kind.tcc` CONFLATES chain-proven denials -/// (`classifyDenial`'s `case .some(Int(EPERM))` arm, -/// `DirectorySizer.swift:483`) with raw-probe guesses -/// (`denial(forFailedProbe:errno:)`'s `case EPERM`, :545); the only -/// surviving discriminator is a detail STRING, and classification derived -/// from message text is forbidden house doctrine (`CacheCleaner.refusalTag` -/// :1402 switches the TYPED error). Anchors re-verified r10 — the three -/// that stood here pointed at a sparse-accounting comment, a hardlink -/// comment and an unrelated line (R3-V5); re-grep before trusting these -/// too (SCANNERS-ROADMAP doctrine). +/// `.tcc` ⇒ NEUTRAL `.other`-kind `ScanError` with the detail preserved. +/// The conflation that MANDATED this neutrality is retired (fn-4.12): +/// `denial(forFailedProbe:errno:)` no longer answers `.tcc` for a bare +/// EPERM — this file's rule (b) became the shared taxonomy's — so `.tcc` +/// now arrives chain-proven only (`classifyDenial`'s +/// `case .some(Int(EPERM))` arm, `DirectorySizer.swift:575`). Preserving +/// the grant hint here is therefore now POSSIBLE; it is deliberately NOT +/// done in fn-4.12, whose boundary excludes this scanner's pinned #459 +/// matrix — recorded residual: a sizing-path chain-proven TCC denial in +/// ephemeral_tmp still renders neutrally, understating a real remedy but +/// asserting nothing false. /// /// ENOENT on a child is a purely OBSERVABLE race contract: silently skipped — /// no item, no denial, no issue. There is no race counter. @@ -1876,6 +1876,11 @@ struct EphemeralTempScanner: @unchecked Sendable { // descriptor-relative and handles such trees whole. case .metadata, .other, .unaddressablePath: return (.unreadable, "\(url.path): \(denial.detail)") + // Unreachable from `classifyDenial` (errno classification never + // yields the cap); mapped rather than defaulted so the switch + // stays a reviewer's inventory (fn-4.15). + case .enumerationCapped: + return (.enumerationTruncated, "\(url.path): \(denial.detail)") } case .metadataUnavailable: return (.unreadable, "\(url.path): metadata unavailable") @@ -1890,6 +1895,13 @@ struct EphemeralTempScanner: @unchecked Sendable { "\(denial.url.path): \(denial.detail)") case .tcc, .metadata, .other, .unaddressablePath: return (.unreadable, "\(denial.url.path): \(denial.detail)") + // The sizer's entry cap (fn-4.15): the taxonomy's truncation + // kind, whose GUI label ("too many entries — partially + // inspected") is exactly true; the detail carries the sizer's + // own no-retry sentence verbatim. + case .enumerationCapped: + return (.enumerationTruncated, + "\(denial.url.path): \(denial.detail)") } } } diff --git a/Sources/Cacheout/Scanner/GitCommandRunner.swift b/Sources/Cacheout/Scanner/GitCommandRunner.swift index fb5d7892..2465de9c 100644 --- a/Sources/Cacheout/Scanner/GitCommandRunner.swift +++ b/Sources/Cacheout/Scanner/GitCommandRunner.swift @@ -6,9 +6,13 @@ /// `private` and nulls both output streams, which is useless for porcelain /// parsing. What IS cloned is its process discipline — `/usr/bin/env`, /// argv-only (never a shell), a fixed PATH, an injected `HOME`, and a -/// BOUNDED wait via `Process.waitForExit(within:)` (a bare -/// `waitUntilExit()` misses its termination wakeup under concurrent -/// spawning/reaping — house doctrine, see `CacheCategory.swift`). +/// BOUNDED wait (a bare `waitUntilExit()` misses its termination wakeup +/// under concurrent spawning/reaping — house doctrine, see +/// `CacheCategory.swift`; since fn-4.27 the runner spawns through its own +/// `SpawnedProcess` — `posix_spawn` with `POSIX_SPAWN_SETPGROUP`, so the +/// child's isolated process group is established AT CREATION rather than +/// discovered after launch — and its bounded wait polls a pid-targeted +/// `waitpid(WNOHANG)` in the same deadline/backoff shape). /// /// ## What this runner adds over the cleaner's /// @@ -191,14 +195,16 @@ enum GitCommandOutcome: Equatable, Sendable { /// Non-zero exit, with git's own stderr (lossily decoded — stderr is a /// human message, never a path used as a deletion target). case failure(exitCode: Int32, stderr: String) - /// The invocation could not be COMPLETED within its bounds, and the full - /// termination protocol ran. Two ways in, and they are one answer on - /// purpose (PR #460 codex r18, C7): the per-invocation budget expired - /// before git exited, OR git exited but its output could not be read to - /// completion within the drain budget — which means something git spawned - /// still holds the inherited pipe, so the captured bytes may be short. - /// Both are retryable and neither may be reported as a `.success` whose - /// `stdout` a porcelain parser will count. + /// The invocation could not be COMPLETED within its bounds. Three ways + /// in, and they are one answer on purpose (PR #460 codex r18 C7; + /// fn-4.24): the per-invocation budget expired before git exited (the + /// full termination protocol ran), OR git exited but its output could + /// not be read to completion within the drain budget — something git + /// spawned still holds the inherited pipe, so the captured bytes may be + /// short — OR a drain DIED on a hard `read(2)` error, which also leaves + /// the captured bytes short. All three are retryable (a fresh invocation + /// opens fresh pipes and descriptors) and none may be reported as a + /// `.success` whose `stdout` a porcelain parser will count. case timeout /// `env` could not find git (exit 127), the launch itself failed, or the /// instance's cached availability probe already said no. @@ -299,6 +305,14 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { let defaultTimeout: TimeInterval private let terminationGrace: TimeInterval private let drainJoinBudget: TimeInterval + /// How `execute` builds the drain over each pipe. TESTING SEAM + /// (fn-4.24): production always uses the default — a `PipeDrain` over + /// the pipe's own read end — and nothing in this file reassigns it. The + /// seam exists because a pipe cannot be made to fail `read(2)` HARD on + /// demand, and the died-on-error drain classes (EISDIR through a + /// directory descriptor) are only reachable through + /// `PipeDrain(readingFrom:)`. + private let drainFactory: (Pipe) -> PipeDrain // MARK: Mutable state (lock-guarded) @@ -317,13 +331,15 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { executableURL: URL = GitCommandRunner.defaultExecutable, defaultTimeout: TimeInterval = GitCommandRunner.scanTimeout, terminationGrace: TimeInterval = GitCommandRunner.defaultTerminationGrace, - drainJoinBudget: TimeInterval = GitCommandRunner.defaultDrainJoinBudget + drainJoinBudget: TimeInterval = GitCommandRunner.defaultDrainJoinBudget, + drainFactory: @escaping (Pipe) -> PipeDrain = { PipeDrain(pipe: $0) } ) { self.baseEnvironment = environment self.executableURL = executableURL self.defaultTimeout = defaultTimeout self.terminationGrace = terminationGrace self.drainJoinBudget = drainJoinBudget + self.drainFactory = drainFactory } /// Production initializer — the cleaner-cloned environment. @@ -447,21 +463,31 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { let argv = Self.argv(for: arguments) let environment = environment(for: profile) - let process = Process() - process.executableURL = executableURL - process.arguments = argv - process.environment = environment - // git must never be able to prompt: an inherited terminal would let - // a credential helper block the whole scan. /dev/null reads EOF. - process.standardInput = FileHandle.nullDevice - let stdoutPipe = Pipe() let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe + // THE ISOLATED PROCESS GROUP IS ESTABLISHED BY THE SPAWN ITSELF + // (fn-4.27): `SpawnedProcess.launch` passes `POSIX_SPAWN_SETPGROUP` + // with pgroup 0, so the kernel makes the child the leader of a new + // group whose id IS its pid, atomically, at creation. There is no + // post-launch `getpgid` discovery any more — r18's shape read the + // group AFTER `run()`, so a leader exiting inside that window left + // `group == nil` and its descendants unsignalled, which was exactly + // the case the group existed for. `process.pid` is the group id for + // the process's whole life, leader dead or alive. + // + // stdin is `/dev/null` inside the spawn's file actions: git must + // never be able to prompt — an inherited terminal would let a + // credential helper block the whole scan; /dev/null reads EOF. + let process: SpawnedProcess do { - try process.run() + process = try SpawnedProcess.launch( + executablePath: executableURL.path, + arguments: argv, + environment: environment, + stdout: stdoutPipe, + stderr: stderrPipe + ) } catch { // `env` itself is missing/unrunnable — the same class of answer // as "git is not on PATH", never a silent zero. @@ -471,22 +497,16 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { ) } - // THE CHILD'S PROCESS GROUP, READ WHILE THE CHILD IS PROVABLY ALIVE - // (PR #460 codex r18, C8). Everything the runner may later have to - // signal is decided here, once, before any wait — see - // `ownProcessGroup(of:)`. - let group = Self.ownProcessGroup(of: process) - // Both drains start BEFORE the wait: either stream filling the // 64 KiB pipe buffer would otherwise block the child forever while // the parent waits for an exit that can never come. - let stdoutDrain = PipeDrain(pipe: stdoutPipe) - let stderrDrain = PipeDrain(pipe: stderrPipe) + let stdoutDrain = drainFactory(stdoutPipe) + let stderrDrain = drainFactory(stderrPipe) stdoutDrain.start() stderrDrain.start() guard process.waitForExit(within: timeout) else { - terminate(process, group: group) + terminate(process) // PINNED ORDER: close the pipe handles FIRST, then join. An open // FD is exactly what wedges a reader when the child (or a // grandchild that inherited the write end) is still holding it. @@ -547,7 +567,7 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { let stdoutJoined = stdoutDrain.join(within: drainJoinBudget) let stderrJoined = stderrDrain.join(within: drainJoinBudget) guard stdoutJoined, stderrJoined else { - terminate(process, group: group) + terminate(process) // The same pinned order as the expiry arm: close FIRST (an open // FD is what wedges a reader), then join. stdoutDrain.close() @@ -559,6 +579,50 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { outcome: .timeout ) } + // AND A DRAIN CAN FINISH BECAUSE `read(2)` DIED, NOT BECAUSE THE + // STREAM ENDED (fn-4.24, PR #460 round 18). A hard read error is + // terminal for the worker — it signals `finished` like EOF does, so + // the joins above answer true — and until the drain RECORDED that + // ending, this boundary had no way to tell the two apart: the + // partial buffer shipped as `.success`. This is the first read of + // that fact, on the one path that can still turn into a success. + // + // The answer is `.timeout` — the same class as the unjoined-drain + // arm above, for the same reason: the output could not be read to + // completion, and a porcelain parser must never count a short + // stdout. Ask the standing question: can a retry differ? Yes — a + // fresh invocation opens fresh pipes and fresh descriptors, so + // nothing deterministic pins the error, and every caller's `.timeout` + // handling (re-scan or refuse) is the right disposition. + // + // NO `terminate` in this arm, deliberately: the child has already + // exited (`waitForExit` succeeded) and both drains have returned, so + // unlike the arm above there is no evidence of a surviving holder — + // a kill whose removal no cell could redden would be an unevidenced + // guard by this repo's own rule. + // + // WOULD A DOWNSTREAM PARSER HAVE CAUGHT THE TRUNCATION? (asked by + // the task spec, answered from the parsers' source): only partially, + // so this gate is load-bearing. `GitWorktreePorcelainParser.parse` + // fails closed on a cut that lands mid-field or mid-record (its + // final `start == endIndex && current.isEmpty` guard), but a cut on + // a RECORD BOUNDARY — right after a record's closing empty field — + // parses cleanly as a repository with fewer worktrees. + // `WorktreeStalenessAssessor.verdict(for:)` catches nothing: a + // truncated `status --porcelain` has fewer entries and an empty one + // is `.clean` by definition. The first-line readers (`rev-parse`, + // `symbolic-ref`) accept whatever line survives. None of them can + // refuse what this boundary fails to mark. + if stdoutDrain.terminalReadFailure != nil + || stderrDrain.terminalReadFailure != nil + { + stdoutDrain.close() + stderrDrain.close() + return GitCommandInvocation( + profile: profile, argv: argv, environment: environment, + outcome: .timeout + ) + } let capturedStdout = stdoutDrain.closeAndCapture() let capturedStderr = stderrDrain.closeAndCapture() @@ -588,74 +652,258 @@ final class GitCommandRunner: GitCommandRunning, @unchecked Sendable { ) } - /// The child's process group ID — but ONLY when the child is that - /// group's own LEADER, and read ONCE, immediately after `run()`, while - /// the pid is provably still allocated to this child. - /// - /// MEASURED on this machine (Darwin 25.5, 2026-08-23) with a 15-line - /// Foundation program: `Process` places every child in a NEW process - /// group whose id IS the child's pid — parent `pid 47862 pgrp 47770`, - /// child `pid 47866 pgid 47866`. So `kill(-pid, …)` reaches the child - /// AND every descendant that has not deliberately left the group, which - /// is the whole of what a git command spawns (helpers, submodule - /// recursions, the odd `sh`). - /// - /// THE `group == pid` TEST IS THE SAFETY, NOT DECORATION. If a future - /// Foundation left the child in the CALLER's group, `-group` would - /// signal this whole process — the app, or the test bundle. `nil` then - /// means "signal the pid alone", which is exactly what the runner did - /// before r18 and is never worse than it. - private static func ownProcessGroup(of process: Process) -> pid_t? { - let pid = process.processIdentifier - guard pid > 0 else { return nil } - let group = getpgid(pid) - guard group == pid else { return nil } - return group - } - /// SIGTERM the TREE → bounded grace → SIGKILL the TREE if anything in it /// is still there → bounded reap wait. Every step bounded; a /// SIGTERM-ignoring child cannot survive it, and neither can a /// descendant that outlives it. /// /// **THE TREE, NOT THE PID** (PR #460 codex r18, C8). Both steps used to - /// target `process.processIdentifier` alone. Killing the parent of a - /// timed-out `git` leaves whatever it spawned — a helper invoked while - /// inspecting a submodule, say — orphaned and running, still holding the - /// inherited pipe write end and still traversing repositories after the - /// runner has returned `.timeout`. The old timeout cell could not see it: - /// its fixture launches `sleep`, and it checked only the shell's pid. + /// target the child's pid alone. Killing the parent of a timed-out `git` + /// leaves whatever it spawned — a helper invoked while inspecting a + /// submodule, say — orphaned and running, still holding the inherited + /// pipe write end and still traversing repositories after the runner has + /// returned `.timeout`. The old timeout cell could not see it: its + /// fixture launches `sleep`, and it checked only the shell's pid. + /// + /// **THE GROUP IS A SPAWN-TIME FACT, NOT A DISCOVERY** (fn-4.27). + /// r18 read the group with `getpgid` after `run()`, "while the pid is + /// provably still allocated" — and a leader that exited inside that + /// window failed the read, leaving `group == nil` and this protocol + /// aimed at a pid that was already a corpse while the descendants — the + /// very case the group protocol exists for — ran on unsignalled. The + /// r18 comment called nil "never worse than before"; for that exited- + /// leader case it was exactly as bad as before, which was the defect. + /// `SpawnedProcess.launch` establishes the group with + /// `POSIX_SPAWN_SETPGROUP` at creation, so `process.pid` IS the group + /// id, leader dead or alive, and nothing here is conditional on + /// observing a live leader. /// /// AND THE ESCALATION IS DECIDED ON THE GROUP, NOT ON THE PARENT. A /// parent that exits inside the grace window says nothing about a /// descendant that ignored the same SIGTERM, so after the grace the /// group is probed (`kill(-group, 0)`) and SIGKILLed if anything answers. /// - /// DISCLOSED RESIDUAL — a pid-recycle window. `group` equals the child's - /// pid, and once the kernel reaps that child the pid may be reissued; a - /// process that then became a group leader with the same id would receive - /// the SIGKILL below. macOS pids are issued sequentially and wrap near - /// 99999, so hitting it needs ~100k spawns inside one `terminationGrace`. - /// The pre-r18 code had the same class of window on its bare - /// `kill(pid, SIGKILL)`; what is new is that the post-exit probe can fire - /// after the parent has already been reaped. - private func terminate(_ process: Process, group: pid_t?) { - signal(SIGTERM, to: process, group: group) + /// DISCLOSED RESIDUAL — a pid-recycle window, unchanged in class from + /// r18: the group id equals the child's pid, and once the kernel reaps + /// that child the pid may be reissued; a process that then became a + /// group leader with the same id would receive the SIGKILL below. macOS + /// pids are issued sequentially and wrap near 99999, so hitting it needs + /// ~100k spawns inside one `terminationGrace`. + private func terminate(_ process: SpawnedProcess) { + process.signalTree(SIGTERM) let parentExited = process.waitForExit(within: terminationGrace) - let treeStillThere = group.map { kill(-$0, 0) == 0 } ?? false + let treeStillThere = kill(-process.pid, 0) == 0 guard !parentExited || treeStillThere else { return } - signal(SIGKILL, to: process, group: group) + process.signalTree(SIGKILL) _ = process.waitForExit(within: terminationGrace) } +} + +// MARK: - The spawn seam (fn-4.27) + +/// The runner's own subprocess handle: `posix_spawn(2)` with +/// `POSIX_SPAWN_SETPGROUP` (pgroup 0), so the child is made the LEADER of a +/// new process group — id == its pid — by the kernel, atomically, as part +/// of creation. +/// +/// WHY NOT `Foundation.Process` (the shape this replaces): Foundation does +/// not expose spawn attributes, so the group could only be DISCOVERED after +/// launch (`getpgid`), and that read required the leader to still be alive +/// — the fn-4.27 defect. WHY NOT the other in-repo option, a setpgid +/// trampoline executable: it would be a second `exec` layer to ship, and +/// `scripts/bundle.sh` copies nothing it is not told to (the v2.1.0 +/// lesson), while a shell trampoline is banned outright by this seam's own +/// fence (`testTheNewGitFilesNeverConstructAShellString`). `posix_spawn` +/// keeps the argv EXACTLY as `Process` passed it — `/usr/bin/env` + +/// ["git", …], argv-only, never a shell — and adds no shipped binary. +/// +/// PARITY WITH WHAT `Process` DID, stated because each is load-bearing: +/// - stdin is `/dev/null` (file action `open`), stdout/stderr are the two +/// pipes' write ends (file action `dup2`); +/// - every OTHER descriptor is closed in the child +/// (`POSIX_SPAWN_CLOEXEC_DEFAULT` — the same hygiene `Process` applies), +/// so a git descendant can inherit at most the two pipe ends the drains +/// are watching; +/// - the parent's copies of the write ends are closed by `launch` the +/// moment the spawn returns — with `Process` the framework did this; a +/// forgotten close here would mean the drains NEVER see EOF (the parent +/// itself would hold the pipe open); +/// - the bounded wait polls `waitpid(WNOHANG)` under the same monotonic +/// deadline/backoff shape as `Process.waitForExit(within:)` (a bare +/// `waitUntilExit` misses wakeups under concurrent reaping — house +/// doctrine, `CacheCategory.swift`). `waitpid` is targeted at THIS pid, +/// so concurrent `Process` users elsewhere in the app are untouched. +/// +/// Single-threaded by contract: `execute` calls every member from the one +/// thread that ran `launch` (the same discipline the `Process` shape had), +/// so the reaped status needs no lock. +private final class SpawnedProcess { + + /// The child's pid AND — by the spawn attribute — its process group id. + let pid: pid_t + + /// The raw `wait(2)` status once the child has been reaped. + private var reapedStatus: Int32? + + private init(pid: pid_t) { self.pid = pid } + + struct SpawnFailure: Error { let code: Int32 } + + /// Spawn `executablePath` with `[executablePath] + arguments` as argv — + /// exactly the vector `Process` builds — in a NEW process group. + static func launch( + executablePath: String, + arguments: [String], + environment: [String: String], + stdout: Pipe, + stderr: Pipe + ) throws -> SpawnedProcess { + // EVERY SETUP RESULT IS CHECKED (PR #461 codex r1, P2). These calls + // allocate, so under transient pressure they answer ENOMEM — and + // discarding that answer is not a lost error, it is a SILENTLY + // DIFFERENT CHILD. A dropped `adddup2` leaves git's stdout attached + // to whatever descriptor 1 already was: git exits 0, its output never + // reaches the drain, and the empty buffer is accepted as a complete + // answer — the exact class fn-4.24 closed at the execute boundary, + // re-entering through the spawn. A dropped attribute call defeats the + // process-group isolation fn-4.27 established, silently. + // + // `throws` is the honest answer: the caller maps it to + // `.gitUnavailable`, which is retried rather than cached (fn-4.25's + // definitive-only rule), and ENOMEM is transient — so a retry can + // genuinely differ. + func require(_ result: Int32) throws { + guard result == 0 else { throw SpawnFailure(code: result) } + } + + var fileActions: posix_spawn_file_actions_t? + try require(posix_spawn_file_actions_init(&fileActions)) + defer { posix_spawn_file_actions_destroy(&fileActions) } + try require(posix_spawn_file_actions_addopen( + &fileActions, 0, "/dev/null", O_RDONLY, 0 + )) + try require(posix_spawn_file_actions_adddup2( + &fileActions, stdout.fileHandleForWriting.fileDescriptor, 1 + )) + try require(posix_spawn_file_actions_adddup2( + &fileActions, stderr.fileHandleForWriting.fileDescriptor, 2 + )) + + var attributes: posix_spawnattr_t? + try require(posix_spawnattr_init(&attributes)) + defer { posix_spawnattr_destroy(&attributes) } + // THE fn-4.27 LINE: the child leads a new group (id == pid) from + // birth. CLOEXEC_DEFAULT closes everything the file actions above + // did not explicitly wire. + try require(posix_spawnattr_setflags( + &attributes, + Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT) + )) + try require(posix_spawnattr_setpgroup(&attributes, 0)) + + // NIL IS THE TERMINATOR, SO A FAILED COPY IS NOT A LOST ARGUMENT — + // IT IS A DIFFERENT COMMAND (PR #461 codex r2). `strdup` allocates, + // so under the same pressure the setup checks above exist for it + // answers nil, and `map { strdup($0) }` wrote that nil straight into + // the vector where `posix_spawn` reads it as end-of-arguments. A + // failed copy of "git" leaves argv `[/usr/bin/env, nil]`: env runs + // with no utility, PRINTS ITS ENVIRONMENT, and exits 0 — so the + // runner accepts unrelated output as a completed git command. Same + // silently-different-child class as a dropped `adddup2`, and quieter, + // because this one succeeds. + // + // Each `defer` is registered BEFORE its vector is filled, so a throw + // part-way through frees what was already copied. + func duplicate(_ text: String) throws -> UnsafeMutablePointer { + guard let copy = strdup(text) else { + throw SpawnFailure(code: ENOMEM) + } + return copy + } + + var argv: [UnsafeMutablePointer?] = [] + defer { argv.forEach { free($0) } } + for argument in [executablePath] + arguments { + argv.append(try duplicate(argument)) + } + argv.append(nil) + var envp: [UnsafeMutablePointer?] = [] + defer { envp.forEach { free($0) } } + for variable in environment { + envp.append( + try duplicate("\(variable.key)=\(variable.value)") + ) + } + envp.append(nil) + + var pid: pid_t = 0 + let rc = posix_spawn( + &pid, executablePath, &fileActions, &attributes, argv, envp + ) + // The parent's write-end copies close on BOTH arms: on success the + // child holds its dup2'd copies and the drains must be able to see + // EOF; on failure nothing holds the pipe at all. + try? stdout.fileHandleForWriting.close() + try? stderr.fileHandleForWriting.close() + guard rc == 0 else { throw SpawnFailure(code: rc) } + return SpawnedProcess(pid: pid) + } - /// The group if there is one and it accepted the signal, the pid - /// otherwise. Never both: a group signal already reached the child. - private func signal(_ code: Int32, to process: Process, group: pid_t?) { - if let group, kill(-group, code) == 0 { return } - let pid = process.processIdentifier - guard pid > 0 else { return } + /// `true` once the child has exited — `terminationStatus` is then safe + /// to read — or `false` if it is still running when `timeout` elapses. + /// Same deadline/backoff shape as `Process.waitForExit(within:)`. + func waitForExit(within timeout: TimeInterval) -> Bool { + let deadline = DispatchTime.now() + timeout + var pollInterval: UInt32 = 1_000 // µs; backs off to 16ms + while !reapIfExited() { + if DispatchTime.now() >= deadline { return false } + usleep(pollInterval) + pollInterval = min(pollInterval * 2, 16_000) + } + return true + } + + /// Exit code for a normal exit; `-(signal)` for a signal death — + /// negative so no real exit code (0…255) can be impersonated. The two + /// call sites test `== 0` and `== 127` only. `-1` before the child has + /// been proven exited (callers gate on `waitForExit` first). + var terminationStatus: Int32 { + guard let status = reapedStatus else { return -1 } + if status & 0x7f == 0 { return (status >> 8) & 0xff } + return -(status & 0x7f) + } + + /// Signal the GROUP; fall back to the pid alone only if the group + /// signal is refused (then the pid-direct kill fails the same way for + /// the same reason — the fallback is a belt, kept because it costs one + /// syscall and can never signal anything the group signal would not). + func signalTree(_ code: Int32) { + if kill(-pid, code) == 0 { return } kill(pid, code) } + + /// One targeted, non-blocking reap attempt. `waitpid` names THIS pid, + /// so nothing here can steal another subsystem's child. + private func reapIfExited() -> Bool { + if reapedStatus != nil { return true } + var status: Int32 = 0 + let reaped = waitpid(pid, &status, WNOHANG) + if reaped == pid { + // Exited or killed. (WUNTRACED is not passed, so stop/continue + // states never come back from this call.) + reapedStatus = status + return true + } + if reaped == -1 && errno == ECHILD { + // Not our child any more — nothing left to wait for. Recorded + // as a signal-less zero so `terminationStatus` stays readable; + // unreachable in practice (the reap is pid-targeted and only + // this instance ever waits on it). + reapedStatus = 0 + return true + } + return false + } } // MARK: - Concurrent pipe drain @@ -741,6 +989,10 @@ final class PipeDrain: @unchecked Sendable { private var buffer = Data() private var isClosed = false private var closeRequested = false + /// The errno of a HARD `read(2)` failure that ended the drain — `nil` + /// when the drain ended at EOF or on a requested close (fn-4.24). + /// Guarded by `lock`, like the buffer it qualifies. + private var terminalReadErrno: Int32? init(pipe: Pipe) { handle = pipe.fileHandleForReading @@ -785,6 +1037,24 @@ final class PipeDrain: @unchecked Sendable { return buffer } + /// How the drain DIED, when it died on a hard `read(2)` error rather + /// than reaching EOF: the errno, or `nil` for a clean ending (EOF or a + /// requested close). fn-4.24 — before this existed, a died-on-error + /// drain signalled `finished` exactly like EOF, `join(within:)` answered + /// true, and `execute` shipped the partial buffer as `.success`; a + /// truncated porcelain listing does not look malformed, it looks like a + /// repository with fewer worktrees. + /// + /// Takes `lock` without announcing itself — the same shape `captured`'s + /// doc warns about — so production reads it ONLY after a successful + /// `join(within:)`, when the worker has provably returned and cannot + /// contend. + var terminalReadFailure: Int32? { + lock.lock() + defer { lock.unlock() } + return terminalReadErrno + } + /// END the drain and take everything it read — the one bounded spelling /// of that pair, and what production uses. /// @@ -917,6 +1187,12 @@ final class PipeDrain: @unchecked Sendable { } // A hard read error is terminal: polling a broken // descriptor would spin, and there is nothing left to read. + // RECORDED, never equated with EOF (fn-4.24): until it was, + // this arm signalled `finished` exactly like end-of-stream, + // and `execute` shipped the partial buffer as `.success` — + // a truncated porcelain listing that reads as a repository + // with fewer worktrees. `lock` is held here. + terminalReadErrno = code isDone = true break readAvailable } diff --git a/Sources/Cacheout/Scanner/GitWorktreeInventory.swift b/Sources/Cacheout/Scanner/GitWorktreeInventory.swift index 3209de4c..f5c9d459 100644 --- a/Sources/Cacheout/Scanner/GitWorktreeInventory.swift +++ b/Sources/Cacheout/Scanner/GitWorktreeInventory.swift @@ -318,6 +318,23 @@ struct GitWorktreeGitdirResolver { let pointer = pointerPath(inFileAt: dotGit, relativeTo: worktreePath) else { return nil } + // THE GATE READS THE POINTER FIRST, AS SPELLED (fn-4.26). On an + // `.automatic` scan the injected provider answers `.absent` for a + // deferred (TCC-protected) target — and its predicate classifies a + // directly-protected spelling lexically — so `canonicalize` below, + // which is `realpath(3)` and therefore itself a traversal of every + // component it resolves, runs only on a target the gate permitted. + // The previous order canonicalized first, which walked the protected + // path before the deferral could answer. No verdict changed: the + // kinds that pass here are exactly the ones the canonical probe + // below could still map onto a directory (a directory spelling, or a + // symlink to one); every other kind, absence, and probe failure + // produced nil AFTER the traversal — now before it. + switch identity.probeKind(of: pointer) { + case .kind(.directory), .kind(.symlink): break + default: return nil + } + let adminDirectory = identity.canonicalize(pointer) guard identity.probeKind(of: adminDirectory) == .kind(.directory), adminDirectory.deletingLastPathComponent().lastPathComponent @@ -326,15 +343,187 @@ struct GitWorktreeGitdirResolver { // BACK-LINK: the admin directory must point back at THIS worktree's // `.git` file. One-way, stale, and forged pointers all stop here. + // + // The back-link TARGET is data too, and `sameLocation`'s fallback + // canonicalizes BOTH sides when either identity is missing — which a + // deferred side always is — so the same gate answers for it before + // the comparison (fn-4.26). A deferred target could never have + // verified anyway: `dotGit` demonstrably exists, and a comparison of + // an existing file against an untouchable one proves nothing. + // Absence and probe failure fail exactly as they did before — the + // fallback comparison they used to reach could not answer true for a + // target that is not there while `dotGit` is. let backlinkFile = adminDirectory.appendingPathComponent("gitdir") guard identity.probeKind(of: backlinkFile) == .kind(.regularFile), let backlinkTarget = pathContents(of: backlinkFile, relativeTo: adminDirectory), + case .kind = identity.probeKind(of: backlinkTarget), identity.sameLocation(backlinkTarget, dotGit) else { return nil } return adminDirectory } + /// The BARE-repository proof (fn-4.28): is this directory a bare + /// repository git itself would accept? `nil` = fail closed. + /// + /// Discovery keys on an entry named `.git`, and a bare repository has + /// none — so a bare parent whose checkouts were ALL deleted used to name + /// no group and the prune tier never ran for exactly the case it exists + /// for. This proof is the discovery half of closing that gap; the + /// listing half stays with `crossValidate`, whose bare branch requires + /// git's OWN porcelain first record to declare the same directory bare + /// before anything downstream is derived from it. + /// + /// WHAT IS REQUIRED, all probed through the injected identity provider + /// (so the TCC deferral answers first, exactly as it does for the + /// `gitdir:` pointer reads above), and each read only AFTER its + /// `probeKind` gate: + /// + /// - `HEAD`, a regular file — never a symlink — whose content is a shape + /// git's own `validate_headref` accepts: a `ref: refs/…` symref or a + /// 40/64-hex detached object id; + /// - `objects`, a directory; + /// - a refs backend: `refs` a directory, or the reftable layout's + /// `reftable` directory; + /// - `config`, a regular file that DECLARES bareness the way git's own + /// writer spells it (a `bare = true` line). A git directory that backs + /// a working tree elsewhere (`--separate-git-dir`) carries the same + /// HEAD/objects/refs shape with `bare = false`, and admitting it here + /// would publish a cross-validation issue on every scan for a healthy + /// repository this scanner deliberately does not cover. + /// + /// RESIDUAL, disclosed rather than implied: a bare repository whose + /// config spells bareness any way other than git's writer (`bare = yes`, + /// an include, no config file at all) stays undiscovered — the same + /// silent non-discovery every bare repository had before fn-4.28, never + /// a refusal dressed as retryable. + func bareRepositoryGitDirectory(at directory: URL) -> URL? { + let head = directory.appendingPathComponent("HEAD") + guard identity.probeKind(of: head) == .kind(.regularFile), + let headContents = identity.smallRegularFileText( + at: head, limit: FileSystemIdentityProvider.gitPointerByteLimit + ), + Self.isAcceptableHeadContent(headContents) + else { return nil } + guard identity.probeKind(of: directory.appendingPathComponent("objects")) + == .kind(.directory) + else { return nil } + let hasRefs = identity.probeKind(of: directory.appendingPathComponent("refs")) + == .kind(.directory) + let hasReftable = identity.probeKind(of: directory.appendingPathComponent("reftable")) + == .kind(.directory) + guard hasRefs || hasReftable else { return nil } + let config = directory.appendingPathComponent("config") + guard identity.probeKind(of: config) == .kind(.regularFile), + let configContents = identity.smallRegularFileText( + at: config, limit: FileSystemIdentityProvider.gitConfigByteLimit + ), + Self.declaresBare(configContents) + else { return nil } + return directory + } + + /// The HEAD shapes git's `validate_headref` accepts: `ref: refs/…` + /// naming a non-empty ref, or a detached 40-hex (SHA-1) / 64-hex + /// (SHA-256) object id. + static func isAcceptableHeadContent(_ contents: String) -> Bool { + let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) + let symrefPrefix = "ref: refs/" + if trimmed.hasPrefix(symrefPrefix) { + return trimmed.count > symrefPrefix.count + } + guard trimmed.count == 40 || trimmed.count == 64 else { return false } + return trimmed.allSatisfy { $0.isHexDigit && ($0.isNumber || $0.isLowercase) } + } + + /// The EFFECTIVE `core.bare`, resolved the way git resolves it: section + /// context is honoured and the LAST value wins. + /// + /// The first version matched any line whose key was `bare`, anywhere in + /// the file (PR #461 codex r2). Two shapes broke it, and git reads both + /// the other way: a healthy `--separate-git-dir` repository carrying + /// `core.bare = false` PLUS an unrelated section with its own `bare` key + /// was admitted as bare, and an early `core.bare = true` later overridden + /// by `false` stayed admitted. Admitting one is not a harmless + /// over-discovery — the scanner then runs `worktree list` against a + /// healthy non-bare admin directory and publishes a cross-validation + /// `unreadable` issue on every scan, for a repository shape this scanner + /// deliberately does not cover. + /// + /// RESIDUAL, unchanged and still disclosed: only git's own writer + /// spelling of the VALUE counts. `bare = yes`, a valueless `bare` key + /// (which git reads as true) and an `include.path` indirection all leave + /// the repository undiscovered — the same silence every bare repository + /// had before fn-4.28, never a refusal dressed as retryable. + static func declaresBare(_ configContents: String) -> Bool { + var section = "" + var subsection: String? + var effective: String? + for rawLine in configContents.split( + whereSeparator: \.isNewline + ) { + var line = Substring(Self.withoutComment(rawLine)) + .drop(while: { $0 == " " || $0 == "\t" }) + if line.first == "[" { + guard let close = line.firstIndex(of: "]") else { continue } + (section, subsection) = Self.sectionName( + line[line.index(after: line.startIndex)..= 2, value.hasPrefix("\""), value.hasSuffix("\"") { + value = String(value.dropFirst().dropLast()) + } + // LAST WINS, which is the whole point: an override must be able + // to turn bareness OFF, not merely fail to turn it on. + effective = value + } + return effective == "true" + } + + /// The line with any unquoted `#`/`;` comment removed. Quoted because a + /// git config VALUE may legitimately contain either character. + private static func withoutComment(_ line: Substring) -> String { + var out = "" + var quoted = false + var escaped = false + for character in line { + if escaped { out.append(character); escaped = false; continue } + if character == "\\" { out.append(character); escaped = true; continue } + if character == "\"" { quoted.toggle(); out.append(character); continue } + if !quoted, character == "#" || character == ";" { break } + out.append(character) + } + return out + } + + /// `[core]` -> ("core", nil); `[core "sub"]` -> ("core", "sub"). Section + /// names are case-insensitive in git, subsection names are not — and a + /// subsection makes the key `core.sub.bare`, which is NOT `core.bare`. + private static func sectionName( + _ header: Substring + ) -> (String, String?) { + guard let quote = header.firstIndex(of: "\"") else { + return ( + header.trimmingCharacters(in: .whitespaces).lowercased(), nil + ) + } + let name = header[..` pointer file. Relative targets resolve /// against `base`. private func pointerPath(inFileAt url: URL, relativeTo base: URL) -> URL? { - guard let contents = try? String(contentsOf: url, encoding: .utf8) else { return nil } + guard let contents = identity.smallRegularFileText( + at: url, limit: FileSystemIdentityProvider.gitPointerByteLimit + ) else { return nil } let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix(Self.gitdirPrefix) else { return nil } let target = String(trimmed.dropFirst(Self.gitdirPrefix.count)) @@ -415,7 +626,9 @@ struct GitWorktreeGitdirResolver { /// Read a bare path file (`gitdir`, `commondir`). Relative targets /// resolve against `base`. private func pathContents(of url: URL, relativeTo base: URL) -> URL? { - guard let contents = try? String(contentsOf: url, encoding: .utf8) else { return nil } + guard let contents = identity.smallRegularFileText( + at: url, limit: FileSystemIdentityProvider.gitPointerByteLimit + ) else { return nil } let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return resolve(trimmed, relativeTo: base) @@ -637,7 +850,15 @@ struct GitWorktreeAdminMapper { worktreePaths.reserveCapacity(gated.count) for entry in gated { let backlink = entry.appendingPathComponent("gitdir") - guard let contents = try? String(contentsOf: backlink, encoding: .utf8) else { + // As at the performer's pointer read: "unreadable" is transient + // for every cause but one — a back-link past + // `gitPointerByteLimit` is past a fixed constant, and the + // `.incomplete` this returns is reported with a re-scan remedy + // that cannot clear it (merge gate r4, P6). + guard let contents = identity.smallRegularFileText( + at: backlink, + limit: FileSystemIdentityProvider.gitPointerByteLimit + ) else { return .incomplete( reason: "admin entry \(entry.path) gitdir file is unreadable" ) diff --git a/Sources/Cacheout/Scanner/GitWorktreeScanner.swift b/Sources/Cacheout/Scanner/GitWorktreeScanner.swift index b526a21a..505a5000 100644 --- a/Sources/Cacheout/Scanner/GitWorktreeScanner.swift +++ b/Sources/Cacheout/Scanner/GitWorktreeScanner.swift @@ -96,10 +96,12 @@ /// the resolver-carried admin container all lie inside the SAME declared dev /// root — the parent alone may EQUAL the root (a /// dev root that IS a repository is legal), everything else is a STRICT -/// descendant. A worktree outside every root, or a parent/admin container -/// outside the worktree's root, becomes a `.containerRefused` issue and NEVER -/// an item (D3: no display-only admission exists, and emitting one malforms -/// the whole outcome). +/// descendant. A worktree outside every root becomes a `.containerRefused` +/// issue; a parent/admin container outside the worktree's root becomes a +/// `.mutationScopeRefused` one (fn-4.12 — that worktree IS inside a +/// configured root, so the old kind's label was false for it). NEVER an item +/// either way (D3: no display-only admission exists, and emitting one +/// malforms the whole outcome). /// /// Because `GitWorktreeReclaimPlan.violation` checks containment LEXICALLY on /// the verbatim spellings (a second resolution at validation time would race @@ -132,15 +134,17 @@ import os /// can name a path under a TCC-protected ancestor. The path is unknowable /// before the pointer is read, so the gate cannot precede the resolution — it /// is applied INSIDE it, by making the deferred paths look like they are not -/// there. The resolver probes each pointer target before it opens anything, so -/// on an automatic scan nothing under a protected ancestor is ever opened, -/// enumerated or read. -/// -/// What DOES still happen on a deferred path is `canonicalize` — `realpath(3)`, -/// the same operation fn-4's pinned protected-root classification performs on a -/// protected root before skipping it (`ProjectTreeWalker.isProtectedRoot` -/// canonicalizes first, by design). The house doctrine already draws the line -/// there, and this wrapper does not move it. +/// there. The resolver probes each pointer target before it opens — and, +/// since fn-4.26, before it CANONICALIZES — anything: `realpath(3)` traverses +/// every component it resolves, so the probe-then-canonicalize order is what +/// keeps an automatic scan from walking a protected path while ruling it +/// untouchable. (This doc used to claim a pre-gate `canonicalize` was fine +/// because `ProjectTreeWalker.isProtectedRoot` "canonicalizes first, by +/// design" — that predicate now classifies a directly-protected spelling +/// LEXICALLY before it ever canonicalizes, so neither side dereferences such +/// a path, and the line the claim leaned on no longer exists.) On an +/// automatic scan nothing under a protected ancestor is opened, enumerated, +/// read, or realpath'd through. /// /// `.absent` rather than `.failed` deliberately: a policy deferral is not a /// problem to report (the walker skips a vanished entry quietly for the same @@ -182,7 +186,14 @@ private final class DeferringIdentityProvider: FileSystemIdentityProvider { // Path arithmetic is delegated UNCHANGED so an injected test provider's // aliasing still flows through (and so the deferral above is the only - // behavioural difference). + // behavioural difference). SAFE only because of ORDER (fn-4.26): the + // resolver asks `probeKind` — which the deferral intercepts — for every + // pointer-derived path BEFORE it canonicalizes it, and the deferral + // predicate itself classifies directly-protected spellings lexically. A + // caller that canonicalized first would traverse the protected path + // through this very pass-through; that ordering is pinned red by + // `testAutomaticScanNeverRealpathsThroughAProtectedAdminDirectory` and + // the resolver-level deferral cells. override func realPath(of path: String) -> String? { wrapped.realPath(of: path) } override func canonicalize(_ url: URL) -> URL { wrapped.canonicalize(url) } @@ -190,8 +201,10 @@ private final class DeferringIdentityProvider: FileSystemIdentityProvider { // MARK: - Discovery -/// What a `.git` entry proved about the directory that holds it. Both kinds -/// come from ONE lstat no-follow probe the walker already performed. +/// What a walk event proved about the directory it describes. The first two +/// kinds come from ONE lstat no-follow probe of a `.git` entry the walker +/// already performed; the third from the resolver's bare-shape proof over a +/// directory that has no `.git` entry at all. enum GitWorktreeDiscoveryKind: Equatable, Sendable { /// `.git` is a DIRECTORY — the holder is a main checkout, and that /// directory IS the repository's git directory. @@ -199,6 +212,13 @@ enum GitWorktreeDiscoveryKind: Equatable, Sendable { /// `.git` is a regular FILE — the holder is a linked worktree whose /// pointer the fn-5.1 resolver validates bidirectionally. case linkedWorktree + /// The directory has NO `.git` entry and IS a git directory itself: the + /// bare-repository shape, proved by the resolver's + /// `bareRepositoryGitDirectory` (fn-4.28). Without this kind a bare + /// parent whose checkouts were all deleted was never discovered, and the + /// prune tier never ran for exactly the all-checkouts-gone case it + /// exists to reclaim. + case bareRepository } /// One directory the walk proved to be a checkout, in walk order. @@ -379,6 +399,34 @@ struct GitWorktreeScanner: @unchecked Sendable { // prunes NOTHING (the walker's own `.git` hard prune is the only prune // this scanner needs, and a name-based skip list is the anti-pattern // that made the field case invisible). + // + // YES, `BuildArtifactsScanner.scan` walks the same kept roots + // (fn-4.18, Codex PR #460 P2: "nearly double filesystem I/O and + // latency"). MEASURED before designing anything, and the claim is + // CORRECTED, not inherited — the numbers and their pinned facts live + // in DevTreeWalkMeasurementTests. The two walks are asymmetric: the + // build walk prunes every matched artifact directory while this one + // deliberately descends everything (nested repositories are its + // quarry), so on an artifact-bearing tree the duplicated enumeration + // is only their intersection — 249 of 11544 entry probes (2.2%) on + // the measured tree. A fused walk must carry THIS walk's unpruned + // reach, and the build scanner pays its sizing census either way. + // True doubling exists only on a tree with no artifacts and no + // repository, where a whole walk measured single-digit milliseconds. + // + // WHY THE FAN-IN IS RECORDED RATHER THAN BUILT: the walker already + // takes N consumers, but a shared walk must be PER SCAN SESSION — + // the witness this consumer captures is walk-instant and is what the + // whole r16/r17 re-proof chain hangs off, so it can never be served + // from an earlier session's walk. Scanners run concurrently in the + // session task group, are also scanned individually (the GUI's and + // CLI's scanner filters), and nothing at the `SpaceScanner` boundary + // names a session for two `scan(context:)` calls to rendezvous on — + // coalescing on accidental concurrency would make the walk count + // timing-dependent in the safety path. Buying the measured 2% would + // therefore mean rewriting the protocol's "a scanner does its own + // I/O" contract, which the task's own boundary forbids: recorded, + // with the numbers, and stopped (fn-4.18). var discoveries: [GitWorktreeDiscovery] = [] let walker = ProjectTreeWalker( home: home, pathGuard: pathGuard, provider: provider @@ -523,6 +571,36 @@ struct GitWorktreeScanner: @unchecked Sendable { continue } } + + // THE BARE BRANCH (fn-4.28). A bare repository stores `HEAD`, + // `objects` and its refs backend directly at its root, with no + // `.git` entry — so once its linked checkouts are all deleted, + // nothing above could discover it and the prune tier never ran for + // exactly the all-checkouts-gone case it exists for. + // + // The event's OWN lstat'd entries are the cost gate: only a + // directory that already shows the full bare shape — and no `.git` + // entry of ANY kind, because a directory holding one is a checkout + // or nothing — pays for the resolver's proof, which re-probes every + // component through the (possibly deferring) provider and reads + // `HEAD` and `config` only behind their `probeKind` gates. A + // directory that merely LOOKS bare fails that proof and contributes + // nothing: no discovery, no issue, no git subprocess. + if !event.entries.contains(where: { $0.name == ".git" }), + event.entries.contains(where: { + $0.name == "HEAD" && $0.kind == .regularFile + }), + event.entries.contains(where: { + $0.name == "objects" && $0.kind == .directory + }), + event.entries.contains(where: { + ($0.name == "refs" || $0.name == "reftable") && $0.kind == .directory + }), + resolver.bareRepositoryGitDirectory(at: event.directory) != nil { + discoveries.append(GitWorktreeDiscovery( + directory: event.directory, kind: .bareRepository + )) + } return [] } @@ -612,6 +690,14 @@ struct GitWorktreeScanner: @unchecked Sendable { } gitDirectory = discovery.adminDirectory .flatMap { resolver.commonGitDirectory(forAdminDirectory: $0) } + case .bareRepository: + // The directory ITSELF is the git directory — the resolver's + // bare-shape proof said so in the walk consumer (fn-4.28). + // Canonicalized exactly as the main-checkout arm + // canonicalizes `/.git`, so a bare parent reached both + // ways (its own shape AND a live checkout's `gitdir:` + // pointer) names ONE group and pays for ONE listing. + gitDirectory = provider.canonicalize(discovery.directory) } guard let gitDirectory else { continue } let key = gitDirectory.path @@ -717,7 +803,10 @@ struct GitWorktreeScanner: @unchecked Sendable { !isDeferred($0.directory, context: context) } // A main checkout is the friendliest `-C` target; a bare parent has - // none, so its linked worktree is used instead. + // none, so its own discovered directory — or, before fn-4.28 gave + // bare repositories a discovery of their own, a linked worktree — + // is used instead (`git -C worktree list` is the documented + // bare workflow). guard let listingTarget = (reachable.first { $0.kind == .mainCheckout } ?? reachable.first)?.directory else { return .processed // every reachable checkout is protected — deferred @@ -993,6 +1082,12 @@ struct GitWorktreeScanner: @unchecked Sendable { // path outside every root is exactly what the secondary gate exists to // limit, and no assessment of it could change the answer. guard !rootsStrictlyContaining(worktreeIdentity, in: bindings).isEmpty else { + // `.containerRefused` is TRUE here and stays (fn-4.12 producer + // audit): its fixed GUI label — "not a configured search root" + // — is exactly this worktree's condition, sitting outside every + // configured root. The containment arms below are the ones that + // moved (`.mutationScopeRefused`): their candidates ARE inside + // a configured root, so that same sentence was false there. issues.append(ScanIssue( url: record.path, kind: .containerRefused, detail: "registered worktree '\(record.path.path)' is outside " @@ -1094,8 +1189,12 @@ struct GitWorktreeScanner: @unchecked Sendable { ) guard case .bound(let root, let parent, let strict) = scope else { if case .unbound(let reason) = scope { + // `.mutationScopeRefused`, NOT `.containerRefused` + // (fn-4.12): this worktree IS inside a configured dev root + // — the detail's first clause has always said so — while + // the old kind's fixed GUI label said the opposite. issues.append(ScanIssue( - url: record.path, kind: .containerRefused, + url: record.path, kind: .mutationScopeRefused, detail: "worktree '\(record.path.path)' is inside a " + "configured dev root but \(reason) — git mutates the " + "parent repository's admin data, so the whole " @@ -1453,8 +1552,13 @@ struct GitWorktreeScanner: @unchecked Sendable { ) guard case .bound(let root, let parent, let strict) = scope else { if case .unbound(let reason) = scope { + // `.mutationScopeRefused` (fn-4.12): the withheld candidate + // is the repository's admin data, not a search root, and + // `.containerRefused`'s fixed GUI label ("not a configured + // search root") diagnosed the wrong thing — the refusal is + // the prune's mutation scope, which `reason` names. issues.append(ScanIssue( - url: parentRepoWorkingDir, kind: .containerRefused, + url: parentRepoWorkingDir, kind: .mutationScopeRefused, detail: "orphaned worktree admin data in this repository " + "cannot be offered for pruning because \(reason) — " + "the whole mutation scope must share one declared " @@ -1768,9 +1872,11 @@ struct GitWorktreeScanner: @unchecked Sendable { /// /// So on `.automatic` the resolver runs on a provider that reports every /// deferred path as ABSENT. The resolver is fail-closed by construction — - /// it `probeKind`s each pointer target BEFORE reading it — so the deferral - /// lands before any `open`, and the worktree simply attributes nowhere - /// (silent, exactly like every other policy skip). + /// it `probeKind`s each pointer target BEFORE reading it, and (fn-4.26) + /// before CANONICALIZING it, `realpath(3)` being a traversal of its own — + /// so the deferral lands before any `open` and before any dereference, + /// and the worktree simply attributes nowhere (silent, exactly like every + /// other policy skip). private func identityProvider(for context: ScanContext) -> FileSystemIdentityProvider { guard !context.includeProtectedRoots else { return provider } let home = self.home diff --git a/Sources/Cacheout/Scanner/OrphanedCachesScanner.swift b/Sources/Cacheout/Scanner/OrphanedCachesScanner.swift index c824b10b..141ec2c9 100644 --- a/Sources/Cacheout/Scanner/OrphanedCachesScanner.swift +++ b/Sources/Cacheout/Scanner/OrphanedCachesScanner.swift @@ -1048,8 +1048,8 @@ struct OrphanedCachesScanner: @unchecked Sendable { /// decision is taken on this spelling. /// /// NO-CROSS rule (safety — PR #458 review, matching the sizer's own - /// root check at `DirectorySizer.swift:261-272` and its within-walk check at - /// `DirectorySizer.swift:354-359`): mount boundaries are never crossed, at + /// root check at `DirectorySizer.swift:322-337` and its within-walk check at + /// `DirectorySizer.swift:448-453`): mount boundaries are never crossed, at /// the root or anywhere beneath it, and an uncrossed boundary makes the /// probe INCOMPLETE. Both signals are now read from DESCRIPTORS — /// `f_fsid` plus `st_dev` (see `crossesMountBoundary`) — which is @@ -1062,8 +1062,8 @@ struct OrphanedCachesScanner: @unchecked Sendable { /// What crossing costs is real and buys nothing: up to `entryLimit` /// reads on network/removable/FUSE storage the user never pointed this /// scanner at, on an item a boundary already makes uncleanable - /// (`CacheCleaner.deleteGuardedChild`:1151, - /// `CacheCleaner.removeGuardedItem`:1371). UNCROSSED ⇒ INCOMPLETE, never + /// (`deleteGuardedChild`, `CacheCleaner.swift:1210`; + /// `removeGuardedItem`, `CacheCleaner.swift:1543`). UNCROSSED ⇒ INCOMPLETE, never /// "clean" — /// and unlike a depth cap it is CLEARABLE: unmount, and the next walk /// reads the tree whole. @@ -1440,7 +1440,7 @@ struct OrphanedCachesScanner: @unchecked Sendable { } // MOUNT BOUNDARY AT THE ROOT, before anything below it is read — - // the same stance `DirectorySizer.swift:261-272` takes on its own root. + // the same stance `DirectorySizer.swift:322-337` takes on its own root. // An entry that IS a mount is not enumerated at all: not one entry // of the foreign filesystem is read, and the verdict is INCOMPLETE // precisely because we did not look. @@ -2728,9 +2728,15 @@ extension OrphanedCachesScanner: SpaceScanner { case .rootNotADirectory(let kind): // A symlinked/non-directory sweep root is NEVER traversed — // the scanner-level issue fn-2 defined for exactly this. + // TWO kinds by what actually stands there (fn-4.12, the PR #459 + // codex r13 split): `.symlinkRoot`'s single GUI label is the + // fixed sentence "symlinked — not searched", so a regular file + // or special file standing at the sweep root must carry + // `.nonDirectoryRoot` — the detail always named the real kind, + // but only in the hover tooltip. return ScanOutcome(items: [], errors: [ScanIssue( url: cachesRoot, - kind: .symlinkRoot, + kind: kind == .symlink ? .symlinkRoot : .nonDirectoryRoot, detail: "sweep root is not a real directory " + "(\(Self.describe(kind))) — never traversed" )]) @@ -2948,6 +2954,11 @@ extension OrphanedCachesScanner: SpaceScanner { case .tcc: return .tccDenied case .permission: return .permissionDenied case .metadata, .other, .unaddressablePath: return .unreadable + // The sizer's entry cap (fn-4.15) is the condition the GUI already + // words for this issue kind ("too many entries — partially + // inspected") — deterministic, so the denial's own detail carries + // the no-retry wording. + case .enumerationCapped: return .enumerationTruncated } } diff --git a/Sources/Cacheout/Scanner/ProjectTreeWalker.swift b/Sources/Cacheout/Scanner/ProjectTreeWalker.swift index 4fae1ff0..4dde8282 100644 --- a/Sources/Cacheout/Scanner/ProjectTreeWalker.swift +++ b/Sources/Cacheout/Scanner/ProjectTreeWalker.swift @@ -76,10 +76,14 @@ /// NodeModulesScanner anti-pattern R2 bans — it made monorepo /// `packages/build/...` invisible). /// -/// ## Failure classification (epic R12) +/// ## Failure classification (epic R12; fn-4.12 bare-errno alignment) /// Per-root enumeration/probe failures use the FROZEN `ScanIssue.Kind` -/// taxonomy: EPERM → `.tccDenied`, EACCES → `.permissionDenied` (the -/// `DirectorySizer.classifyDenial` precedent), anything else `.unreadable`. +/// taxonomy: EACCES → `.permissionDenied`; a BARE EPERM is NEUTRAL +/// `.unreadable` — this walk is raw-syscall throughout, and a raw errno +/// carries no provenance, so `.tccDenied` is never assertable from here +/// (`DirectorySizer.denial(forFailedProbe:errno:)` is the shared rule; the +/// chain-proven Cocoa arm in `classifyDenial` is what CAN assert TCC). +/// Anything else `.unreadable`. /// `.malformedOutcome` is NEVER authored here (reserved to the validator). /// TCC PROTECTION of a configured root is prefix-under-protected-ancestor on /// the CANONICAL root path (a user-added `~/Documents/GitHub` is protected @@ -116,6 +120,22 @@ struct ProjectTreeWalker { /// none are descended). static let defaultMaxDepth = 8 + /// The HARD ceiling `walk` clamps any caller-supplied `maxDepth` to + /// (fn-4.13). `visit` recurses once per level, and the cooperative + /// pool's small thread stack breaks that recursion long before any + /// filesystem limit does — MEASURED through the real `visit` on the + /// real executor (`Task.detached`, mkdirat-chain fixture): a walk at + /// depth 256 survives; depth 288 kills the process with signal 10, a + /// guard-page hit, no refusal anyone can act on. 128 leaves a 2x margin + /// under the measured floor of the crash band (257..288) while sitting + /// 16x above `defaultMaxDepth` — every production caller passes the + /// default, so this clamp changes no shipped behavior; it exists so a + /// test seam or a future caller cannot turn a parameter into a crash. + /// DETERMINISTIC by design and disclosed here rather than at runtime: + /// levels past the clamp are simply outside the walk's budget, exactly + /// as levels past `maxDepth` always were. + static let stackSafeMaxDepthCeiling = 128 + /// Home-relative first-level ancestors macOS gates behind a TCC consent /// prompt. Protection of an ARBITRARY configured root is decided by /// canonical-path PREFIX under one of these (see `isProtectedRoot`), @@ -152,17 +172,47 @@ struct ProjectTreeWalker { // MARK: - TCC-protected-root determination (R12) - /// Is `root` gated behind a macOS TCC consent prompt? True iff the - /// CANONICAL root path is equal to or under a canonical protected - /// ancestor (`home/Documents`, `home/Desktop`, `home/Downloads`) — - /// prefix by `pathComponents`, never string `hasPrefix`, never basename: - /// `~/Documents/GitHub` is protected because `Documents` is; a directory - /// merely NAMED `Documents` outside home is not; an alias spelling that - /// resolves INTO `~/Documents` through a symlinked ancestor is protected - /// as `~/Documents`. + /// Is `root` gated behind a macOS TCC consent prompt? True iff the root + /// is equal to or under a protected ancestor (`home/Documents`, + /// `home/Desktop`, `home/Downloads`) — prefix by `pathComponents`, never + /// string `hasPrefix`, never basename: `~/Documents/GitHub` is protected + /// because `Documents` is; a directory merely NAMED `Documents` outside + /// home is not; an alias spelling that resolves INTO `~/Documents` + /// through a symlinked ancestor is protected as `~/Documents`. + /// + /// TWO STAGES, and the ORDER is load-bearing (fn-4.26): this predicate + /// IS the TCC gates' classification, so it must answer for a + /// directly-protected spelling without dereferencing it — `realpath(3)` + /// traverses every component it resolves, and the previous + /// canonicalize-first body performed that traversal on exactly the paths + /// it was about to rule untouchable. + /// + /// 1. LEXICAL — the spelling as given, `.`/`..` folded with no + /// filesystem access, against the spelled ancestors. A match + /// classifies protected with NOTHING dereferenced. A spelling under a + /// protected ancestor that a symlink would resolve elsewhere now + /// classifies protected too — fail-closed, and every caller's + /// true-arm is a silent skip or deferral, never a mutation. + /// 2. CANONICAL — only for spellings stage 1 could not match: the alias + /// shapes (a symlinked ancestor, a case/NFD respelling) classify on + /// the canonical path exactly as before. The argument handed to + /// `canonicalize` here is never a directly-protected spelling; an + /// ALIAS argument's resolution does still traverse its target — the + /// disclosed residual, and not one the worktree resolver's pointer + /// chase can reach (its targets arrive spelled by git, and stage 1 + /// answers for those). static func isProtectedRoot( _ root: URL, home: URL, provider: FileSystemIdentityProvider ) -> Bool { + let spelled = root.standardizedFileURL.pathComponents + for name in tccProtectedAncestorNames { + let ancestor = home.appendingPathComponent(name) + .standardizedFileURL.pathComponents + if spelled.count >= ancestor.count, + Array(spelled.prefix(ancestor.count)) == ancestor { + return true + } + } let rootComponents = provider.canonicalize(root).pathComponents for name in tccProtectedAncestorNames { let ancestor = provider @@ -227,13 +277,95 @@ struct ProjectTreeWalker { didAnchorRoot: ((URL, SecureDirectory) -> Void)? = nil ) -> [ScanIssue] { var issues: [ScanIssue] = [] + // The stack-safe clamp (fn-4.13) — see `stackSafeMaxDepthCeiling`. + let maxDepth = min(maxDepth, Self.stackSafeMaxDepthCeiling) + // ONE kernel mount-table read per walk (fn-4.12) — the per-root + // mount gate below answers from this harvest, so every root of one + // walk is judged against the same table. + let mountTable = Set(provider.mountPointPaths()) for root in roots { if Task.isCancelled { break } + // THE TCC GATE MOVED BELOW THE TABLE (PR #461 merge gate). + // `isProtectedRoot`'s stage 2 canonicalizes, and `realpath(3)` on + // a hung hard mount blocks uninterruptibly in the kernel — so on + // an automatic scan (`includeProtectedRoots` false for every + // non-userInitiated trigger) the protected check was the FIRST + // contact with the root, and the walk hung there before the table + // could answer. The previous round moved the `probeKind` below + // the table and asserted the hang was unreachable; it was not, + // because `probeKind` was never the only way the root gets + // touched. The table is memory, so it answers before any code + // that can touch the filesystem at all. + // + // MOUNT FIRST, AND NOW ACTUALLY FIRST (PR #461 codex r1). The + // paragraph below has said MOUNT FIRST since fn-4.12 while the + // root probe ran above it, so an unresponsive mounted volume was + // `lstat`ed before the guard that exists to avoid touching it: + // the walk hung, the `.mountedVolumeRoot` issue was never + // emitted, and the session reached its watchdog leaving a blocked + // worker behind. The comment described the contract; the order + // did not honour it. The kernel table is memory — it costs no + // filesystem call — so asking it first is free. + // + // MOUNT FIRST (fn-4.12, the `EphemeralTempScanner` shape): a + // kernel-table mount standing at a configured root is a + // CONDITION of the machine, not a refusal of the root, and it + // is the one condition here whose remedy a re-scan can honor — + // this gate re-reads the table every walk, so "unmount, then + // re-scan" is true. Decided from the kernel's own spelling, + // before admission, so the guard's `.deniedVolumeRoot` clause + // never converts it into a generic policy sentence. A mount the + // table names under a DIFFERENT spelling of this root falls + // through to the guard and is refused there — as policy, which + // is also true. + // + // RESIDUAL, disclosed rather than smoothed over (merge gate r2): + // this order CHANGES what a root that is both TCC-protected and + // mounted reports on a background scan. It used to be skipped in + // silence by the gate below; it now says `.mountedVolumeRoot`. + // That is the honest answer — the volume, not privacy, is why its + // contents are not ours to walk — but it is a user-visible change, + // and its printed remedy is only half true for such a root: eject + // it and the NEXT scan reaches the TCC gate and skips it silently + // again, with no second issue to explain the disappearance. + if mountTable.contains(root.path) { + issues.append(ScanIssue( + url: root, kind: .mountedVolumeRoot, + detail: "configured dev root is a mounted volume — not " + + "scanned; its contents belong to that volume. " + + "Eject or unmount it, then re-scan" + )) + continue + } + // TCC policy gate (R9/R12): a background rescan must never be // the thing that fires a macOS privacy prompt. Prefix-under- // protected-ancestor on the CANONICAL root — never basename. + // + // Asked AFTER the table, which removes the one ordering in which + // this gate's own `canonicalize` was the first contact with a + // root that IS a mount point. It does NOT make the hang + // impossible, and the note that stood here saying so was wrong + // (merge gate r3, P7): `mountTable.contains(root.path)` is exact + // membership against mount POINTS, so a root INSIDE a mounted + // volume — `/dev` — never matches, and stage 2's + // `realpath(3)` below is then first contact with a path on that + // volume. Measured order for such a root, background arm: + // canonicalize -> realPath -> probeKind, issues []. + // + // WHERE THAT CASE IS ACTUALLY COVERED, and it is not here: by the + // session's wall-clock bound, the same answer `captureBounded` + // gives for a container root inside a hung mount (SpaceScanner's + // note at the capture says the table preflight never covered it + // either). Both walker callers are `SpaceScanner` conformers, so + // a blocked `realpath` is converted into `.scanDidNotFinish` with + // the ledger concluded `.boundFired`. The same residual applies + // verbatim: the bound converts the hang into a REPORT, it does + // not cure it — `realpath` takes no deadline, so its thread stays + // parked until the volume answers. CAN A RETRY DIFFER? Yes: the + // volume answers, or it is unmounted. if !includeProtectedRoots, Self.isProtectedRoot(root, home: home, provider: provider) { continue @@ -241,18 +373,37 @@ struct ProjectTreeWalker { // ABSENT root: honest no-item omission — machines differ, and // the seeds routinely include roots that do not exist. No issue, - // no events (epic registration-time story). + // no events (epic registration-time story). Asked AFTER the + // mount table, so a root that IS a mount point is never probed — + // NOT, as this note used to claim, because that makes the hang + // impossible; see the TCC gate above for the root-inside-a-volume + // case and where it is really bounded. let rootProbe = provider.probeKind(of: root) if rootProbe == .absent { continue } // Container admission BEFORE any traversal — the SCAN-TIME // read-only mode (fn-3.4 round 9): no snapshot, and this token // cannot delete. Refusal → classified issue, root never walked. + // TWO kinds by WHICH clause refused (fn-4.12): production + // callers walk the very roots their guard was built from, so a + // refusal there is a POLICY verdict on a configured root — + // `.policyRefusedRoot`, because `.containerRefused`'s fixed GUI + // label ("not a configured search root") was false for it. But + // `roots:` is a parameter: a root the guard does NOT know + // (`.notAConfiguredContainer`) keeps `.containerRefused`, whose + // label is exactly that condition — one kind per truth, decided + // by the typed error, never by message text. do { _ = try pathGuard.admitSearchRoot(root) } catch { + let kind: ScanIssue.Kind + if case PathGuardError.notAConfiguredContainer = error { + kind = .containerRefused + } else { + kind = .policyRefusedRoot + } issues.append(ScanIssue( - url: root, kind: .containerRefused, + url: root, kind: kind, detail: error.localizedDescription )) continue @@ -262,18 +413,33 @@ struct ProjectTreeWalker { // real target may sit anywhere — but symlinked ANCESTORS already // resolved through the lstat, so `/var`-style alias roots pass). // A root we cannot even lstat is a classified, visible failure. + // TWO kinds by what actually stands there (fn-4.12, the PR #459 + // codex r13 split): `.symlinkRoot`'s single GUI label is the + // fixed sentence "symlinked — not searched", so a regular file, + // FIFO, socket or device must carry `.nonDirectoryRoot` instead + // of sending the user hunting for a link that is not there. switch rootProbe { case .kind(.directory): break case .failed(let code): issues.append(Self.issue(forFailedProbe: root, errno: code)) continue - case .kind, .absent: + case .kind(.symlink): issues.append(ScanIssue( url: root, kind: .symlinkRoot, - detail: "dev root is not a real directory" + detail: "dev root is a symlink — never traversed" + )) + continue + case .kind(let kind): + issues.append(ScanIssue( + url: root, kind: .nonDirectoryRoot, + detail: "dev root is not a real directory " + + "(\(Self.describe(kind))) — never traversed" )) continue + case .absent: + // Unreachable belt: absence already continued above. + continue } // THE ROOT OPEN — the ONE path-based open of this walk, and the @@ -393,9 +559,9 @@ struct ProjectTreeWalker { // mid-walk deletion race, quiet by contract. continue case .failed(let code): - // Classified by errno (EPERM → TCC, EACCES → - // permission) — never a silent zero (R12). No kind was - // proven, so the child is not listed. + // Classified by errno (EACCES → permission; bare EPERM + // is NEUTRAL, fn-4.12) — never a silent zero (R12). No + // kind was proven, so the child is not listed. issues.append(Self.issue(forFailedProbe: child, errno: code)) case .kind(let kind, let identity, _): vetted[name] = identity @@ -626,35 +792,63 @@ struct ProjectTreeWalker { // `overlongDescendantPathBytes` now feeds the row's SIZE CAVEAT only // — the refusal it used to drive was retired with its premise. case .metadata, .other, .unaddressablePath: kind = .unreadable + // The sizer's entry cap (fn-4.15). This walker never runs the sizer's + // enumeration, so the arm is unreachable today; it maps to the + // taxonomy's truncation kind so a future sizer-fed path stays honest. + case .enumerationCapped: kind = .enumerationTruncated } return ScanIssue(url: denial.url, kind: kind, detail: denial.detail) } - /// Classify a failed lstat probe by errno (EPERM → TCC, EACCES → BSD - /// permissions) — same taxonomy as the sizer's denial classification. + /// Classify a failed lstat probe by errno — the shared raw-errno rule + /// (`DirectorySizer.denial(forFailedProbe:errno:)`): EACCES → BSD + /// permissions; a BARE EPERM is NEUTRAL (see the sizer's rule doc). private static func issue( forFailedProbe url: URL, errno code: Int32 ) -> ScanIssue { issue(from: DirectorySizer.denial(forFailedProbe: url, errno: code)) } - /// Classify a failed directory OPEN by errno, on the SAME frozen - /// taxonomy: EPERM → `.tccDenied`, EACCES → `.permissionDenied`, - /// everything else (notably ENOTDIR — a name that is no longer a - /// directory) → `.unreadable`. + /// Classify a failed directory OPEN by errno, on the SAME rule as the + /// probe classifier above: EACCES → `.permissionDenied`; a BARE EPERM + /// is NEUTRAL `.unreadable` (fn-4.12 — an `openat`/`open` errno carries + /// no provenance, so neither a privacy denial nor a filesystem refusal + /// may be asserted; the EPERM arm here claimed `.tccDenied` and, with + /// it, the GUI's "Grant access…" remedy link, on a guess); everything + /// else (notably ENOTDIR — a name that is no longer a directory) → + /// `.unreadable`. private static func issue( forFailedOpen url: URL, errno code: Int32 ) -> ScanIssue { let kind: ScanIssue.Kind + var caveat = "" switch code { - case EPERM: kind = .tccDenied + case EPERM: + kind = .unreadable + caveat = " — the cause could not be established (a privacy " + + "denial and a filesystem refusal are indistinguishable " + + "in a bare errno)" case EACCES: kind = .permissionDenied default: kind = .unreadable } return ScanIssue( url: url, kind: kind, detail: "directory open failed: " - + String(cString: strerror(code)) + + String(cString: strerror(code)) + caveat ) } + + /// The house spelling for a non-directory root's real kind — the same + /// wording `EphemeralTempScanner`/`OrphanedCachesScanner.describe` use, + /// so one condition reads identically across scanners. + private static func describe( + _ kind: FileSystemIdentityProvider.FileKind + ) -> String { + switch kind { + case .regularFile: return "regular file" + case .directory: return "directory" + case .symlink: return "symlink" + case .other: return "special file" + } + } } diff --git a/Sources/Cacheout/Scanner/SpaceScanner.swift b/Sources/Cacheout/Scanner/SpaceScanner.swift index 010854e4..b375304b 100644 --- a/Sources/Cacheout/Scanner/SpaceScanner.swift +++ b/Sources/Cacheout/Scanner/SpaceScanner.swift @@ -29,9 +29,9 @@ /// `git_worktree_reclaim` — `.commands` and `.gitWorktreeReclaim` /// serialize ONLY their kind; argv arrays and plan paths never reach any /// wire. -/// - `ScanIssue.Kind`: `container_refused` | `symlink_root` | `tcc_denied` | -/// `permission_denied` | `unreadable` | `config_invalid` | -/// `tool_unavailable` | `malformed_outcome`. +/// - `ScanIssue.Kind`: FROZEN wire strings, case-by-case, on the enum's own +/// `wireString` and in PROTOCOL.md's `kind` row — EXTENSIBLE, consumers +/// tolerate unknown kinds (the list that stood here rotted; fn-4.12). /// - Item ids: full 64-char lowercase-hex SHA-256 over the UTF-8 bytes of /// `scannerID + "\0" + canonicalPath` (`ReclaimableItem.stableID`). @@ -836,7 +836,17 @@ struct ScanIssue: Equatable, Sendable { /// consumers that assume the case list is closed. Generalizes the /// retired `NodeModulesScanIssue.Kind` scanner-agnostically. enum Kind: Equatable, Sendable { - /// `PathGuard.admitContainer` refused the search root. + /// A path that is genuinely NOT a configured search root — which is + /// the fixed sentence the GUI derives from this kind, so that is the + /// ONE condition allowed to carry it (fn-4.12 producer audit). Its + /// live producer is `GitWorktreeScanner`'s discovered-worktree arm + /// (a registered worktree sitting outside EVERY configured dev + /// root). The refusals that used to ride this kind while their own + /// `detail` said "configured … refused" moved to + /// `.policyRefusedRoot`/`.mountedVolumeRoot` (`DevRootsStore`, + /// `ProjectTreeWalker`) and `.mutationScopeRefused` + /// (`GitWorktreeScanner`'s containment arms). The WIRE string is + /// frozen; only the producer set narrowed. case containerRefused /// A REGISTERED search root that the kernel's mount table names as a /// mount point: another volume stands at that path, so what is there @@ -881,6 +891,20 @@ struct ScanIssue: Equatable, Sendable { /// remedy is claimed in the label because the causes do not share /// one. A FILESYSTEM kind: `url` names the refused root. case policyRefusedRoot + /// A DISCOVERED deletable candidate withheld because the destructive + /// git operation's whole MUTATION SCOPE — the paths git itself would + /// modify (the worktree, the admin directories) PLUS the parent + /// repository whose records name them — is not contained in ONE + /// configured dev root (`GitWorktreeScanner.mutationScope`, D13). + /// The candidate itself is often INSIDE a configured root — which is + /// exactly why `.containerRefused` ("not a configured search root") + /// was a false diagnosis for these producers and this is its own + /// kind (fn-4.12): the GUI's visible row label derives from the kind + /// alone. The label claims no remedy — where the out-of-scope data + /// sits is the user's layout — and `detail` names which path broke + /// the containment. A FILESYSTEM kind: `url` names the candidate + /// whose removal was withheld. + case mutationScopeRefused /// The search root is a SYMLINK — and only a symlink. Its target may /// sit anywhere, so the no-follow root gate never traverses it. /// @@ -927,7 +951,7 @@ struct ScanIssue: Equatable, Sendable { /// kind: a config parse failure has no honest filesystem path, so /// `url` is nil and a fake path is never invented. (Policy-REJECTED /// configured roots are NOT this kind — they carry their offending - /// path honestly under the frozen `.containerRefused`.) + /// path honestly under `.policyRefusedRoot`, fn-4.12.) case configInvalid /// An EXTERNAL TOOL a scanner depends on is unavailable (fn-5, D12 /// revised — e.g. `git` missing from the runner's fixed PATH, or its @@ -980,6 +1004,7 @@ struct ScanIssue: Equatable, Sendable { case .mountedVolumeRootAtRegistration: return "mounted_volume_root_at_registration" case .policyRefusedRoot: return "policy_refused_root" + case .mutationScopeRefused: return "mutation_scope_refused" case .symlinkRoot: return "symlink_root" case .nonDirectoryRoot: return "non_directory_root" case .tccDenied: return "tcc_denied" @@ -1375,14 +1400,43 @@ struct ScanSessionBounds: Sendable { /// wind down before `untilProducerFinishes()` stops waiting for it. See /// that method for what is given up when this one expires. let producerWindDownGrace: Duration + /// How long the PRE-SESSION container-identity capture + /// (`ContainerSnapshot.captureBounded`, fn-4.19) may take before the + /// session is concluded `.boundFired` with NOTHING scanned. Its own + /// bound and not `eventDeadline`'s, because the two cover different + /// work: the capture is a handful of `lstat`s (microseconds when every + /// volume answers — a hung network mount or unresponsive FUSE + /// filesystem under a session root is what spends it), while the event + /// deadline covers whole filesystem walks. See `scanValidatedSession` + /// for what a session whose capture timed out reports. + let captureDeadline: Duration + + /// `captureDeadline` defaults here so the FIXTURE constructions across + /// the suite — none of which wedge the capture unless they inject a + /// bound of their own — keep their two-argument spelling. Ten seconds is + /// the `default` philosophy applied to a microseconds-scale operation: + /// it never fails a capture for being SLOW, only for being ABANDONED. + init( + eventDeadline: Duration, + producerWindDownGrace: Duration, + captureDeadline: Duration = .seconds(10) + ) { + self.eventDeadline = eventDeadline + self.producerWindDownGrace = producerWindDownGrace + self.captureDeadline = captureDeadline + } /// What the SHIPPED composition runs under — `production(…)` is the /// only construction in the repo that names it. Ten minutes is far above /// any measured scan of a real machine; it exists to convert "never" /// into "reported", and a scan that legitimately needs longer than this - /// has a different problem. + /// has a different problem. The capture deadline is thirty seconds by + /// the same rule: four orders of magnitude above a healthy capture, + /// short enough that a scan stalled on a dead mount reports within the + /// time a user will actually wait for a spinner. static let production = ScanSessionBounds( - eventDeadline: .seconds(600), producerWindDownGrace: .seconds(30) + eventDeadline: .seconds(600), producerWindDownGrace: .seconds(30), + captureDeadline: .seconds(30) ) /// WHAT A COMPOSITION THAT DID NOT NAME A BOUND GETS, and why it is not @@ -1417,13 +1471,27 @@ struct ScanSessionBounds: Sendable { /// the pool's every worker is held by a scanner blocked in a syscall, which /// is the exact wedge the bound exists to convert into a report. /// -/// The queue is DEDICATED and SERIAL, and nothing blocking may ever be -/// scheduled on it. Every body it runs is non-suspending and microseconds -/// long — yield into an unbounded `AsyncStream`, `finish()`, `Task.cancel()`, +/// The queue is DEDICATED and SERIAL, and nothing blocking may be scheduled +/// on it. Every body it runs is non-suspending and microseconds long — yield +/// into an unbounded `AsyncStream`, `finish()`, `Task.cancel()`, /// `OneShotGate.open()` (which resumes continuations by ENQUEUEING them, it /// does not run them inline). That is what makes one serial queue safe for /// every concurrent session on the machine; put a blocking call in one of -/// these bodies and you would stall every other session's deadline behind it. +/// these bodies and you stall every other session's deadline behind it. +/// +/// ONE BODY BREAKS THIS, KNOWINGLY (PR #461 merge gate r3, P6 — recorded +/// HERE because this is the site a future round reads to answer "may I block +/// in this body?", and until r3 the answer here was an unqualified no while +/// the exception lived a thousand lines away). `CacheoutViewModel.dockerPrune` +/// schedules `LaunchClaim.abandon()` on this queue, and `abandon()` blocks on +/// the claim's lock, which `begin` holds across a `Process.run()` — a +/// fork/exec, milliseconds, more under load. It is deliberate: releasing that +/// lock earlier is precisely the window `LaunchClaim` exists to close. No +/// deadlock cycle exists, but for that interval every other bound scheduled +/// here — `DiskInfo`, `PathGuard`, the wind-down grace, the event-deadline +/// watchdog — is delayed, and that prune's own `.timedOut` settle overshoots +/// its budget by the same amount. A SECOND such body would not be acceptable +/// on this reasoning; this one is the exception, not a precedent. enum ScanSessionClock { static let queue = DispatchQueue( label: "app.cacheout.scan-session-bounds" @@ -1794,10 +1862,12 @@ struct SpaceScannerRuntime { let scanners: [any SpaceScanner] /// UNION of every scanner's declared `trustedContainerRoots`, in /// registration order, deduplicated by path — and with SHADOWING - /// ALIASES suppressed (`suppressingAliasShadows`): at most one spelling - /// per canonical location survives whenever any of them is a real - /// directory, so first-match root matching can never return an unusable - /// spelling of a location another scanner registered usably. + /// ALIASES suppressed (`suppressingAliasShadows`): an unusable spelling + /// whose own link content names a surviving real-directory spelling is + /// dropped, so first-match root matching can never return such an + /// unusable spelling of a location another scanner registered usably + /// (comparison is by NAME since fn-4.11 — the residual is recorded on + /// that function). let trustedContainerRoots: [URL] /// PER-SCANNER declared container roots, captured at registration @@ -1809,11 +1879,14 @@ struct SpaceScannerRuntime { /// union. private let declaredContainerRoots: [String: [URL]] - /// DECLARED path -> canonical path, for every root any scanner declared + /// DECLARED path -> comparison key, for every root any scanner declared /// (union survivors and alias-suppressed drops alike), captured at - /// registration from `suppressingAliasShadows`' single probe pass. - /// Read ONLY by `sessionContainerRoots`, which uses it to decide the - /// snapshot's capture set without a session-time realpath. + /// registration from `suppressingAliasShadows`' single probe pass: the + /// canonical path for a proven real directory, the folded link content + /// or parent-canonical leaf-kept spelling otherwise (fn-4.11 — nothing + /// here follows a symlink leaf). Read ONLY by `sessionContainerRoots`, + /// which uses it to decide the snapshot's capture set without a + /// session-time realpath. private let containerRootCanonicalKeys: [String: String] /// The AUTHORITATIVE category registry, keyed by slug — registered at @@ -1966,17 +2039,46 @@ struct SpaceScannerRuntime { /// /// - a dropped spelling is never a real directory, so `admitContainer`'s /// gate (2) refused it, and the walker refuses it as a root; - /// - it is dropped ONLY when a real-directory spelling of the SAME - /// canonical location survives — and root matching is by canonical - /// identity, so every claim the alias could have matched still matches - /// the covering root, which additionally passes the gate; + /// - it is dropped ONLY when its own link content NAMES a surviving + /// real-directory spelling (by that root's canonical key or declared + /// path) — and delete-time root matching is by canonical identity, so + /// every claim the alias could have matched still matches the covering + /// root, which additionally passes the gate; /// - a claim spelled AS the alias stays refused: gate (2) checks the /// caller's own spelling too. /// + /// PROBED AS SPELLED FIRST (fn-4.11 — the fn-4.26 order at the union's + /// scope). This probe runs at CONSTRUCTION, on the main thread, and the + /// old shape canonicalized EVERY root — leaf included — so a symlink + /// root here (a dev root a same-UID process re-aimed, or fn-6's + /// symlinked temp root when its own resolution could not place it) made + /// `realpath(3)` name the DESTINATION, and an unresponsive volume there + /// froze launch. Now only a spelling PROVEN a real directory by the + /// no-follow lstat is canonicalized (the resolved leaf then IS the + /// object the lstat touched); a symlink leaf contributes what its own + /// CONTENT names (`FileSystemIdentityProvider.lexicalAliasTarget` — one + /// `readlink(2)` + string folding, the fn-6 technique), compared by + /// NAME against the real-directory spellings this union holds. A root + /// the kernel mount table names is not probed at all (the r15 shape — + /// `lstat` OF a mount point is served by the mounted filesystem): kept + /// verbatim, and still fail-closed, because delete-time root matching + /// skips over-mounted configured roots from the same table. + /// + /// RESIDUAL at measured scope: the name compare misses a target written + /// through a THIRD spelling (a second link hop, a case variant, an + /// unresolved `/var`-style alias of the covering root's declared + /// spelling) — such an alias is KEPT, where the old full resolution + /// dropped it, and it can then sit ahead of the root it shadows for + /// first-match root matching (the fn-4.5 breakage, at that narrowed + /// scope). The same trade fn-6.1 records for its own resolution + /// (`testAliasWrittenThroughAThirdSpellingKeepsBothRootsRatherThanGuessing`): + /// closing it requires resolving the destination, which is the exact + /// contact this function must not make. + /// /// The `resolveTargetKeepingLeaf` doctrine is preserved exactly as - /// `DevRootsStore` preserves it: the leaf-resolving canonical path is a - /// comparison KEY only and never reaches the returned union — every - /// surviving entry is the verbatim spelling its scanner declared. + /// `DevRootsStore` preserves it: every comparison value is a KEY only + /// and never reaches the returned union — every surviving entry is the + /// verbatim spelling its scanner declared. /// /// Nothing is silently lost: a dropped root is unusable in its own right, /// and the scanner that declared it still declares it @@ -1987,43 +2089,79 @@ struct SpaceScannerRuntime { private static func suppressingAliasShadows( in roots: [URL], provider: FileSystemIdentityProvider ) -> (roots: [URL], canonicalKeys: [String: String]) { - // Probed ONCE per root: the canonical comparison KEY, and whether the - // DECLARED spelling is itself a real directory (leaf lstat no-follow) - // — the same probe pair, with the same meaning, as fn-4.1's dev-root - // resolution. - let probed = roots.map { root in - (declared: root, - key: provider.canonicalize(root).path, - isDirectory: provider.probeKind(of: root) == .kind(.directory)) + let mounted = Set(provider.mountPointPaths()) + // Probed ONCE per root, as spelled first (see the doc above): the + // canonical KEY for proven real directories, the folded link + // content for symlink leaves, nothing for a root the mount table + // names. `mapKey` is what `canonicalKeys` carries when neither a + // canonical key nor a covering root applies: the folded content + // when the link has one, else the parent-canonical leaf-kept + // spelling (`resolveTargetKeepingLeaf` — ancestors only), so two + // scanners' spellings of one location still land on one key + // without any leaf-following resolution. + let probed = roots.map { + root -> (declared: URL, key: String?, aliasTarget: String?, + mapKey: String) in + if mounted.contains(root.path) { + return (root, nil, nil, root.path) + } + switch provider.probeKind(of: root) { + case .kind(.directory): + let key = provider.canonicalize(root).path + return (root, key, nil, key) + case .kind(.symlink): + let target = provider.lexicalAliasTarget(of: root) + return (root, nil, target, + target ?? provider.resolveTargetKeepingLeaf(root).path) + case .kind(.regularFile), .kind(.other), .absent, .failed: + return (root, nil, nil, + provider.resolveTargetKeepingLeaf(root).path) + } + } + // The spellings a REAL-DIRECTORY root already covers — its + // canonical key and its declared path. An alias's folded link + // content is compared against THESE STRINGS, never resolved. + var coveredByRealDirectory = Set() + for root in probed { + guard let key = root.key else { continue } + coveredByRealDirectory.insert(key) + coveredByRealDirectory.insert(root.declared.path) } - let coveredByRealDirectory = Set( - probed.lazy.filter(\.isDirectory).map(\.key) - ) // Two real-directory spellings of one location are NOT touched: both // pass the reality gate, so neither shadows the other, and dropping // either would change which declared spelling the identity binding // keys off for no safety gain. - return ( - roots: probed - .filter { $0.isDirectory || !coveredByRealDirectory.contains($0.key) } - .map(\.declared), - // THE SAME PROBE'S KEYS, carried out rather than recomputed (PR - // #459 codex r16). `sessionContainerRoots` needs to know which - // union entries a participating scanner's declared root can - // MATCH, and matching is by canonical identity - // (`PathGuard.matchConfiguredRoot`, PathGuard.swift:462-469), not - // by spelling. Re-canonicalizing at session time would pay this - // construction's realpath bill again — per session, per trigger, - // on exactly the roots the participation gate exists to leave - // alone. Keyed by DECLARED path and kept for every declared root - // including the ones dropped above: a participating scanner whose - // own spelling was suppressed still reaches the covering entry - // through this map. - canonicalKeys: Dictionary( - probed.map { ($0.declared.path, $0.key) }, - uniquingKeysWith: { first, _ in first } - ) - ) + var union: [URL] = [] + // THE SAME PROBE'S KEYS, carried out rather than recomputed (PR + // #459 codex r16). `sessionContainerRoots` needs to know which + // union entries a participating scanner's declared root can MATCH, + // and matching is by canonical identity + // (`PathGuard.matchConfiguredRoot`, PathGuard.swift:536-543), not + // by spelling. Re-deriving them at session time would pay this + // construction's probe bill again — per session, per trigger, on + // exactly the roots the participation gate exists to leave alone. + // Keyed by DECLARED path and kept for every declared root including + // the ones dropped below. A DROPPED spelling's entry is the NAME its + // content folded to, not the covering root's canonical key — fn-4.11 + // retired that link on measured evidence (deleting it left every + // cell green): a scanner can produce no admissible claim through a + // spelling the reality gates refuse (the walker's root gate never + // walks it, scan-time origin binding is to the scanner's own + // declaration, and `admitContainer`'s gate (2) refuses the alias + // spelling itself), so pulling the covering entry into the capture + // set for such a participant was exactly the over-capture the r16 + // narrowing exists to avoid. + var canonicalKeys: [String: String] = [:] + for root in probed { + canonicalKeys[root.declared.path] = root.key ?? root.mapKey + if root.key == nil, + let target = root.aliasTarget, + coveredByRealDirectory.contains(target) { + continue // the shadowing alias — dropped from the union + } + union.append(root.declared) + } + return (roots: union, canonicalKeys: canonicalKeys) } /// The production registry — the single place scanners are registered. @@ -3018,7 +3156,7 @@ struct SpaceScannerRuntime { /// /// WHY IT CANNOT STRAND A LATER CLEAN. Omission from the snapshot is /// fail-closed: `PathGuard.admitContainer` refuses a root it cannot find - /// there (PathGuard.swift:400-403). Both consumers already refuse the + /// there (PathGuard.swift:474-477). Both consumers already refuse the /// same items for an independent reason: /// /// - the ViewModel gates every destructive path on the scanner's @@ -3026,10 +3164,10 @@ struct SpaceScannerRuntime { /// (`isBlockedFromDestructivePaths`, CacheoutViewModel.swift:610-614). /// A non-participating scanner delivers no event, so its retained rows /// keep the older generation while adoption moves on - /// (CacheoutViewModel.swift:1487-1488) — they are already + /// (CacheoutViewModel.swift:1488-1489) — they are already /// visible-but-non-cleanable before this filter sees them; /// - the CLI resolves the items it cleans FROM the same collected - /// session (CLIHandler.swift:2123 and :2442 pass that session's + /// session (CLIHandler.swift:2125 and :2444 pass that session's /// snapshot), so it can only ever hold items a participating scanner /// produced. /// @@ -3040,15 +3178,19 @@ struct SpaceScannerRuntime { /// /// WHY CANONICAL KEYS AND NOT JUST PATHS. Delete-time root matching is by /// canonical identity over the whole union, returning the FIRST match - /// (PathGuard.swift:462-469), and the snapshot is keyed by THAT root's + /// (PathGuard.swift:536-543), and the snapshot is keyed by THAT root's /// declared spelling. So a participating scanner's claim can legitimately /// key off a union entry only a NON-participating scanner declared — an - /// alias spelling of the same location, including the case where the - /// participating scanner's own spelling was dropped by - /// `suppressingAliasShadows`. Filtering by declared path alone would have - /// turned those admissions into `containerUnavailable`; the registration - /// -captured `containerRootCanonicalKeys` pull the covering entry in - /// without a session-time realpath. + /// alias spelling of the same location, both spellings real directories + /// (the ancestor-symlink case + /// `testAParticipatingScannersAliasedRootIsStillCaptured` stages). + /// Filtering by declared path alone would have turned those admissions + /// into `containerUnavailable`; the registration-captured + /// `containerRootCanonicalKeys` pull the covering entry in without a + /// session-time realpath. (This paragraph once also claimed the link + /// held for a spelling `suppressingAliasShadows` DROPPED — retired in + /// fn-4.11: a dropped spelling supports no admissible claim, so its map + /// entry deliberately links nothing; see that function.) private func sessionContainerRoots( for selected: [any SpaceScanner] ) -> [URL] { @@ -3087,8 +3229,10 @@ struct SpaceScannerRuntime { func scanValidated( scannerIDs: Set? = nil, context: ScanContext - ) -> AsyncStream { - scanValidatedSession(scannerIDs: scannerIDs, context: context).events + ) async -> AsyncStream { + await scanValidatedSession( + scannerIDs: scannerIDs, context: context + ).events } /// `scanValidated` plus the producer's REAL completion (additive over @@ -3099,10 +3243,18 @@ struct SpaceScannerRuntime { /// keeps its "scanning" guard honest until the walk has actually /// stopped, instead of releasing it while an orphaned traversal is /// still reading the same trees. + /// ASYNC SINCE fn-4.19, and the await is the fix: the container-identity + /// capture used to run synchronously inside this call, which + /// `CacheoutViewModel.scan` reaches on the MainActor — so every session + /// root's `lstat` ran ON THE MAIN THREAD, before any bound existed. The + /// method is nonisolated, so an async caller hops off its actor to run + /// it, and the capture itself is bounded (`captureDeadline`) with its + /// expiry reported through the session's own `.scanDidNotFinish` + /// vocabulary — see below. func scanValidatedSession( scannerIDs: Set? = nil, context: ScanContext - ) -> ValidatedScanSession { + ) async -> ValidatedScanSession { // TWO independent filters, and the second is the PROTOCOL's rather // than the caller's (PR #459 review r2). `scannerIDs` is what the // caller asked for; `participates(in:)` is what the scanner will @@ -3131,50 +3283,57 @@ struct SpaceScannerRuntime { // lstat (PR #459 review r6 codex C2 — that lstat is first contact // with the mounted filesystem). // - // RECORDED, NOT FIXED, AND NOT COVERED BY THE BOUND BELOW (PR #460 - // codex r13). This capture is SYNCHRONOUS — `ContainerSnapshot - // .capture` reads the mount table and then runs `provider - // .identity(of:)`, an `lstat`, for every remaining session container - // root — and `CacheoutViewModel.scan` calls this method without an - // `await`, on the MainActor. So it runs ON THE MAIN THREAD, and it - // runs BEFORE the stream, the producer, the watchdog and the grace - // timer exist: a hung network mount or unresponsive FUSE volume - // freezes the app here, unbounded and unreported, with no - // `.scanDidNotFinish` possible because nothing is armed yet. r12's - // verifier measured a 6.03 s `scan` with `isMainThread == true` from - // a 6 s blocking `identity(of:)`; verified here as a code path - // rather than re-measured — the loop is straight-line synchronous - // and this call site has no suspension point before it. The - // `mountPointPaths()` preflight does NOT close it: it skips roots - // that ARE mount points, and a root INSIDE a hung mount is still - // lstat'ed. Pre-existing on origin/main; its own task. + // AND IT IS BOUNDED, OFF THE CALLING THREAD (fn-4.19; through fn-4 + // round 1 this was RECORDED, NOT FIXED). The capture is synchronous + // filesystem work — the mount-table read plus one `lstat` per + // remaining session container root — and it ran inline in this + // then-synchronous method, which `CacheoutViewModel.scan` reaches + // from the MainActor: a hung network mount or unresponsive FUSE + // volume under ANY session root froze the app here, unbounded and + // unreported, with no `.scanDidNotFinish` possible because nothing + // was armed yet (r12's verifier measured a 6.03 s `scan` with + // `isMainThread == true` from a 6 s blocking `identity(of:)`; the + // `mountPointPaths()` preflight never covered a root INSIDE a hung + // mount). `captureBounded` runs the loop in a detached task racing a + // `ScanSessionClock` timer — the `BoundedDiskInfo` shape, and the + // same off-the-pool clock every session bound uses. // - // IT IS NOT THE ONLY PRE-SESSION WAIT, and reading it as "the one - // place the bound cannot reach" is what let a second one sit - // undisclosed for two rounds (PR #460 codex r14, V2-1). - // `CacheoutViewModel.scan`'s header refresh also runs after the - // in-progress guard and before this method is called; it is now - // bounded on its own clock (`BoundedDiskInfo`), which is the shape a - // fix for THIS site would take too — a wall-clock budget of its own, - // since the session's cannot be armed yet. What stops that here and - // not there is the disposition: an abandoned header fetch costs a - // stale figure, while an abandoned container snapshot would leave the - // session with no identity baseline to admit deletes against, so - // giving up on it needs a product decision about what the scan then - // reports. - let snapshot = ContainerSnapshot.capture( - roots: sessionContainerRoots(for: selected), provider: provider + // WHAT AN EXPIRED CAPTURE REPORTS — the product decision the r13 + // record said this fix would owe. An abandoned capture leaves the + // session with NO identity baseline to admit deletes against, so + // proceeding to scan would publish items that delete-time admission + // must refuse wholesale — a scan-shaped success whose cleaning + // silently fails is the erasure class this project refuses. The + // session therefore runs NO scanner and reports every selected one + // through the vocabulary consumers already fail closed on: one + // `.scanDidNotFinish` per scanner, the ledger concluded + // `.boundFired` (so `didExceedBounds` is true and the GUI declines + // adoption; the CLI's target-scoped refusal reads the same rows), + // and a detail that names the capture and the retryable causes. CAN + // A RETRY DIFFER? Yes — the causes are a stalled volume or a + // starved band, both transient — so the re-scan remedy the label + // names is real, and this is a bound, not a deterministic strand. + // THE SESSION'S WALL-CLOCK BOUNDS (PR #460 codex r12, D2) — see + // `ScanSessionBounds` for the mechanism and the decision. Everything + // the watchdog needs is captured HERE, before any task starts — and + // since fn-4.19 the capture below spends the first of them. + let bounds = sessionBounds + let selectedIDs = selected.map(\.id) + let capture = await ContainerSnapshot.captureBounded( + roots: sessionContainerRoots(for: selected), provider: provider, + within: bounds.captureDeadline ) + guard case .captured(let snapshot) = capture else { + return Self.captureTimedOutSession( + selectedIDs: selectedIDs, captureDeadline: bounds.captureDeadline, + windDownGrace: bounds.producerWindDownGrace + ) + } let registeredCategories = self.registeredCategories let declaredContainerRoots = self.declaredContainerRoots let preDeleteRevalidators = self.preDeleteRevalidators let (events, continuation) = AsyncStream.makeStream() - // THE SESSION'S WALL-CLOCK BOUNDS (PR #460 codex r12, D2) — see - // `ScanSessionBounds` for the mechanism and the decision. Everything - // the watchdog needs is captured HERE, before any task starts. - let bounds = sessionBounds - let selectedIDs = selected.map(\.id) let ledger = ScanSessionLedger() let woundDown = OneShotGate() // THE PRODUCER RUNS IN A LOWER PRIORITY BAND THAN ITS CONSUMER, and @@ -3303,6 +3462,55 @@ struct SpaceScannerRuntime { ) } + /// The session a TIMED-OUT container-identity capture produces + /// (fn-4.19): nothing scanned, everything said so. One + /// `.scanDidNotFinish` per selected scanner — the kind's contract holds + /// exactly (nothing was rejected: nothing ARRIVED, and a retry can + /// genuinely differ) — with a detail naming the capture rather than the + /// walk, the ledger concluded `.boundFired` so `didExceedBounds` reads + /// true, and the EMPTY snapshot, which admits no container (the same + /// fail-closed refusal an omitted root has always produced). + /// + /// The stream is fully buffered before it is returned (`makeStream`'s + /// unbounded default), the producer is a completed no-op, and the + /// wind-down gate opens immediately — `untilProducerFinishes()` returns + /// on the spot, because there is no walk to wind down. + private static func captureTimedOutSession( + selectedIDs: [String], captureDeadline: Duration, + windDownGrace: Duration + ) -> ValidatedScanSession { + let (events, continuation) = + AsyncStream.makeStream() + let ledger = ScanSessionLedger() + let woundDown = OneShotGate() + // The same atomic step the watchdog takes, for the same reason: the + // conclusion and the reported set are decided together. This is the + // first touch of a fresh ledger, so the missing set is every + // selected scanner. + let missing = ledger.conclude(.boundFired, selected: selectedIDs) ?? [] + for id in missing { + continuation.yield(.malformed( + scannerID: id, + ScanIssue( + url: nil, kind: .scanDidNotFinish, + detail: "the container-identity capture did not finish " + + "within \(captureDeadline); no scanner ran and " + + "nothing from this session was used — a volume " + + "that stopped answering (a hung network mount or " + + "unresponsive FUSE filesystem under a scanned " + + "location) can cause this, and a re-scan after it " + + "answers or is unmounted can succeed" + ) + )) + } + continuation.finish() + woundDown.open() + return ValidatedScanSession( + snapshot: .empty, events: events, producer: Task {}, + ledger: ledger, woundDown: woundDown, windDownGrace: windDownGrace + ) + } + // MARK: Slug & item-id syntax /// `[a-z0-9_]+` — the address grammar's slug alphabet (no colon, so the diff --git a/Sources/Cacheout/Scanner/ValuablesDetector.swift b/Sources/Cacheout/Scanner/ValuablesDetector.swift index b9b56ef1..a5aeff97 100644 --- a/Sources/Cacheout/Scanner/ValuablesDetector.swift +++ b/Sources/Cacheout/Scanner/ValuablesDetector.swift @@ -140,7 +140,7 @@ /// this machine, `st_dev` is identical for literally every path INCLUDING /// `/` and `/System/Volumes/Data`, so the device comparison is blind to /// exactly the APFS firmlink split it was partly meant to catch. The two -/// path-based signals the sizer (`DirectorySizer.swift:261-272,354-359`) and the +/// path-based signals the sizer (`DirectorySizer.swift:322-337,448-453`) and the /// project walker already use are retained beside it — they are the seam /// hermetic tests inject through, and they can only ever push the answer /// toward refusal. There is still exactly ONE notion of "mount boundary" in @@ -162,8 +162,8 @@ /// which means network round trips, spin-up, and privacy-sensitive access to /// a filesystem the user never pointed this scanner at. And it buys nothing: /// an artifact dir containing a boundary is `.denied` at scan time and -/// refused whole by the cleaner (`CacheCleaner.deleteGuardedChild`:1151 and -/// `CacheCleaner.removeGuardedItem`:1371), so no +/// refused whole by the cleaner (`deleteGuardedChild`, `CacheCleaner.swift:1210`, +/// and `removeGuardedItem`, `CacheCleaner.swift:1543`), so no /// valuable found past the mount could ever change an outcome. /// /// UNCROSSED ⇒ INCOMPLETE, never "clean": the honest report is "we did not @@ -939,7 +939,7 @@ enum ValuablesDetector { descriptorWindow: Int? = nil ) -> ValuablesDisclosure { // MOUNT BOUNDARY AT THE ROOT. The sizer applies these signals to its - // OWN root (`DirectorySizer.swift:261-272`) and declines to enumerate; the + // OWN root (`DirectorySizer.swift:322-337`) and declines to enumerate; the // probe must decline identically, or the delete-time face — which has // no size report to consult — would read a whole mounted volume. // Nothing beneath is opened: not one entry of a foreign filesystem is diff --git a/Sources/Cacheout/ViewModels/CacheoutViewModel.swift b/Sources/Cacheout/ViewModels/CacheoutViewModel.swift index c61c1c74..17b81890 100644 --- a/Sources/Cacheout/ViewModels/CacheoutViewModel.swift +++ b/Sources/Cacheout/ViewModels/CacheoutViewModel.swift @@ -1329,15 +1329,16 @@ class CacheoutViewModel: ObservableObject { } /// Pure row derivation (XCTest asserts on this directly): one row per - /// DECLARED path, carrying the refusal detail of the `.containerRefused` - /// issue that names it — a policy-rejected persisted root is visible in - /// the editor exactly as it is visible in the scan results (R16). + /// DECLARED path, carrying the refusal detail of the `.policyRefusedRoot` + /// issue that names it (fn-4.12; `.containerRefused` before that) — a + /// policy-rejected persisted root is visible in the editor exactly as it + /// is visible in the scan results (R16). nonisolated static func devRootRows( declaredPaths: [String], issues: [ScanIssue], home: URL ) -> [DevRootRowModel] { let refusalsByPath = Dictionary( issues - .filter { $0.kind == .containerRefused } + .filter { $0.kind == .policyRefusedRoot } .compactMap { issue -> (String, String)? in guard let url = issue.url else { return nil } return (url.standardizedFileURL.path, issue.detail) @@ -1527,7 +1528,7 @@ class CacheoutViewModel: ObservableObject { diskInfo = fetched } - let session = sessionRuntime.scanValidatedSession( + let session = await sessionRuntime.scanValidatedSession( scannerIDs: participating, context: context ) @@ -1818,69 +1819,200 @@ class CacheoutViewModel: ObservableObject { @Published var isDockerPruning = false @Published var lastDockerPruneResult: String? + /// THE STATED BOUND on one prune attempt (fn-4.20). Ten minutes is the + /// `ScanSessionBounds.production` philosophy applied to a subprocess: a + /// `docker system prune` over a large image store legitimately runs for + /// minutes, so the bound exists to convert "never" into "reported", + /// not to hurry a big prune — and a prune that legitimately needs + /// longer than this has a different problem. + static let dockerPruneDefaultBudget: Duration = .seconds(600) + + /// TEST SEAMS — production reads the defaults. The budget so a cell can + /// prove the expiry path in milliseconds; the command so a cell can + /// substitute a wedged or scripted child for the real docker CLI. + var dockerPruneBudget: Duration = CacheoutViewModel.dockerPruneDefaultBudget + var dockerPruneCommand: [String] = ["docker", "system", "prune", "-f"] + + /// What one prune attempt produced. `.finished` carries the child's own + /// exit status INCLUDING failures — a completed failure and a timeout + /// are kept apart (the `BoundedDiskInfo.Outcome` discipline) so a cell + /// cannot pass one while asserting the other. + private enum DockerPruneOutcome: Sendable { + case finished(status: Int32, output: String) + case launchFailed + case timedOut + } + func dockerPrune() async { isDockerPruning = true + // Released on EVERY path out of this method — and since fn-4.20 + // every path RETURNS: the child interaction below is raced against + // its budget, so the defer can no longer be postponed forever by a + // docker CLI that never exits. defer { isDockerPruning = false } - let process = Process() + // A `ClaimedProcess`, not a `Process`: it owns the child and never + // hands it out, so nothing below this line can start the prune except + // through the claim (PR #461 merge gate r3, P1 — the gate restored + // the two-statement launch here and all 1667 cells stayed green, + // because the claim's window was closed in the TYPE and unlatched at + // this, its only call site). There is no `process` in scope now, so + // that shape does not compile. let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["docker", "system", "prune", "-f"] - process.standardOutput = pipe - process.standardError = pipe // Real home is correct here: the view model has no injected-home // seam — docker prune is a production-only action on the real // account (unlike CacheCleaner/CacheCategory subprocesses, which // pin HOME to their injected home). - process.environment = [ - "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin", - "HOME": FileManager.default.homeDirectoryForCurrentUser.path - ] + let child = ClaimedProcess( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + arguments: dockerPruneCommand, + environment: [ + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin", + "HOME": FileManager.default.homeDirectoryForCurrentUser.path + ], + standardOutput: pipe, + standardError: pipe + ) - do { - // RECORDED, NOT FIXED (PR #460 codex r13). This wait is - // UNBOUNDED, and it is the one primitive this repo replaced - // everywhere else: `Process.waitUntilExit()` can miss its - // termination wakeup under concurrent reaping (see - // `Process.waitForExit(within:)` in CacheCategory.swift, which - // exists for that and is used by CacheCleaner and the category - // subprocesses). Here it sits behind a `readToEnd()` on a - // cooperative worker, so a docker CLI that never exits holds the - // worker AND latches `isDockerPruning` true for the life of the - // app — the button never re-enables. Pre-existing on - // origin/main, unrelated to this PR's scanners, and left to its - // own change: the fix is `waitForExit(within:)` plus a - // termination policy, which is a product decision about how long - // a prune may take. - let result = try await Task.detached { () -> (Int32, String) in - try process.run() - let data = try pipe.fileHandleForReading.readToEnd() ?? Data() - process.waitUntilExit() - let output = String(data: data, encoding: .utf8) ?? "" - return (process.terminationStatus, output) - }.value - - if result.0 == 0 { - // Extract "Total reclaimed space:" line - if let line = result.1.components(separatedBy: "\n") - .first(where: { $0.contains("reclaimed") }) { - lastDockerPruneResult = line.trimmingCharacters(in: .whitespaces) - } else { - lastDockerPruneResult = "Docker pruned successfully" - } + // BOUNDED, AND BOUNDED AS A WHOLE (fn-4.20; through fn-4 round 1 + // this was RECORDED, NOT FIXED at PR #460 codex r13). The old body + // did `readToEnd()` then a bare `process.waitUntilExit()` in a + // detached task and awaited its value: unbounded, on a cooperative + // worker, and the one surviving production call site of the + // primitive this repo retired everywhere else after measuring it + // miss its termination wakeup under concurrent reaping + // (`Process.waitForExit(within:)`, CacheCategory.swift). A docker + // CLI that never exited held the worker AND latched + // `isDockerPruning` true for the life of the app. + // + // AND THE WAIT WAS NOT THE ONLY PARK — the task-spec question + // "can `readToEnd()` park too?" answers YES: it blocks until EOF, + // and EOF needs every write end of the pipe closed, so a wedged + // child that keeps its descriptors open parks the read BEFORE any + // wait is reached. Bounding only the wait would have moved the + // strand one line up. That is why the budget races the WHOLE child + // interaction (spawn → drain → bounded wait) through + // `FirstWinsRendezvous` on `ScanSessionClock` — off the cooperative + // pool, where a `Task.sleep` deadline cannot be starved — while the + // interaction itself still ends in `waitForExit(within:)`, never + // the retired primitive. + let budget = dockerPruneBudget + let waitSeconds = Double(budget.components.seconds) + + Double(budget.components.attoseconds) / 1e18 + let rendezvous = FirstWinsRendezvous() + // THE LAUNCH IS CLAIMED, NOT ASSUMED (PR #461 codex r1, P1). The + // timer settling `.timedOut` does not stop a detached task that has + // not been scheduled yet, and the starvation this off-pool timer + // exists to survive is precisely what keeps it queued: the timeout + // branch would see `isRunning == false`, report truthfully that + // nothing is running, re-enable the button — and the task would then + // launch an unowned destructive prune, free to overlap the retry the + // user was just invited to make. Both sides decide through one lock. + // + // RESIDUAL, disclosed (merge gate r2): `begin` performs the launch + // UNDER the claim's lock — that is the whole mechanism — so the timer + // body below blocks on that lock for the duration of the spawn, and + // it runs on `ScanSessionClock`'s single shared serial queue. No + // deadlock cycle exists (nothing the child's launch waits on is + // dispatched to that queue), but for a fork/exec's worth of time — + // milliseconds, more under load — every other bound scheduled there + // is delayed, and a `.timedOut` settle overshoots its budget by the + // same amount. Not fixable by releasing the lock earlier: releasing + // it is exactly the window this type exists to close. + let timer = ScanSessionClock.schedule(after: budget) { + child.abandon() + rendezvous.settle(.timedOut) + } + Task.detached { + // ONE ACT, and the only act available: `start()` decides and + // launches under one lock, and the child is not reachable any + // other way (PR #461 merge gate r3, P1). + do { + guard try child.start() else { return } + } catch { + rendezvous.settle(.launchFailed) + return + } + let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data() + // EOF does not prove exit (a child can close its descriptors + // and live on), so the exit is still awaited — bounded, and by + // the SAME figure: the outer timer started first, so on a + // wedged child it is the timer that settles, and this poll can + // never outlive the budget by more than its own scheduling. + guard child.waitForExit(within: waitSeconds) else { + rendezvous.settle(.timedOut) + return + } + rendezvous.settle(.finished( + status: child.terminationStatus, + output: String(data: data, encoding: .utf8) ?? "" + )) + } + let outcome = await rendezvous.wait() + timer.cancel() + + switch outcome { + case .finished(let status, let output) where status == 0: + // Extract "Total reclaimed space:" line + if let line = output.components(separatedBy: "\n") + .first(where: { $0.contains("reclaimed") }) { + lastDockerPruneResult = line.trimmingCharacters(in: .whitespaces) } else { - let lowerOutput = result.1.lowercased() - if lowerOutput.contains("cannot connect") || - lowerOutput.contains("is the docker daemon running") || - lowerOutput.contains("connection refused") || - lowerOutput.contains("no such file or directory") { - lastDockerPruneResult = "Docker must be running to prune" - } else { - lastDockerPruneResult = "Docker prune failed — is Docker running?" - } + lastDockerPruneResult = "Docker pruned successfully" + } + case .finished(_, let output): + let lowerOutput = output.lowercased() + if lowerOutput.contains("cannot connect") || + lowerOutput.contains("is the docker daemon running") || + lowerOutput.contains("connection refused") || + lowerOutput.contains("no such file or directory") { + lastDockerPruneResult = "Docker must be running to prune" + } else { + lastDockerPruneResult = "Docker prune failed — is Docker running?" } - } catch { + case .launchFailed: lastDockerPruneResult = "Docker not found" + case .timedOut: + // REPORTED, NOT SWALLOWED — and the abandonment is disclosed + // rather than dressed as a kill: SIGTERM is best-effort (docker + // forwards it; a child that ignores it keeps running, and its + // reader thread stays parked until the pipe closes — the same + // abandonment residual `BoundedDiskInfo` carries). CAN A RETRY + // DIFFER? Yes: the causes — a daemon mid-restart, a huge layer + // delete, a wedged Docker Desktop — are all transient, so + // "check Docker and retry" is a real remedy, not a strand + // dressed as one. + // TWO TIMEOUTS, AND THE MESSAGE MUST NOT CLAIM THE OTHER ONE + // (PR #461 merge gate). `abandon()` above already decided which + // this is, and because `begin` performs the launch under the same + // lock, `didStart` is now a fact rather than a guess about a + // statement that may not have run yet. + // + // BOTH ARMS ARE PINNED, and the r2 note that stood here claiming + // otherwise was false in both halves (merge gate r3, P5). It said + // the wordings had no cell and that pinning them would need the + // strings hoisted into production API; in fact + // `testDockerPruneExpiresReportsAndReleasesTheButton` already + // pinned this arm, and + // `testAPruneThatNeverStartedSaysSoAndClaimsNothingWasStopped` + // now pins the other — both by reading `lastDockerPruneResult`, + // published state those cells already consume, with nothing + // hoisted. A residual is recorded so a future round need not + // rediscover it; that one would have sent a future round hunting + // for a cell thirty lines away in a file it already reads, and + // licensed a swap of these two messages as "uncovered". + if child.didStart { + if child.isRunning { child.terminate() } + lastDockerPruneResult = "Docker prune did not finish within " + + "\(budget) — asked it to stop; check Docker and retry" + } else { + // Nothing was launched, so nothing was asked to stop. Saying + // otherwise is the false-message class this project retires + // everywhere else; the remedy is still real, because the + // cause (a starved pool, a busy daemon) is transient. + lastDockerPruneResult = "Docker prune did not start within " + + "\(budget) — nothing was run; check Docker and retry" + } } // Refresh disk info after prune — THE TWIN OF `scan`'s fetch, and @@ -1888,10 +2020,10 @@ class CacheoutViewModel: ObservableObject { // bounds this one either: `isDockerPruning` is released by the // `defer` at the top of this method, which does not run until this // await returns, so an unstarted detached fetch latched the button - // disabled exactly the way the prune's own unbounded - // `waitUntilExit()` does. On `.timedOut` the header keeps the - // figures it had, `lastDockerPruneResult` (already set above) still - // reaches the user, and the next scan refreshes. + // disabled exactly the way the prune's own then-unbounded + // `waitUntilExit()` did before fn-4.20. On `.timedOut` the header + // keeps the figures it had, `lastDockerPruneResult` (already set + // above) still reaches the user, and the next scan refreshes. if case .fetched(let fetched) = await BoundedDiskInfo.current( within: diskInfoBudget ) { @@ -1963,8 +2095,8 @@ class CacheoutViewModel: ObservableObject { // below — COUNTED, `grep -n 'isCleaning ='`). No watchdog, no // timeout and no view ever clears it. So a clean that never returns // latches BOTH the clean path and the scan path shut for the life of - // the app, exactly as `dockerPrune`'s unbounded `waitUntilExit()` - // latches its own button. + // the app, exactly as `dockerPrune`'s formerly unbounded wait + // latched its own button before fn-4.20. // // NOT BOUNDED, deliberately, and this is the product decision: a // deletion cannot be abandoned. `removefile`/`trashItem` keep running diff --git a/Sources/Cacheout/Views/ScannerItemSection.swift b/Sources/Cacheout/Views/ScannerItemSection.swift index 1fba5846..133562f4 100644 --- a/Sources/Cacheout/Views/ScannerItemSection.swift +++ b/Sources/Cacheout/Views/ScannerItemSection.swift @@ -314,6 +314,15 @@ struct ScanIssueRowPresentation: Equatable { // which clause fired, in the tooltip. case .policyRefusedRoot: return "refused by the search-root safety policy" + // A DISCOVERED candidate (a git worktree, or a repository's admin + // data), not a root: git's cleanup would modify paths that do not + // all sit inside ONE configured dev root, so no removal is offered + // (fn-4.12). Under `.containerRefused` this row read "not a + // configured search root" while the producer's own `detail` said + // "inside a configured dev root". No remedy is claimed — where the + // out-of-scope data sits is the user's layout; `detail` names it. + case .mutationScopeRefused: + return "git cleanup is not contained in one dev root — not offered" case .symlinkRoot: return "symlinked — not searched" // Not a symlink and not a directory (PR #459 codex r13): a regular // file, FIFO, socket or device stands there. Under `.symlinkRoot` diff --git a/Tests/CacheoutTests/BuildArtifactsScannerTests.swift b/Tests/CacheoutTests/BuildArtifactsScannerTests.swift index fec174d6..35e10e5e 100644 --- a/Tests/CacheoutTests/BuildArtifactsScannerTests.swift +++ b/Tests/CacheoutTests/BuildArtifactsScannerTests.swift @@ -1004,7 +1004,7 @@ final class BuildArtifactsScannerTests: XCTestCase { } func testLogicalBytesPredicateMatchesTheAsBuiltBoundaryCells() { - // The as-built predicate VERBATIM (BuildArtifactsScanner.swift:1405-1406) + // The as-built predicate VERBATIM (BuildArtifactsScanner.swift:1416-1417) // — boundary cells on BOTH sides, which no filesystem fixture can // place precisely. var equal = SizeReport() @@ -1361,8 +1361,11 @@ final class BuildArtifactsScannerTests: XCTestCase { "candidate-level denials are never dropped") } - func testInjectedEPERMClassifiesAsTccDenied() async throws { + func testInjectedEPERMClassifiesNeutrallyNeverAsTcc() async throws { // EPERM cannot be fixtured from an unentitled process — inject it. + // NEUTRAL since fn-4.12: the failing probe is a raw lstat, and a + // bare errno carries no provenance, so `.tccDenied` — and the GUI's + // "Grant access…" link that rides it — may not be asserted. let target = try makeProject( at: dev.appendingPathComponent("proj"), marker: "Cargo.toml", artifact: "target", payloadBytes: nil @@ -1384,8 +1387,13 @@ final class BuildArtifactsScannerTests: XCTestCase { XCTAssertEqual(found.state, .denied, "nothing measurable behind the denial") XCTAssertEqual(found.rootRecords.map(\.status), [.deniedUnmeasured]) - XCTAssertEqual(found.scanError?.kind, .tccDenied, - "EPERM → TCC (the frozen taxonomy)") + XCTAssertEqual(found.scanError?.kind, .other, + "bare EPERM is neutral — never TCC (fn-4.12)") + XCTAssertTrue( + found.scanError?.message.contains("could not be established") + == true, + "the neutral detail says why: \(String(describing: found.scanError))" + ) } // MARK: - R16 data path + per-root issue surfacing @@ -1409,8 +1417,10 @@ final class BuildArtifactsScannerTests: XCTestCase { ) XCTAssertEqual(itemPaths(outcome), [identityPath(of: target)]) - XCTAssertEqual(outcome.errors.map(\.kind), [.containerRefused], - "the config issue rides the outcome: \(outcome.errors)") + XCTAssertEqual(outcome.errors.map(\.kind), [.policyRefusedRoot], + "the config issue rides the outcome — the root IS " + + "configured, so `.containerRefused` was the " + + "false kind (fn-4.12): \(outcome.errors)") XCTAssertEqual(outcome.errors.first?.url?.path, "/") } @@ -3770,7 +3780,7 @@ final class BuildArtifactsScannerTests: XCTestCase { async throws { // The ROOT cell. The sizer declines to enumerate its own root when - // that root is a mount (`DirectorySizer.swift:261-272`); the probe must + // that root is a mount (`DirectorySizer.swift:322-337`); the probe must // decline identically, or it reads a whole foreign volume that the // caller has already denied. let artifact = try makeProject( @@ -5266,7 +5276,7 @@ final class BuildArtifactsScannerTests: XCTestCase { categories: [], home: fixtureHome, provider: FileSystemIdentityProvider() ) - let session = runtime.scanValidatedSession( + let session = await runtime.scanValidatedSession( context: ScanContext(trigger: .userInitiated) ) var items: [ReclaimableItem] = [] @@ -5825,9 +5835,10 @@ final class BuildArtifactsScannerTests: XCTestCase { } /// R16 data path: a POLICY-REJECTED PERSISTED root is never registered - /// and never walked, while its classified `.containerRefused` issue - /// rides EVERY scan outcome — asserted across two consecutive scans, and - /// the stored value is never rewritten. + /// and never walked, while its classified `.policyRefusedRoot` issue + /// (fn-4.12; `.containerRefused` before that, whose label contradicted + /// the detail) rides EVERY scan outcome — asserted across two + /// consecutive scans, and the stored value is never rewritten. func testPolicyRejectedPersistedRootRidesEveryScanAndNeverRegisters() async throws { @@ -5848,7 +5859,7 @@ final class BuildArtifactsScannerTests: XCTestCase { for pass in 1...2 { let outcome = try await runScan(scanner) - let refusals = outcome.errors.filter { $0.kind == .containerRefused } + let refusals = outcome.errors.filter { $0.kind == .policyRefusedRoot } XCTAssertEqual(refusals.count, 1, "pass \(pass)") XCTAssertEqual(refusals.first?.url?.path, "/", "pass \(pass)") XCTAssertTrue( @@ -5913,7 +5924,8 @@ final class BuildArtifactsScannerTests: XCTestCase { let fileIssue = try XCTUnwrap( outcome.errors.first { $0.url?.path == fileRoot.path } ) - XCTAssertEqual(fileIssue.kind, .symlinkRoot) + XCTAssertEqual(fileIssue.kind, .nonDirectoryRoot, + "a regular-file root is NOT 'symlinked' (fn-4.12)") // Nested roots walked independently, overlap collapsed to ONE item // per canonical identity (D7). XCTAssertEqual( @@ -7247,6 +7259,54 @@ final class BuildArtifactsScannerTests: XCTestCase { } } + /// A child open failing with a chosen raw errno — the seam behind the + /// containment descent's denial classification (fn-4.12). + private final class FailingChildOpenProvider: FileSystemIdentityProvider { + var failErrno: Int32 = EPERM + override func openChildDirectory( + inDirectory parent: Int32, named name: String, logical url: URL + ) -> Int32 { + errno = failErrno + return -1 + } + } + + /// The descent's own raw-errno classifier follows the shared bare-EPERM + /// rule (fn-4.12): a raw `openat` EPERM is NEUTRAL `.metadata` — never + /// `.tcc`, whose item-row mapping prints the "Grant access…" remedy on + /// a guess — while EACCES (the control, asserting WHICH refusal fired) + /// keeps its unambiguous `.permission`. + func testDescentOpenEPERMClassifiesNeutrallyEACCESAsPermission() throws { + let root = dev.appendingPathComponent("root") + let artifact = root.appendingPathComponent("proj/target") + try mkdir(artifact) + + for (code, expected): (Int32, SizeDenial.Kind) in [ + (EPERM, .metadata), (EACCES, .permission), + ] { + let provider = FailingChildOpenProvider() + provider.failErrno = code + let held = try anchors([root], provider: provider) + switch BuildArtifactsScanner.anchoredArtifactDirectory( + try candidate(artifact: artifact, originRoot: root), + rootAnchors: held, provider: provider + ) { + case .obstructed(let report): + XCTAssertEqual(report.denials.map(\.kind), [expected], + "errno \(code)") + if code == EPERM { + XCTAssertTrue( + report.denials.first?.detail + .contains("could not be established") == true, + "the neutral detail says why: \(report.denials)" + ) + } + default: + XCTFail("a failed child open must be a classified obstruction") + } + } + } + /// `..` IS a legal `openat` name, and it climbs. The descent's own /// component check is what refuses it before any syscall — not the /// provider's, which is a different layer and may be overridden, diff --git a/Tests/CacheoutTests/CLIGateTests.swift b/Tests/CacheoutTests/CLIGateTests.swift index 6a5b062b..cf7cc532 100644 --- a/Tests/CacheoutTests/CLIGateTests.swift +++ b/Tests/CacheoutTests/CLIGateTests.swift @@ -945,11 +945,13 @@ final class CLIGateTests: XCTestCase { exact + (row["estimated_up_to_bytes"] as? Int64 ?? 0), "size_bytes stays the compatibility component sum") - // R12/R16: the classified config issue is VISIBLE on the wire. + // R12/R16: the classified config issue is VISIBLE on the wire — + // `policy_refused_root` since fn-4.12 (the root IS configured; the + // old `container_refused` label said the opposite). let errors = try XCTUnwrap(envelope["scanner_errors"] as? [[String: Any]]) let refusals = errors.filter { $0["scanner_id"] as? String == "build_artifacts" - && $0["kind"] as? String == "container_refused" + && $0["kind"] as? String == "policy_refused_root" } XCTAssertEqual(refusals.count, 1, "\(errors)") XCTAssertEqual(try XCTUnwrapElement(refusals, 0)["path"] as? String, "/") @@ -1135,7 +1137,8 @@ final class CLIGateTests: XCTestCase { // row builder (the count was stale at "seven" before PR #459 codex // r11 — `enumeration_truncated` and `config_invalid` had already // landed; codex r13 added two more, r15 one; the fn-5 merge added - // `tool_unavailable`) — exact rows: the nine + // `tool_unavailable`; fn-4.12 added + // `mutation_scope_refused`) — exact rows: the ten // non-TCC filesystem kinds below carry // their real `path`; `tcc_denied` carries its path AND, ALONE, a // `grant_hint` (macOS denies CLI processes silently, so the row must @@ -1157,6 +1160,10 @@ final class CLIGateTests: XCTestCase { // splitting the two conditions `container_refused` and // `symlink_root` were mis-labelling on `ephemeral_tmp`. (.policyRefusedRoot, "policy_refused_root"), + // ADDED fn-4.12 — the git-worktree containment refusals, whose + // candidates ARE inside a configured root, so + // `container_refused` was a false diagnosis for them. + (.mutationScopeRefused, "mutation_scope_refused"), (.symlinkRoot, "symlink_root"), (.nonDirectoryRoot, "non_directory_root"), (.permissionDenied, "permission_denied"), diff --git a/Tests/CacheoutTests/CacheCleanerTests.swift b/Tests/CacheoutTests/CacheCleanerTests.swift index fb9636fa..d612526e 100644 --- a/Tests/CacheoutTests/CacheCleanerTests.swift +++ b/Tests/CacheoutTests/CacheCleanerTests.swift @@ -233,7 +233,10 @@ final class CacheCleanerTests: XCTestCase { var mountPointPaths: Set = [] /// Paths whose probe reports `.absent` even though they exist — /// hermetic stand-in for a child vanishing between enumeration and - /// the cleaner's already-gone probe (fn-2.3 ENOENT-asymmetry tests). + /// the cleaner's already-gone read (fn-2.3 ENOENT-asymmetry tests). + /// Honored by BOTH kind seams: `probeKind` (the path probe that used + /// to be the skip's decider) and `probeChild` (the + /// descriptor-relative binding read that decides it since fn-4.21). var absentPaths: Set = [] override func identity(of url: URL) -> Identity? { @@ -263,6 +266,20 @@ final class CacheCleanerTests: XCTestCase { } return super.probeKind(of: url) } + + override func probeChild( + inDirectory descriptor: Int32, named name: String, + logical: @autoclosure () -> URL + ) -> ChildProbe { + let url = logical() + if absentPaths.contains(url.path) + || absentPaths.contains(canonicalize(url).path) { + return .absent + } + return super.probeChild( + inDirectory: descriptor, named: name, logical: url + ) + } } /// Minimal protocol conformer for the runtime-derived-admission test @@ -536,6 +553,106 @@ final class CacheCleanerTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: goodB.path)) } + // MARK: - fn-4.15: a CANCELLED (partial) measurement fails closed + + /// Runs `body` inside a task that is PROVABLY cancelled before `body` + /// starts (bounded spin on `Task.isCancelled`, then the already-cancelled + /// task runs `body`); cancellation's stickiness makes every + /// `Task.isCancelled` read inside answer true — deterministic, no race. + private func inPreCancelledTask( + _ body: @escaping @Sendable () async -> T + ) async -> T? { + let task = Task.detached { () -> T? in + var spins = 0 + while !Task.isCancelled { + spins += 1 + if spins > 1_000_000 { return nil } // bounded, never a park + await Task.yield() + } + return await body() + } + task.cancel() + return await task.value + } + + /// The CATEGORY-CHILD arm: the delete-time sizer is uncapped but + /// cancellable, and a walk that stopped on cancellation swept only part + /// of the tree — its mount check and its claims are both partial, so the + /// child is refused, not deleted. REAL path end to end: the production + /// sizer marks the report inside an already-cancelled task. + /// + /// MUTATION: delete the `report.cancelled` guard in the category-child + /// arm — RED here (the child is deleted despite the partial sweep). + func testACancelledMeasurementRefusesTheCategoryChildNotDeletes() async throws { + let root = try makeTempDir("cancelled-category-root") + defer { try? FileManager.default.removeItem(at: root) } + let child = root.appendingPathComponent("payload-dir") + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + try writeFile(child.appendingPathComponent("payload.bin")) + + let category = makeCategory(at: root, name: "cancelled-cat") + let cleaner = CacheCleaner(containerRoots: []) + let items = categoryItems([makeScanResult(category: category)]) + + let cleaned = await inPreCancelledTask { + await cleaner.clean(items: items, moveToTrash: false) + } + let report = try XCTUnwrap(cleaned) + + XCTAssertTrue(FileManager.default.fileExists(atPath: child.path), + "a partially-measured child must not be deleted") + XCTAssertEqual(report.errors.count, 1) + let message = try XCTUnwrap(report.errors.first?.message) + // WHICH refusal fired: cancellation, not the mount arm and not a + // denial — and honestly retryable (a new clean re-measures). + XCTAssertTrue(message.contains("cancelled"), message) + XCTAssertTrue(message.contains("not permanent"), message) + + // CONTROL: the identical fixture, uncancelled, deletes cleanly — so + // the refusal above is attributable to cancellation alone. + let control = await cleaner.clean(items: items, moveToTrash: false) + XCTAssertTrue(control.errors.isEmpty, + "\(control.errors.map(\.message))") + XCTAssertFalse(FileManager.default.fileExists(atPath: child.path)) + } + + /// The ITEM-TARGET arm: same rule through `clean(items:)`'s removeItem + /// pipeline. + /// + /// MUTATION: delete the `report.cancelled` guard in the item arm — RED + /// here (the target is deleted despite the partial sweep). + func testACancelledMeasurementRefusesTheItemTargetNotDeletes() async throws { + let root = try makeTempDir("cancelled-item-root") + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("proj/artifacts") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try writeFile(target.appendingPathComponent("a.json")) + + let items = [removableItem(at: target, originContainer: root)] + let cleaner = CacheCleaner( + containerRoots: [root], containerSnapshot: sessionSnapshot(of: [root]) + ) + + let cleaned = await inPreCancelledTask { + await cleaner.clean(items: items, moveToTrash: false) + } + let report = try XCTUnwrap(cleaned) + + XCTAssertTrue(FileManager.default.fileExists(atPath: target.path), + "a partially-measured item must not be deleted") + XCTAssertEqual(report.errors.count, 1) + XCTAssertTrue(report.entries.isEmpty, "nothing was freed") + let message = try XCTUnwrap(report.errors.first?.message) + XCTAssertTrue(message.contains("cancelled"), message) + XCTAssertTrue(message.contains("not permanent"), message) + + // CONTROL: uncancelled, the same item deletes. + let control = await cleaner.clean(items: items, moveToTrash: false) + XCTAssertTrue(control.errors.isEmpty, + "\(control.errors.map(\.message))") + XCTAssertFalse(FileManager.default.fileExists(atPath: target.path)) + } + // MARK: - Honest freed bytes (R1/R16) func testCategoryFreedEqualsMeasuredDeletedBytesAcrossTwoPaths() async throws { @@ -3928,9 +4045,19 @@ final class CacheCleanerTests: XCTestCase { func arm() { armed = true } - override func identity(ofDescriptor descriptor: Int32) -> Identity? { + /// The revalidator's ownership gate — the ONLY production caller of + /// this accessor — is what marks "the revalidation has run". It used + /// to be `identity(ofDescriptor:)`, which was equivalent while the + /// cleaner's first descriptor question came after the verdict; since + /// the leaf binding was hoisted above the measurement (PR #461 codex + /// r2) two descriptor questions happen BEFORE the revalidation, so + /// that gate opened too early and the swap landed ahead of the + /// revalidator's own path check — which then caught it, making this + /// cell prove a different guard than the one it is named for. + override func ownerUID(ofDescriptor fd: Int32) -> UInt32? { + let real = super.ownerUID(ofDescriptor: fd) inspected = true - return super.identity(ofDescriptor: descriptor) + return real } override func identity(of url: URL) -> Identity? { @@ -4823,12 +4950,24 @@ final class CacheCleanerTests: XCTestCase { XCTAssertTrue(message.contains("nothing was reported freed"), message) } - /// Removes the child inside the ONE window between the container proof - /// and the leaf bind — the `probeChild` that reads the leaf under the + /// Removes the child inside the window between the container proof and + /// a chosen leaf bind — the Nth `probeChild` that reads the leaf under a /// held container descriptor. A real `removeItem`, no sleeps. + /// + /// WHY THE CALL IS COUNTED (fn-4.21): the guarded-child pipeline now + /// takes its own binding read at its FIRST read of the child, before the + /// measurement, so the DISPOSAL's bind is the SECOND `probeChild` of the + /// child in trash mode. A vanish at call 1 is "gone before anything was + /// measured" — the already-gone SKIP; a vanish at call 2 is the + /// disposal's own window, which is what + /// `testTrashModeRefusesAChildThatVanishedBeforeItCouldBeBound` is + /// about. private final class VanishTheChildAtTheBindProvider: FileSystemIdentityProvider, @unchecked Sendable { var child: URL! + /// 1-based index of the matching `probeChild` call to vanish on. + var vanishOnCall = 1 + private var matchingCalls = 0 private(set) var vanished = false override func probeChild( @@ -4839,8 +4978,11 @@ final class CacheCleanerTests: XCTestCase { if !vanished, url.standardizedFileURL.path == child.standardizedFileURL.path { - vanished = true - try? FileManager.default.removeItem(at: child) + matchingCalls += 1 + if matchingCalls >= vanishOnCall { + vanished = true + try? FileManager.default.removeItem(at: child) + } } return super.probeChild( inDirectory: descriptor, named: name, logical: url @@ -4875,6 +5017,10 @@ final class CacheCleanerTests: XCTestCase { let provider = VanishTheChildAtTheBindProvider() provider.child = child + // Call 1 is the pipeline's own fn-4.21 binding read (a vanish there + // is the already-gone skip); call 2 is the DISPOSAL's bind — the + // window this cell is about. + provider.vanishOnCall = 2 let recorder = TrashRecorder() let cleaner = CacheCleaner( @@ -4907,6 +5053,52 @@ final class CacheCleanerTests: XCTestCase { XCTAssertTrue(message.contains(child.path), message) } + /// THE OTHER SIDE OF THE COUNTED CALL (fn-4.21): a child that vanishes + /// at the PIPELINE's binding read — before anything was measured — is + /// the already-gone SKIP, exactly as a child absent at the old path + /// probe was: no entry, no error, and the Trash never consulted. + func testTrashModeChildVanishedAtThePipelineBindIsSkipped() async throws { + let home = try makeTempDir("home") + let base = try makeTempDir() + let trashDir = try makeTempDir("fake-trash") + defer { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: base) + try? FileManager.default.removeItem(at: trashDir) + } + let root = base.appendingPathComponent("cache-root") + let child = root.appendingPathComponent("entry") + try FileManager.default.createDirectory( + at: child, withIntermediateDirectories: true + ) + try writeFile(child.appendingPathComponent("ours.bin"), bytes: 4096) + + let provider = VanishTheChildAtTheBindProvider() + provider.child = child + provider.vanishOnCall = 1 + + let recorder = TrashRecorder() + let cleaner = CacheCleaner( + home: home, containerRoots: [], provider: provider, + trashHandler: makeTrashSeam(into: trashDir, recorder: recorder) + ) + let report = await cleaner.clean( + items: categoryItems( + [makeScanResult(category: makeCategory(at: root))], + home: home, provider: provider + ), + moveToTrash: true + ) + + XCTAssertTrue(provider.vanished, "the fixture never removed the child") + XCTAssertTrue(recorder.urls.isEmpty, "\(recorder.urls)") + XCTAssertTrue(report.entries.isEmpty, "\(report.entries)") + XCTAssertTrue( + report.errors.isEmpty, + "gone before anything was measured is a SKIP: \(report.errors)" + ) + } + // MARK: - The cleanup log's own open must not block (PR #459 review r4) /// A zero-record `.removeContents` item whose refusal is LOGGED — the @@ -5045,19 +5237,25 @@ extension CacheCleanerTests { return real } - /// Descriptor-identity question #1 after the verdict returns is the - /// cleaner's admitted-parent capture; #2 is the DISPOSAL's own - /// container proof (`openAdmittedContainer`, reached through - /// `DepthSafeRemoval.remove` on the permanent arm and - /// `TrashDisposal.boundLeaf` on the Trash arm) — after the final - /// path check. The swap lands there, for real; every answer is - /// `super`'s real answer (the parent directory's identity is - /// unchanged by a leaf swap, so the container proof rightly passes - /// and the LEAF binding is the one guard left standing). + /// The FIRST descriptor-identity question after the verdict returns + /// is the DISPOSAL's own container proof (`openAdmittedContainer`, + /// reached through `DepthSafeRemoval.remove` on the permanent arm and + /// `TrashDisposal.boundLeaf` on the Trash arm) — after the final path + /// check. The swap lands there, for real; every answer is `super`'s + /// real answer (the parent directory's identity is unchanged by a + /// leaf swap, so the container proof rightly passes and the LEAF + /// binding is the one guard left standing). + /// + /// It used to be the SECOND, with the cleaner's admitted-parent + /// capture ahead of it. That capture moved above the measurement when + /// the leaf binding was hoisted there (PR #461 codex r2), so it now + /// happens BEFORE the revalidator gates and is no longer counted + /// here. The count changed because production's order changed; what + /// the swap targets did not. override func identity(ofDescriptor descriptor: Int32) -> Identity? { if armed, revalidatorGatesRan { descriptorIdentityCallsAfterGates += 1 - if descriptorIdentityCallsAfterGates == 2, !swapped { + if descriptorIdentityCallsAfterGates == 1, !swapped { swapped = true try? FileManager.default.moveItem(at: target, to: stash) plantReplacement() @@ -5272,4 +5470,663 @@ extension CacheCleanerTests { XCTAssertTrue(FileManager.default.fileExists(atPath: payload.path), "and its target tree is untouched") } + + // MARK: - fn-4.21: the leaf binding where `expecting:` is nil + + /// Deterministic child-swap fixture for CONTENTS mode — the mainline + /// deletion path (`CategoryScanner` is default-selected). + /// + /// The window it aims at: the child is measured, then the deletion hops + /// to a background queue and opens the ADMITTED CONTAINER (proved) and + /// the LEAF (with `expecting: nil`, proved against nothing). The swap is + /// two real `rename(2)`-class mutations INSIDE the container — the child + /// renamed aside at its own path, a stranger created at the name — with + /// the container itself untouched, so the container binding passes by + /// construction and only a LEAF binding can refuse. + /// + /// Single-threaded and deterministic: the swap fires inside a provider + /// question the pipeline is known to ask at the right instant — the + /// first identity-of-descriptor question whose answer IS the container + /// (the deletion proving the folder it is about to resolve the leaf in), + /// gated on the measurement having already read the payload + /// (`identity(of:)` of the marker file, the sizer's claim read). No + /// sleeps, no races to win. + private final class ContentsChildSwapProvider: FileSystemIdentityProvider { + var container: URL! + var child: URL! + /// A file INSIDE the child whose claim read marks "the measurement + /// has happened" — the swap must land after it, never before. + var markerLeafName: String! + var stash: URL! + private var armed = false + private var measureSeen = false + private(set) var swapped = false + private var containerIdentity: Identity? + + func arm() { + containerIdentity = super.identity(of: container) + armed = true + } + + override func identity(of url: URL) -> Identity? { + if armed, url.lastPathComponent == markerLeafName { + measureSeen = true + } + return super.identity(of: url) + } + + override func identity(ofDescriptor fd: Int32) -> Identity? { + let answer = super.identity(ofDescriptor: fd) + if armed, measureSeen, !swapped, answer == containerIdentity { + swapped = true + try? FileManager.default.moveItem(at: child, to: stash) + try? FileManager.default.createDirectory( + at: child, withIntermediateDirectories: true + ) + try? Data("stranger".utf8).write( + to: child.appendingPathComponent("stranger.bin") + ) + } + return answer + } + } + + private func makeContentsSwapFixture( + _ label: String = #function + ) throws -> (home: URL, root: URL, victim: URL, marker: String) { + let home = try makeTempDir(label) + let root = home.appendingPathComponent("Library/Caches/fixture-cache") + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true + ) + let victim = root.appendingPathComponent("victim-child") + try FileManager.default.createDirectory( + at: victim, withIntermediateDirectories: true + ) + let marker = "payload-\(UUID().uuidString.prefix(8)).bin" + try writeFile(victim.appendingPathComponent(marker), bytes: 4096) + return (home, root, victim, marker) + } + + /// **THE fn-4.21 P1, CONTENTS MODE, PERMANENT ARM** (PR #460 r18 + /// adversarial verification; pre-existing on `origin/main`). + /// + /// `deleteGuardedChild` passes `expecting: nil`, and + /// `DepthSafeRemoval.proveInspectedRoot` returns immediately on nil — so + /// before the fix NOTHING between the measurement and the `unlinkat` + /// examined the child: a stranger renamed onto the child's own path + /// inside the window was destroyed, and the report carried the byte + /// count of the tree the app had measured and then never touched. + /// + /// The binding demanded here is the r18 `BoundObject` shape: the child's + /// kind+inode read by `TrashDisposal.boundLeaf` under the SAME admitted + /// container the removal is proved against, captured at the pipeline's + /// FIRST READ of the child and re-proved by the same function on the far + /// side of the queue hop. + func testContentsModeChildSwappedInsideTheWindowIsRefused() async throws { + let (home, root, victim, marker) = try makeContentsSwapFixture() + defer { try? FileManager.default.removeItem(at: home) } + + let provider = ContentsChildSwapProvider() + provider.container = root + provider.child = victim + provider.markerLeafName = marker + provider.stash = home.appendingPathComponent("stash") + + let cleaner = CacheCleaner( + home: home, containerRoots: [], provider: provider + ) + provider.arm() + let report = await cleaner.clean( + items: categoryItems( + [makeScanResult(category: makeCategory(at: root))], + home: home, provider: provider + ), + moveToTrash: false + ) + + XCTAssertTrue(provider.swapped, "the fixture never armed the swap") + XCTAssertTrue( + FileManager.default.fileExists( + atPath: victim.appendingPathComponent("stranger.bin").path + ), + "the stranger renamed onto the child's path was DELETED without " + + "any check having examined it" + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: provider.stash.appendingPathComponent(marker).path + ), + "the measured tree is intact at the stash path" + ) + XCTAssertTrue( + report.entries.isEmpty, + "reported bytes for a tree it never touched: \(report.entries)" + ) + XCTAssertEqual(report.errors.count, 1, "\(report.errors)") + let message = try XCTUnwrap(report.errors.first?.message) + // WHICH refusal fired, not just that one did: the leaf binding's + // (`.notTheInspectedObject`), never the container's. + XCTAssertTrue( + message.contains("no longer the one that was inspected"), message + ) + XCTAssertTrue( + logContents(home: home).contains("REFUSED [content-drift]"), + logContents(home: home) + ) + } + + /// CONTROL for the cell above: identical fixture, identical provider + /// double, never armed — the clean must SUCCEED, so the swap cell's + /// refusal is evidenced to come from the swap and not from the fixture + /// refusing for its own reasons. + func testContentsModeSwapFixtureUnarmedControlCleans() async throws { + let (home, root, victim, marker) = try makeContentsSwapFixture() + defer { try? FileManager.default.removeItem(at: home) } + + let provider = ContentsChildSwapProvider() + provider.container = root + provider.child = victim + provider.markerLeafName = marker + provider.stash = home.appendingPathComponent("stash") + // NOT armed. + + let cleaner = CacheCleaner( + home: home, containerRoots: [], provider: provider + ) + let report = await cleaner.clean( + items: categoryItems( + [makeScanResult(category: makeCategory(at: root))], + home: home, provider: provider + ), + moveToTrash: false + ) + + XCTAssertFalse(provider.swapped) + XCTAssertTrue(report.errors.isEmpty, "\(report.errors)") + XCTAssertEqual(report.entries.count, 1) + XCTAssertFalse( + FileManager.default.fileExists(atPath: victim.path), + "the child is deleted on the unarmed control" + ) + } + + /// A provider whose descriptor-relative child read fails with a + /// NON-ENOENT errno for the victim child — the bind-failure branch of + /// `deleteGuardedChild`'s pipeline capture that is neither "already + /// gone" nor a successful binding (`TrashDisposal.boundLeaf` → + /// `probeChild` answering `.failed`). Modeled on + /// `WorktreeReclaimPerformerTests.LockUnreadableProvider` (PR #460 + /// codex r19, R3): deny exactly one read, pass everything else through. + private final class ChildBindDeniedProvider: FileSystemIdentityProvider, + @unchecked Sendable + { + var deniedName: String! + var armed = false + private(set) var denied = false + + override func probeChild( + inDirectory descriptor: Int32, named name: String, + logical: @autoclosure () -> URL + ) -> ChildProbe { + if armed, name == deniedName { + denied = true + return .failed(errno: EACCES) + } + return super.probeChild( + inDirectory: descriptor, named: name, logical: logical() + ) + } + } + + /// **THE NON-ENOENT BIND-FAILURE BRANCH OF fn-4.21's PIPELINE CAPTURE** + /// (fn-4 round 1 gate): `deleteGuardedChild` catches `.posix(ENOENT)` + /// from `TrashDisposal.boundLeaf` as `skippedAlreadyGone`; every OTHER + /// bind failure must be the child's FAILURE — reported in the cleanup + /// report — and never a silent skip, because an EACCES leaf is still + /// STANDING THERE: "already gone" would be a lie the report repeats as + /// success-shaped silence, and the deletion must not proceed either + /// (nothing was bound to prove it against). + /// + /// WHICH refusal: the report's one error is the binding's own + /// `.posix(EACCES)` ("Permission denied" naming the child's path) — + /// distinguishable from a skip, which produces NO error row and NO + /// entry. + /// + /// MUTATION: widen the ENOENT catch in `deleteGuardedChild` to swallow + /// every `DepthSafeRemoval.Failure` as `skippedAlreadyGone` and this + /// cell goes red on the error-count assertion — the EACCES leaf is then + /// reported as if it were absent. + func testContentsModeChildWhoseBindReadFailsIsRefusedNotSkipped() + async throws + { + let (home, root, victim, marker) = try makeContentsSwapFixture() + defer { try? FileManager.default.removeItem(at: home) } + + let provider = ChildBindDeniedProvider() + provider.deniedName = victim.lastPathComponent + + let cleaner = CacheCleaner( + home: home, containerRoots: [], provider: provider + ) + provider.armed = true + let report = await cleaner.clean( + items: categoryItems( + [makeScanResult(category: makeCategory(at: root))], + home: home, provider: provider + ), + moveToTrash: false + ) + + XCTAssertTrue(provider.denied, "the fixture never denied the bind") + XCTAssertTrue( + FileManager.default.fileExists( + atPath: victim.appendingPathComponent(marker).path + ), + "a child whose bind read failed must survive untouched" + ) + XCTAssertTrue( + report.entries.isEmpty, + "reported bytes for a child it refused to bind: \(report.entries)" + ) + // NOT a skip: a skip is silent (zero errors); a non-ENOENT bind + // failure is the child's failure, with the binding's own errno. + XCTAssertEqual(report.errors.count, 1, "\(report.errors)") + let message = try XCTUnwrap(report.errors.first?.message) + XCTAssertTrue(message.contains("Permission denied"), message) + XCTAssertTrue(message.contains(victim.path), message) + } + + /// CONTROL for the cell above: identical fixture, identical provider + /// double, never armed — the clean must SUCCEED, so the refusal above + /// is evidenced to come from the denied bind and not from the fixture + /// refusing for its own reasons. + func testContentsModeBindDeniedFixtureUnarmedControlCleans() async throws { + let (home, root, victim, _) = try makeContentsSwapFixture() + defer { try? FileManager.default.removeItem(at: home) } + + let provider = ChildBindDeniedProvider() + provider.deniedName = victim.lastPathComponent + // NOT armed. + + let cleaner = CacheCleaner( + home: home, containerRoots: [], provider: provider + ) + let report = await cleaner.clean( + items: categoryItems( + [makeScanResult(category: makeCategory(at: root))], + home: home, provider: provider + ), + moveToTrash: false + ) + + XCTAssertFalse(provider.denied) + XCTAssertTrue(report.errors.isEmpty, "\(report.errors)") + XCTAssertEqual(report.entries.count, 1) + XCTAssertFalse( + FileManager.default.fileExists(atPath: victim.path), + "the child is deleted on the unarmed control" + ) + } + + /// Deterministic target-swap fixture for ITEM mode with NO revalidator + /// (`probedObject == nil` — `expecting:` nil at runtime). + /// + /// The swap fires inside the delete-time RECHECKS: the first + /// path-identity question about the target asked AFTER the container + /// binding was read from a descriptor + /// (`DepthSafeRemoval.admittedParent`, the recheck sequence's first + /// act). That instant is after the fn-4.21 binding captures the leaf and + /// before the removal, so an unbound deletion destroys the stranger and + /// a bound one refuses. + private final class ItemNilProbeSwapProvider: FileSystemIdentityProvider { + var target: URL! + var stash: URL! + private var armed = false + private var parentSeen = false + private(set) var swapped = false + private var parentIdentity: Identity? + + func arm() { + parentIdentity = super.identity( + of: target.deletingLastPathComponent() + ) + armed = true + } + + override func identity(ofDescriptor fd: Int32) -> Identity? { + let answer = super.identity(ofDescriptor: fd) + if armed, answer == parentIdentity { parentSeen = true } + return answer + } + + override func identity(of url: URL) -> Identity? { + guard armed, parentSeen, !swapped, + url.lastPathComponent == target.lastPathComponent + else { return super.identity(of: url) } + // Freeze the answer about the object that WAS there, then swap — + // every later path question answers about the stranger, which + // every path check accepts. + let frozen = super.identity(of: url) + swapped = true + try? FileManager.default.moveItem(at: target, to: stash) + try? FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try? Data("stranger".utf8).write( + to: target.appendingPathComponent("stranger.bin") + ) + return frozen + } + } + + /// **THE SAME DEFECT, ITEM MODE, PERMANENT ARM** — `removeGuardedItem` + /// passes `probedObject`, which is nil for every item whose scanner + /// registers no revalidator (`fixture_scanner` here, and every shipped + /// scanner without one). Before the fix the deletion's leaf open was + /// proved against NOTHING: a stranger renamed onto the target's path + /// after the container binding was captured (and after every path + /// recheck answered about the old object) was destroyed with success + /// reported. + func testItemModeNilProbeTargetSwappedInsideTheWindowIsRefused() + async throws + { + let base = try makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let container = base.appendingPathComponent("fixture-container") + let project = container.appendingPathComponent("proj") + let target = project.appendingPathComponent("victim-item") + try FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try writeFile(target.appendingPathComponent("payload.bin"), bytes: 4096) + + let provider = ItemNilProbeSwapProvider() + provider.target = target + provider.stash = base.appendingPathComponent("stash") + + let cleaner = CacheCleaner( + home: base, containerRoots: [container], + containerSnapshot: sessionSnapshot( + of: [container], provider: provider + ), + provider: provider + ) + provider.arm() + let report = await cleaner.clean( + items: [makeRemoveItem(origin: container, target: target)], + moveToTrash: false + ) + + XCTAssertTrue(provider.swapped, "the fixture never armed the swap") + XCTAssertTrue( + FileManager.default.fileExists( + atPath: target.appendingPathComponent("stranger.bin").path + ), + "the stranger renamed onto the target's path was DELETED without " + + "any check having examined it" + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: provider.stash + .appendingPathComponent("payload.bin").path + ), + "the measured tree is intact at the stash path" + ) + XCTAssertTrue( + report.entries.isEmpty, + "reported bytes for a tree it never touched: \(report.entries)" + ) + XCTAssertEqual(report.errors.count, 1, "\(report.errors)") + let message = try XCTUnwrap(report.errors.first?.message) + XCTAssertTrue( + message.contains("no longer the one that was inspected"), message + ) + XCTAssertTrue( + logContents(home: base).contains("REFUSED [content-drift]"), + logContents(home: base) + ) + } + + /// Swaps the target while the MEASUREMENT is walking it — strictly after + /// any pre-measure binding and strictly before a post-measure one. + private final class MeasureWindowSwapProvider: FileSystemIdentityProvider { + var target: URL! + var stash: URL! + private var armed = false + private(set) var swapped = false + + func arm() { armed = true } + + /// Fires when the SIZER reaches the payload inside the target — its + /// per-entry `probeKind` during enumeration. Matched by basename, not + /// by path prefix: the sizer canonicalizes, so a `/var` fixture path + /// never prefix-matches its own `/private/var` spelling (the same + /// reason the sibling fixtures match on `lastPathComponent`). + override func probeKind(of url: URL) -> KindProbe { + if armed, !swapped, url.lastPathComponent == "payload.bin" { + swapped = true + let answer = super.probeKind(of: url) + try? FileManager.default.moveItem(at: target, to: stash) + try? FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try? Data("stranger".utf8).write( + to: target.appendingPathComponent("stranger.bin") + ) + return answer + } + return super.probeKind(of: url) + } + } + + /// **BOUND BEFORE MEASURED** (PR #461 codex r2). + /// + /// Item mode measured the target and only then bound its leaf, so for + /// every item whose scanner registers no revalidator there was a window + /// the whole measurement wide: rename the target away, install a stranger + /// at the same name, and the binding recorded the STRANGER. The far-side + /// proof then succeeded — it proved the stranger against itself — the + /// stranger was destroyed, and the report credited the ORIGINAL tree's + /// bytes. Contents mode has always bound first; only this arm was + /// backwards. + /// + /// MUTATION: move the binding back below `sizer.measure` and this reds + /// 3/3, on "the stranger installed during the measurement was DELETED" + /// plus a success entry and zero errors. + /// + /// CORRECTION to this cell's own first description (PR #461 merge gate + /// r4, P8): the mutant does NOT credit the original tree's bytes. The + /// entry it produces is `exactBytes: 0, estimatedUpToBytes: 0` — measured + /// — because the sizer's size read lands after the rename, so there is + /// nothing left to count. The defect is unchanged and no smaller: a + /// stranger nobody inspected is destroyed and reported as SUCCESS. Only + /// the bytes half of the story was wrong, and a wrong detail in an + /// evidence note is how the next round is sent looking in the wrong + /// place. + func testItemModeTargetSwappedDuringTheMeasurementIsRefused() + async throws + { + let base = try makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let container = base.appendingPathComponent("fixture-container") + let project = container.appendingPathComponent("proj") + let target = project.appendingPathComponent("victim-item") + try FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try writeFile(target.appendingPathComponent("payload.bin"), bytes: 4096) + + let provider = MeasureWindowSwapProvider() + provider.target = target + provider.stash = base.appendingPathComponent("stash") + + let cleaner = CacheCleaner( + home: base, containerRoots: [container], + containerSnapshot: sessionSnapshot( + of: [container], provider: provider + ), + provider: provider + ) + provider.arm() + let report = await cleaner.clean( + items: [makeRemoveItem(origin: container, target: target)], + moveToTrash: false + ) + + XCTAssertTrue(provider.swapped, "the fixture never fired the swap") + XCTAssertTrue( + FileManager.default.fileExists( + atPath: target.appendingPathComponent("stranger.bin").path + ), + "the stranger installed during the measurement was DELETED — the " + + "binding was taken after the measure and recorded it" + ) + XCTAssertTrue( + report.entries.isEmpty, + "reported bytes for a tree it never deleted: \(report.entries)" + ) + XCTAssertEqual(report.errors.count, 1, "\(report.errors)") + } + + /// Plants a stranger at a GHOST target's name the moment the pre-measure + /// bind has answered "absent" — the window the `??` fallback reopens. + private final class GhostThenStrangerProvider: FileSystemIdentityProvider { + var target: URL! + private var armed = false + private(set) var sawAbsent = false + private(set) var planted = false + + func arm() { armed = true } + + override func probeChild( + inDirectory descriptor: Int32, named name: String, + logical: @autoclosure () -> URL + ) -> ChildProbe { + let answer = super.probeChild( + inDirectory: descriptor, named: name, logical: logical() + ) + guard armed, name == target.lastPathComponent else { return answer } + if case .absent = answer { + sawAbsent = true + } else if sawAbsent, !planted { + // The fallback's own read — already too late in the shipped + // shape, which is the point. + } + if sawAbsent, !planted { + planted = true + try? FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try? Data("stranger".utf8).write( + to: target.appendingPathComponent("stranger.bin") + ) + } + return answer + } + } + + /// **A GHOST LEAF LEAVES THE MEASURE→BIND WINDOW WIDE OPEN** + /// (PR #461 merge gate r4, P4). + /// + /// The r2 hoist binds the leaf before the measurement — but only when the + /// leaf BINDS. When the target is absent at that moment (a ghost), the + /// `??` fallback re-reads at the original point, and if an object has + /// appeared in between it binds THAT one: the far-side proof then + /// succeeds against the newcomer and destroys it, reporting success. The + /// comment at the fallback claimed the original read "stands exactly + /// where it always did — same call, same point, same failure"; the third + /// clause was false, because that read can now SUCCEED on a different + /// object. + /// + /// Nothing was measured — the ghost measures empty — so the report does + /// not even have bytes to be wrong about. It simply deletes a stranger + /// and calls it a success. Pre-existing rather than introduced by the + /// hoist, and disclosed nowhere. + func testAStrangerArrivingAtAGhostTargetIsNeverDeleted() async throws { + let base = try makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let container = base.appendingPathComponent("fixture-container") + let project = container.appendingPathComponent("proj") + let target = project.appendingPathComponent("victim-item") + try FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try writeFile(target.appendingPathComponent("payload.bin"), bytes: 4096) + + let provider = GhostThenStrangerProvider() + provider.target = target + + let cleaner = CacheCleaner( + home: base, containerRoots: [container], + containerSnapshot: sessionSnapshot( + of: [container], provider: provider + ), + provider: provider + ) + // The target is gone by the time cleaning starts: scanned, then + // removed by someone else. This is the ghost the arm is written for. + try FileManager.default.removeItem(at: target) + provider.arm() + + let report = await cleaner.clean( + items: [makeRemoveItem(origin: container, target: target)], + moveToTrash: false + ) + + XCTAssertTrue(provider.planted, "the fixture never planted a stranger") + XCTAssertTrue( + FileManager.default.fileExists( + atPath: target.appendingPathComponent("stranger.bin").path + ), + "a stranger that arrived at a GHOST target's name was DELETED — " + + "nothing ever inspected it, and nothing measured it" + ) + XCTAssertTrue( + report.entries.isEmpty, + "reported SUCCESS for an object that arrived after the " + + "measurement: \(report.entries)" + ) + XCTAssertEqual(report.errors.count, 1, "\(report.errors)") + } + + /// CONTROL for the item-mode cell: identical fixture and provider, + /// never armed — success, so the refusal above is the swap's and only + /// the swap's. + func testItemModeNilProbeSwapFixtureUnarmedControlCleans() async throws { + let base = try makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let container = base.appendingPathComponent("fixture-container") + let project = container.appendingPathComponent("proj") + let target = project.appendingPathComponent("victim-item") + try FileManager.default.createDirectory( + at: target, withIntermediateDirectories: true + ) + try writeFile(target.appendingPathComponent("payload.bin"), bytes: 4096) + + let provider = ItemNilProbeSwapProvider() + provider.target = target + provider.stash = base.appendingPathComponent("stash") + // NOT armed. + + let cleaner = CacheCleaner( + home: base, containerRoots: [container], + containerSnapshot: sessionSnapshot( + of: [container], provider: provider + ), + provider: provider + ) + let report = await cleaner.clean( + items: [makeRemoveItem(origin: container, target: target)], + moveToTrash: false + ) + + XCTAssertFalse(provider.swapped) + XCTAssertTrue(report.errors.isEmpty, "\(report.errors)") + XCTAssertEqual(report.entries.count, 1) + XCTAssertFalse( + FileManager.default.fileExists(atPath: target.path), + "the target is deleted on the unarmed control" + ) + } } diff --git a/Tests/CacheoutTests/CacheoutViewModelTests.swift b/Tests/CacheoutTests/CacheoutViewModelTests.swift index b40e92ef..47f8c06a 100644 --- a/Tests/CacheoutTests/CacheoutViewModelTests.swift +++ b/Tests/CacheoutTests/CacheoutViewModelTests.swift @@ -1418,6 +1418,176 @@ final class CacheoutViewModelTests: XCTestCase { XCTAssertEqual(issue.kind, .scanDidNotFinish) } + /// A provider whose `identity(of:)` for ONE root blocks the way an + /// `lstat` against a hung mount does — the fn-4.19 fixture. The root it + /// wedges is a plain directory, NOT a mount point, so the + /// `mountPointPaths()` preflight passes it through to the lstat: this is + /// the "root INSIDE a hung mount" shape, the one the kernel-table skip + /// can never see. It records the thread the block landed on, so the cell + /// can assert the capture left the main thread as well as the bound. + private final class HungRootIdentityProvider: FileSystemIdentityProvider, + @unchecked Sendable + { + private let lock = NSLock() + /// The wedge is RELEASABLE, not a fixed sleep: the losing capture's + /// thread stays parked past the cell's assertions by design (the + /// disclosed abandonment residual), and a fixed multi-second sleep + /// left that parked worker leaking into whichever cell ran next — + /// observed as `testScanIsNotParkedByItsOwnDiskInfoPreamble…` going + /// red only when scheduled inside this cell's leftover window. The + /// cell releases it on the way out; the 10 s timeout is a fallback + /// so no mutation can turn this cell into a suite hang. + private let wedge = DispatchSemaphore(value: 0) + private var hungPath: String? + private var recordedMainThread: Bool? + + func arm(root: URL) { + lock.lock() + defer { lock.unlock() } + hungPath = root.path + } + + func disarm() { + lock.lock() + defer { lock.unlock() } + hungPath = nil + } + + func releaseWedge() { + for _ in 0..<8 { wedge.signal() } + } + + /// `true`/`false` once the wedge fired; nil if it never did. + var blockedOnMainThread: Bool? { + lock.lock() + defer { lock.unlock() } + return recordedMainThread + } + + override func identity(of url: URL) -> Identity? { + lock.lock() + let isHung = url.path == hungPath + if isHung { recordedMainThread = Thread.isMainThread } + lock.unlock() + if isHung { + // NOT `Task.sleep`: an lstat against a dead mount blocks the + // THREAD, and so must the simulation. + _ = wedge.wait(timeout: .now() + 10) + } + return super.identity(of: url) + } + } + + /// **fn-4.19, END TO END THROUGH PRODUCTION.** The container-identity + /// capture used to run synchronously on the main thread before any + /// bound existed: a hung mount under a session root froze the app, + /// unbounded and unreported (measured at PR #460 r12: a 6 s blocking + /// `identity(of:)` gave a 6.03 s `scan` with `isMainThread == true`). + /// + /// Now: the scan returns on `captureDeadline` (not the wedge); EVERY + /// selected scanner — the one whose root wedged AND the healthy one, + /// because nothing ran — gets a `.scanDidNotFinish` row whose detail + /// names the CAPTURE (which refusal, not just that one fired); the block + /// lands OFF the main thread; the in-progress guard is released; nothing + /// is adopted; and a re-scan after the volume answers succeeds — the + /// remedy the label names is real, so this is a bound, not a + /// deterministic strand. + /// + /// MUTATION (proved red, fn-4 round 2): (a) restore the synchronous + /// unbounded `ContainerSnapshot.capture` call in `scanValidatedSession` + /// and this cell reds on the elapsed-time assertion (the scan takes the + /// wedge); (b) make the timed-out branch finish the stream WITHOUT + /// yielding the per-scanner rows — the silent-skip erasure — and it reds + /// on the missing-issue assertion. + @MainActor + func testAHungMountUnderASessionRootIsBoundedReportedAndOffMain() + async throws + { + let hungRoot = base + .appendingPathComponent("inside-hung-mount") + .appendingPathComponent("cache-root") + try fm.createDirectory(at: hungRoot, withIntermediateDirectories: true) + let okOutcome = ScanOutcome( + items: [perItem(scanner: "ok", id: "o1", bytes: 5000)], errors: [] + ) + let provider = HungRootIdentityProvider() + let runtime = try makeRuntime( + [ + fixtureScanner("ok") { okOutcome }, + FixtureScanner( + id: "victim", trustedContainerRoots: [hungRoot] + ) { + ScanOutcome(items: [], errors: []) + }, + ], + provider: provider, + sessionBounds: ScanSessionBounds( + eventDeadline: .milliseconds(500), + producerWindDownGrace: .milliseconds(50), + captureDeadline: .milliseconds(200) + ) + ) + let viewModel = CacheoutViewModel(runtime: runtime) + // Armed AFTER construction: registration-time reads are not the + // capture, and must not be what the cell wedges. Released on every + // exit so the abandoned capture thread cannot leak into the next + // cell. + provider.arm(root: hungRoot) + defer { provider.releaseWedge() } + + let started = Date() + await viewModel.scan(trigger: .automatic) + let elapsed = Date().timeIntervalSince(started) + + // (1) BOUNDED: the scan returned on the capture deadline (200 ms), + // not the wedge (a semaphore this cell has not yet released, capped + // at 10 s so a mutated run fails rather than hangs). + XCTAssertLessThan( + elapsed, 2.0, + "the scan must return on captureDeadline, not on the hung " + + "lstat: \(elapsed) s" + ) + // (2) OFF THE MAIN THREAD: the wedge fired, and not on main. + XCTAssertEqual( + provider.blockedOnMainThread, false, + "the capture's lstat must have left the main thread " + + "(nil = never blocked, true = still on main)" + ) + // (3) REPORTED, NOT SWALLOWED — for EVERY selected scanner, since + // nothing ran; a silent skip of a container root is the erasure + // class this project refuses. And WHICH refusal: the capture's, not + // the walk's. + for id in ["ok", "victim"] { + let issue = try XCTUnwrap( + viewModel.malformedIssuesByScannerID[id], + "scanner \(id) must carry the capture-expiry row" + ) + XCTAssertEqual(issue.kind, .scanDidNotFinish) + XCTAssertNil(issue.url, "a NON-filesystem kind never invents a path") + XCTAssertTrue( + issue.detail.contains("container-identity capture"), + issue.detail + ) + } + // (4) NOTHING PUBLISHED, NOTHING ADOPTED, GUARD RELEASED. + XCTAssertEqual(viewModel.items(forScanner: "ok"), []) + XCTAssertFalse(viewModel.hasScanned, + "a capture-expired first scan is not a completed scan") + XCTAssertFalse(viewModel.isAnyScanInProgress, + "the scan guard must not survive the capture bound") + + // (5) A RETRY CAN DIFFER — the volume "answers" (disarm) and the + // next scan runs to completion. This is also the cell's CONTROL: + // were the fixture refusing for its own reasons, this scan would + // still carry the rows and fail here. + provider.disarm() + await viewModel.scan(trigger: .automatic) + XCTAssertNil(viewModel.malformedIssuesByScannerID["ok"]) + XCTAssertNil(viewModel.malformedIssuesByScannerID["victim"]) + XCTAssertEqual(viewModel.items(forScanner: "ok").map(\.id), ["o1"]) + XCTAssertTrue(viewModel.hasScanned) + } + /// THE BOUND MUST FIRE WHEN THE COOPERATIVE POOL IS STARVED — i.e. in /// exactly the wedge class it exists for (PR #460 codex r13, B). /// @@ -1611,7 +1781,7 @@ final class CacheoutViewModelTests: XCTestCase { // `.utility` rather than this cell's, and so the starvation below // cannot reach the MainActor the test itself runs on. let consumer = Task.detached(priority: .utility) { - let session = runtime.scanValidatedSession( + let session = await runtime.scanValidatedSession( context: ScanContext(trigger: .automatic) ) for await _ in session.events {} @@ -1902,6 +2072,144 @@ final class CacheoutViewModelTests: XCTestCase { XCTAssertGreaterThan(disk.totalSpace, 0) } + // MARK: - Docker prune bound (fn-4.20) + + /// **fn-4.20: A WEDGED DOCKER CLI MUST NOT LATCH THE BUTTON.** The old + /// body awaited `readToEnd()` + a bare `waitUntilExit()` unbounded, so + /// a child that neither exited nor closed its pipe held a cooperative + /// worker and left `isDockerPruning` true for the life of the app. + /// + /// The fixture child (`sleep 30`) is exactly that shape: it keeps its + /// stdout open, so `readToEnd()` parks — the task-spec's "can the read + /// park too?" answered by construction, not only in a comment — and it + /// ignores no signals, so the expiry's SIGTERM also cleans the fixture + /// up. With a 300 ms budget the prune must return promptly, release the + /// button, and REPORT the expiry, not swallow it. + /// + /// MUTATION (proved red, fn-4 round 2): drop the `ScanSessionClock` + /// timer from `dockerPrune` (never settle `.timedOut`) and this cell + /// reds on the elapsed assertion — the await rides the child's full 30 s + /// instead of the budget. Restoring the bare `waitUntilExit()` INSIDE + /// the raced task is caught by the OTHER named cell, the + /// `DocumentedContractTests` grep gate: the outer race would still + /// bound it, which is precisely why the gate exists as its own cell. + @MainActor + func testDockerPruneExpiresReportsAndReleasesTheButton() async throws { + let runtime = try makeRuntime([]) + let viewModel = CacheoutViewModel(runtime: runtime) + viewModel.dockerPruneBudget = .milliseconds(300) + viewModel.dockerPruneCommand = ["sh", "-c", "sleep 30"] + + let started = Date() + await viewModel.dockerPrune() + let elapsed = Date().timeIntervalSince(started) + + XCTAssertLessThan( + elapsed, 5.0, + "dockerPrune must return on its budget, not on the wedged " + + "child: \(elapsed) s" + ) + XCTAssertFalse( + viewModel.isDockerPruning, + "the button must be released on the expiry path" + ) + let result = try XCTUnwrap( + viewModel.lastDockerPruneResult, + "the expiry must be reported, not swallowed" + ) + XCTAssertTrue(result.contains("did not finish"), result) + } + + /// THE OTHER TIMEOUT ARM, PINNED (PR #461 merge gate r3, P5). + /// + /// The r2 disclosure at this branch's site claimed both wordings were + /// uncoverable and that pinning them would need production API hoisted + /// for a test to read. Both halves were false: the sibling cell above + /// already pins the `didStart` arm by reading `lastDockerPruneResult`, + /// published state three cells in this file read. This one pins the + /// other arm the same way, so the two messages cannot be swapped, and + /// the false disclosure is retired. + /// + /// The arm requires the timer to win before the detached task is even + /// scheduled, which a zero budget makes the ordinary case. + @MainActor + func testAPruneThatNeverStartedSaysSoAndClaimsNothingWasStopped() + async throws + { + var startedReports = 0 + var neverStartedReports = 0 + for _ in 0..<12 { + let runtime = try makeRuntime([]) + let viewModel = CacheoutViewModel(runtime: runtime) + viewModel.dockerPruneBudget = .zero + viewModel.dockerPruneCommand = ["sh", "-c", "sleep 30"] + await viewModel.dockerPrune() + let result = viewModel.lastDockerPruneResult ?? "" + if result.contains("did not start") { + neverStartedReports += 1 + XCTAssertTrue( + result.contains("nothing was run"), + "an unstarted prune must not claim anything was stopped: " + + result + ) + XCTAssertFalse( + result.contains("asked it to stop"), + "the two arms must not share vocabulary: \(result)" + ) + } else if result.contains("did not finish") { + startedReports += 1 + XCTAssertTrue(result.contains("asked it to stop"), result) + } + } + XCTAssertGreaterThan( + neverStartedReports, 0, + "the never-started arm was not reached in 12 zero-budget rounds " + + "(\(startedReports) rounds started) — this cell pins nothing" + ) + } + + /// CONTROL for the cell above, and the success path's parser: a child + /// that prints docker's reclaimed line and exits must be read to EOF + /// and reported through the same seams — so the expiry cell's refusal + /// is evidenced to come from the wedge, not from the seams themselves. + @MainActor + func testDockerPruneSuccessStillParsesTheReclaimedLine() async throws { + let runtime = try makeRuntime([]) + let viewModel = CacheoutViewModel(runtime: runtime) + viewModel.dockerPruneBudget = .seconds(10) + viewModel.dockerPruneCommand = [ + "sh", "-c", "echo 'Total reclaimed space: 1.234GB'", + ] + + await viewModel.dockerPrune() + + XCTAssertFalse(viewModel.isDockerPruning) + XCTAssertEqual( + viewModel.lastDockerPruneResult, "Total reclaimed space: 1.234GB" + ) + } + + /// AND THE FAILURE PATH IS STILL A COMPLETED FAILURE, NOT A TIMEOUT — + /// the two travel different arms of `DockerPruneOutcome` and must not + /// be able to trade places: a child that exits non-zero within the + /// budget reports the daemon guidance, never the expiry sentence. + @MainActor + func testDockerPruneCompletedFailureIsNotReportedAsExpiry() async throws { + let runtime = try makeRuntime([]) + let viewModel = CacheoutViewModel(runtime: runtime) + viewModel.dockerPruneBudget = .seconds(10) + viewModel.dockerPruneCommand = [ + "sh", "-c", "echo 'Cannot connect to the Docker daemon' >&2; exit 1", + ] + + await viewModel.dockerPrune() + + XCTAssertFalse(viewModel.isDockerPruning) + XCTAssertEqual( + viewModel.lastDockerPruneResult, "Docker must be running to prune" + ) + } + /// EXACTLY ONE EVENT PER SCANNER, EVER — the watchdog's report and the /// real outcome must be exclusive in BOTH directions (PR #460 codex r13, /// C). @@ -1992,7 +2300,7 @@ final class CacheoutViewModelTests: XCTestCase { producerWindDownGrace: .milliseconds(50) ) ) - let session = runtime.scanValidatedSession( + let session = await runtime.scanValidatedSession( context: ScanContext(trigger: .automatic) ) // THE RAW STREAM, not the view model's reduction of it: this is diff --git a/Tests/CacheoutTests/CategoryScannerTests.swift b/Tests/CacheoutTests/CategoryScannerTests.swift index 887ec935..451d5df3 100644 --- a/Tests/CacheoutTests/CategoryScannerTests.swift +++ b/Tests/CacheoutTests/CategoryScannerTests.swift @@ -2037,7 +2037,7 @@ final class CategoryScannerTests: XCTestCase { let runtime = try makeRuntime(scanners: [fast, gated], home: home) var events: [ValidatedScannerEvent] = [] - for await event in runtime.scanValidated( + for await event in await runtime.scanValidated( context: ScanContext(trigger: .automatic) ) { events.append(event) diff --git a/Tests/CacheoutTests/DevRootsSettingsTests.swift b/Tests/CacheoutTests/DevRootsSettingsTests.swift index 84115399..082b9420 100644 --- a/Tests/CacheoutTests/DevRootsSettingsTests.swift +++ b/Tests/CacheoutTests/DevRootsSettingsTests.swift @@ -17,7 +17,8 @@ import XCTest /// duplicate: `/`, a volume root, `$HOME`, and symlink aliases of each are /// REFUSED inline while `~/Documents` and `~/Documents/dev` are ACCEPTED; /// - `CacheoutViewModel.devRootRows` — the declared list with the -/// `.containerRefused` detail of any policy-rejected persisted root; +/// `.policyRefusedRoot` detail of any policy-rejected persisted root +/// (fn-4.12; `.containerRefused` before that); /// - `ScanIssueRowPresentation` — the visible per-root error row: a denied /// root with its grant-access affordance, a refused configured root, and /// the PATH-LESS `.configInvalid` parse failure (no invented path). @@ -399,13 +400,17 @@ final class DevRootsSettingsTests: XCTestCase { let refused = ScanIssueRowPresentation( issue: ScanIssue( - url: URL(fileURLWithPath: "/"), kind: .containerRefused, + // The kind `DevRootsStore` emits for this detail since + // fn-4.12 — the row and the tooltip agree that the root IS + // configured. + url: URL(fileURLWithPath: "/"), kind: .policyRefusedRoot, detail: "configured dev root refused: …" ), home: fixtureHome ) XCTAssertEqual(refused.location, "/", "the row NAMES the root") - XCTAssertEqual(refused.label, "not a configured search root") + XCTAssertEqual(refused.label, + "refused by the search-root safety policy") XCTAssertFalse(refused.showsSettingsLink, "no settings link that cannot help") @@ -490,12 +495,11 @@ final class DevRootsSettingsTests: XCTestCase { /// /// The two kinds are asserted TOGETHER and must differ in both halves: /// the mounted row states the condition AND its remedy, and the refusal - /// row is left exactly as it was, because `DevRootsStore` (a - /// policy-rejected persisted root) and `ProjectTreeWalker` (a scan-time - /// admission refusal) still render through it. `EphemeralTempScanner`'s - /// own `admitSearchRoot` catch was the THIRD such producer until PR #459 - /// codex r13 moved it to `.policyRefusedRoot`; the cell below is that - /// half. + /// row keeps its label for the ONE producer left on it (fn-4.12): + /// `GitWorktreeScanner`'s worktree-outside-every-root arm, where "not a + /// configured search root" is exactly the condition. `DevRootsStore` + /// and `ProjectTreeWalker` moved to `.policyRefusedRoot` in fn-4.12, + /// the same move `EphemeralTempScanner` made in PR #459 codex r13. func testAMountedRootRowStatesTheConditionAndTheRemedyRefusalRowUnchanged() throws { let root = URL(fileURLWithPath: "/private/tmp") let mounted = ScanIssueRowPresentation( @@ -524,8 +528,9 @@ final class DevRootsSettingsTests: XCTestCase { XCTAssertFalse(mounted.showsSettingsLink, "Full Disk Access cannot unmount a volume") - // The OTHER producers of `.containerRefused` are untouched: same - // kind, same fixed label as before this change. + // `.containerRefused` keeps its fixed label for its remaining + // producer (fn-4.12: `GitWorktreeScanner`'s outside-every-root arm, + // where the sentence IS the condition). let refusal = ScanIssueRowPresentation( issue: ScanIssue( url: root, kind: .containerRefused, detail: "refused: …" @@ -591,10 +596,12 @@ final class DevRootsSettingsTests: XCTestCase { /// /// Both halves are pinned in both directions: the new labels are exact, /// and the two OLD kinds still render exactly what they rendered before - /// — `.symlinkRoot` is still produced by four other call sites - /// (`EphemeralTempRoots`, `DevRootsStore`, `ProjectTreeWalker`, - /// `OrphanedCachesScanner`) and `.containerRefused` by two - /// (`DevRootsStore`, `ProjectTreeWalker`). + /// — `.symlinkRoot` still means "a symlink stands there" at every + /// producer (fn-4.12 narrowed `ProjectTreeWalker` and + /// `OrphanedCachesScanner` to symlinks only, as PR #459 did the temp + /// scanner; `EphemeralTempRoots`' and `DevRootsStore`'s alias arms were + /// symlink-only by construction) and `.containerRefused` keeps its one + /// remaining producer (`GitWorktreeScanner`'s outside-every-root arm). func testNonDirectoryAndPolicyRefusedRootsGetTheirOwnVisibleSentences() throws { let root = URL(fileURLWithPath: "/private/tmp") @@ -664,6 +671,55 @@ final class DevRootsSettingsTests: XCTestCase { "two conditions, two sentences") } + /// THE fn-4.12 SIBLING on the SAME derivation: the git-worktree + /// containment refusals' own kind. Pinned in both directions — the new + /// label is exact and claims no remedy, and `.containerRefused` (still + /// carried by the outside-every-root arm) keeps its old sentence, + /// distinct from this one. + func testMutationScopeRefusedGetsItsOwnVisibleSentence() throws { + let worktree = fixtureHome + .appendingPathComponent("Documents/GitHub/wt-feature") + + let scopeRefused = ScanIssueRowPresentation( + issue: ScanIssue( + url: worktree, kind: .mutationScopeRefused, + detail: "worktree 'wt-feature' is inside a configured dev " + + "root but the parent repository is outside every " + + "declared root — git mutates the parent repository's " + + "admin data, so the whole mutation scope must share " + + "one declared root" + ), + home: fixtureHome + ) + XCTAssertEqual(scopeRefused.location, "~/Documents/GitHub/wt-feature", + "the row NAMES the withheld candidate") + XCTAssertEqual( + scopeRefused.label, + "git cleanup is not contained in one dev root — not offered" + ) + XCTAssertFalse( + scopeRefused.label.contains("not a configured search root"), + "the candidate IS inside a configured root — asserting the " + + "opposite was the defect" + ) + XCTAssertFalse(scopeRefused.showsSettingsLink, + "no settings link that cannot help") + XCTAssertEqual(ScanIssue.Kind.mutationScopeRefused.wireString, + "mutation_scope_refused") + + let outsideEveryRoot = ScanIssueRowPresentation( + issue: ScanIssue( + url: worktree, kind: .containerRefused, + detail: "registered worktree is outside every configured " + + "dev root" + ), + home: fixtureHome + ) + XCTAssertEqual(outsideEveryRoot.label, "not a configured search root") + XCTAssertNotEqual(outsideEveryRoot.label, scopeRefused.label, + "two conditions, two sentences") + } + /// END TO END through the REAL scanner: a policy-rejected persisted root /// and a corrupt stored value both reach the GUI section as VISIBLE /// issue rows — never a zero-byte item row, never an empty section. @@ -685,8 +741,10 @@ final class DevRootsSettingsTests: XCTestCase { home: fixtureHome, devRoots: DevRootsResolution( keptRoots: resolution.keptRoots, + // The shape `DevRootsStore` actually emits since fn-4.12 — + // `.policyRefusedRoot`, because the root IS configured. issues: resolution.issues + [ScanIssue( - url: URL(fileURLWithPath: "/"), kind: .containerRefused, + url: URL(fileURLWithPath: "/"), kind: .policyRefusedRoot, detail: "configured dev root refused: filesystem root" )] ), @@ -709,14 +767,15 @@ final class DevRootsSettingsTests: XCTestCase { let kinds = section.issues.map(\.kind) XCTAssertTrue(kinds.contains(.configInvalid), "\(kinds)") - XCTAssertTrue(kinds.contains(.containerRefused), "\(kinds)") + XCTAssertTrue(kinds.contains(.policyRefusedRoot), "\(kinds)") let rows = section.issues.map { ScanIssueRowPresentation(issue: $0, home: fixtureHome) } XCTAssertTrue(rows.contains { $0.location == "Scanner output" }) XCTAssertTrue(rows.contains { - $0.location == "/" && $0.label == "not a configured search root" - }) + $0.location == "/" + && $0.label == "refused by the search-root safety policy" + }, "the row states the TRUE condition (fn-4.12)") } } diff --git a/Tests/CacheoutTests/DevRootsStoreTests.swift b/Tests/CacheoutTests/DevRootsStoreTests.swift index eaa4b491..6b1d0d93 100644 --- a/Tests/CacheoutTests/DevRootsStoreTests.swift +++ b/Tests/CacheoutTests/DevRootsStoreTests.swift @@ -118,7 +118,7 @@ final class DevRootsStoreTests: XCTestCase { // MARK: - R16: store-layer attack fixtures - func testPersistedFilesystemRootExcludedWithFrozenContainerRefused() throws { + func testPersistedFilesystemRootExcludedWithPolicyRefusedRoot() throws { let original: Any = ["/"] persist(original) @@ -128,9 +128,11 @@ final class DevRootsStoreTests: XCTestCase { "a persisted `/` must never be registered or walked") XCTAssertEqual(resolution.issues.count, 1) let issue = try XCTUnwrap(resolution.issues.first) - XCTAssertEqual(issue.kind, .containerRefused, - "policy-rejected roots keep the FROZEN kind") - XCTAssertEqual(issue.kind.wireString, "container_refused") + XCTAssertEqual(issue.kind, .policyRefusedRoot, + "a policy-rejected root IS configured (fn-4.12): the " + + "kind-derived GUI label under `.containerRefused` " + + "said the opposite of the detail") + XCTAssertEqual(issue.kind.wireString, "policy_refused_root") XCTAssertEqual(issue.url?.path, "/", "a policy rejection carries its offending path honestly") assertStoredUnchanged(original) @@ -146,8 +148,9 @@ final class DevRootsStoreTests: XCTestCase { let resolution = makeStore().effectiveRoots(home: fixtureHome) XCTAssertEqual(resolution.keptRoots, [], - "canonicalize-before-check: an alias of / is /") - XCTAssertEqual(resolution.issues.map(\.kind), [.containerRefused]) + "an alias of / names / — read from the link's own " + + "content since fn-4.11, never by resolving it") + XCTAssertEqual(resolution.issues.map(\.kind), [.policyRefusedRoot]) XCTAssertEqual(resolution.issues.first?.url?.path, alias.path, "the issue names the DECLARED offending spelling") } @@ -163,7 +166,7 @@ final class DevRootsStoreTests: XCTestCase { .effectiveRoots(home: fixtureHome) XCTAssertEqual(resolution.keptRoots, []) - XCTAssertEqual(resolution.issues.map(\.kind), [.containerRefused]) + XCTAssertEqual(resolution.issues.map(\.kind), [.policyRefusedRoot]) } func testPersistedHomeExcludedInDirectAndAliasSpellings() throws { @@ -174,10 +177,11 @@ final class DevRootsStoreTests: XCTestCase { let resolution = makeStore().effectiveRoots(home: fixtureHome) XCTAssertEqual(resolution.keptRoots, [], - "$HOME must be excluded by inode identity in EVERY " - + "spelling") + "$HOME must be excluded in EVERY spelling — by inode " + + "identity when spelled directly, by the link's own " + + "content when aliased (fn-4.11)") XCTAssertEqual(resolution.issues.map(\.kind), - [.containerRefused, .containerRefused]) + [.policyRefusedRoot, .policyRefusedRoot]) XCTAssertEqual(resolution.issues.map { $0.url?.path }, [fixtureHome.path, alias.path]) } @@ -207,7 +211,7 @@ final class DevRootsStoreTests: XCTestCase { XCTAssertEqual(resolution.keptRoots.map(\.path), [good.path], "a dangerous string in a VALID array is rejected " + "individually; the rest of the list survives") - XCTAssertEqual(resolution.issues.map(\.kind), [.containerRefused]) + XCTAssertEqual(resolution.issues.map(\.kind), [.policyRefusedRoot]) } // MARK: - R8/R16: guarded parsing + mixed-corrupt semantics @@ -245,7 +249,7 @@ final class DevRootsStoreTests: XCTestCase { // THE pinned attack cell: [true, "/"]. The array shape is invalid, // so the WHOLE value is a parse failure — seeds in effect, ONE // config_invalid issue, and the embedded "/" never reaches the kept - // set (the visible parse issue covers it; no containerRefused row + // set (the visible parse issue covers it; no per-root refusal row // is fabricated for a value that was never accepted as config). let original: Any = [true, "/"] persist(original) @@ -420,6 +424,144 @@ final class DevRootsStoreTests: XCTestCase { XCTAssertEqual(resolution.issues, []) } + // MARK: - fn-4.11: resolution never names a symlink root's destination + + /// FAILS THE TEST on any call naming the forbidden DESTINATION or + /// anything below it, and on any LEAF-FOLLOWING operation on a listed + /// alias spelling (`canonicalize`/`realPath`/`isMountPoint` — `realpath(3)` + /// resolves the link, `statfs(2)` follows it). `lstat`/`readlink`-class + /// calls on the alias itself stay legal: they read the link's own entry + /// and content, never the destination. + private final class DestinationForbiddingProvider: + FileSystemIdentityProvider, @unchecked Sendable + { + var forbiddenDestination = "" + var aliasSpellings: Set = [] + private let fail: (String) -> Void + + init(fail: @escaping (String) -> Void) { + self.fail = fail + super.init() + } + + private func forbid(_ method: String, _ path: String) { + guard !forbiddenDestination.isEmpty, + path == forbiddenDestination + || path.hasPrefix(forbiddenDestination + "/") + else { return } + fail("\(method) made first contact with the destination: \(path)") + } + private func forbidFollow(_ method: String, _ path: String) { + forbid(method, path) + if aliasSpellings.contains(path) { + fail("\(method) is a leaf-following resolution of the alias " + + "spelling itself: \(path)") + } + } + + override func realPath(of path: String) -> String? { + forbidFollow("realPath", path) + let out = super.realPath(of: path) + if let out { forbid("realPath output", out) } + return out + } + override func canonicalize(_ url: URL) -> URL { + forbidFollow("canonicalize", url.path) + return super.canonicalize(url) + } + override func isMountPoint(_ url: URL) -> Bool { + forbidFollow("isMountPoint", url.path) + return super.isMountPoint(url) + } + override func probeKind(of url: URL) -> KindProbe { + forbid("probeKind", url.path) + return super.probeKind(of: url) + } + override func identity(of url: URL) -> Identity? { + forbid("identity", url.path) + return super.identity(of: url) + } + override func symlinkTarget(of url: URL) -> String? { + forbid("symlinkTarget", url.path) + return super.symlinkTarget(of: url) + } + override func canEnumerateDirectory(_ url: URL) -> Bool { + forbidFollow("canEnumerateDirectory", url.path) + return super.canEnumerateDirectory(url) + } + override func ownerProbe(of url: URL) -> OwnerProbe { + forbid("ownerProbe", url.path) + return super.ownerProbe(of: url) + } + override func leafMetadata(of url: URL) -> LeafMetadata? { + forbid("leafMetadata", url.path) + return super.leafMetadata(of: url) + } + override func linkCount(of url: URL) -> UInt64? { + forbid("linkCount", url.path) + return super.linkCount(of: url) + } + } + + /// fn-4.11: `effectiveRoots` runs synchronously inside runtime + /// construction on the main thread, and a persisted dev root is a path + /// the app does not control — a same-UID process can aim it at an + /// unresponsive mounted volume. The old shape canonicalized every root + /// (policy first, probe pair second), so a symlink root's DESTINATION + /// was named — and could block launch — before any window existed. Both + /// alias shapes are kept verbatim with ZERO destination contact. + func testResolutionNeverContactsASymlinkDevRootsDestination() throws { + let destination = base.appendingPathComponent("quarantined-dest") + try mkdir(destination) + let alias = base.appendingPathComponent("alias-to-dest") + try fm.createSymbolicLink(at: alias, withDestinationURL: destination) + let dangling = base.appendingPathComponent("dangling-alias") + try fm.createSymbolicLink( + at: dangling, + withDestinationURL: destination.appendingPathComponent("gone") + ) + persist([alias.path, dangling.path]) + + let provider = DestinationForbiddingProvider(fail: { XCTFail($0) }) + provider.forbiddenDestination = destination.path + provider.aliasSpellings = [alias.path, dangling.path] + + let resolution = makeStore(provider: provider) + .effectiveRoots(home: fixtureHome) + XCTAssertEqual(resolution.keptRoots.map(\.path), + [alias.path, dangling.path], + "nothing declared covers these leaves — they pass " + + "through verbatim for the walk-time gate to class") + XCTAssertEqual(resolution.issues, []) + } + + /// fn-4.11's recorded residual, pinned the way fn-6 pins its own + /// (`testAliasWrittenThroughAThirdSpellingKeepsBothRootsRatherThanGuessing`): + /// the drop decision compares the link's CONTENT against spellings the + /// resolution already holds — it never resolves the destination, so a + /// target written through a spelling nobody declared matches nothing and + /// BOTH roots are kept rather than guessed about. The alias stays + /// fail-closed in its own right: never walkable, never admissible. + func testAliasNamingItsTargetThroughAThirdSpellingKeepsBothRoots() throws { + let real = base.appendingPathComponent("real-root") + try mkdir(real) + let alias = base.appendingPathComponent("alias-root") + // A case-variant spelling: on the default case-insensitive volume + // the link RESOLVES onto the real root (the old full-resolution key + // dropped it); lexically it matches nothing anyone declared. + try fm.createSymbolicLink( + at: alias, + withDestinationURL: base.appendingPathComponent("REAL-ROOT") + ) + persist([alias.path, real.path]) + + let resolution = makeStore().effectiveRoots(home: fixtureHome) + XCTAssertEqual(resolution.keptRoots.map(\.path), + [alias.path, real.path], + "a name compare must keep both rather than guess") + XCTAssertEqual(resolution.issues, []) + } + func testNestedRealRootsBothKeptNoKeepAncestorDrop() throws { // D7: path ancestry is NEVER traversal equivalence — an ancestor's // depth-8 walk does not reach what a nested root's own depth-8 @@ -505,7 +647,7 @@ final class DevRootsStoreTests: XCTestCase { XCTAssertEqual(resolution.keptRoots.map(\.path), [good.path], "the CLI replacement path runs the SAME policy") - XCTAssertEqual(resolution.issues.map(\.kind), [.containerRefused]) + XCTAssertEqual(resolution.issues.map(\.kind), [.policyRefusedRoot]) XCTAssertNil(storedValue, "a per-invocation replacement is NEVER persisted") } diff --git a/Tests/CacheoutTests/DevTreeWalkMeasurementTests.swift b/Tests/CacheoutTests/DevTreeWalkMeasurementTests.swift new file mode 100644 index 00000000..c1db82d9 --- /dev/null +++ b/Tests/CacheoutTests/DevTreeWalkMeasurementTests.swift @@ -0,0 +1,268 @@ +/// # DevTreeWalkMeasurementTests — fn-4.18's measurement, kept as the record +/// +/// Codex (PR #460, P2) claimed the two dev-root scanners' separate +/// `ProjectTreeWalker` runs cost "nearly double filesystem I/O and latency". +/// The task's first acceptance criterion was MEASURE FIRST, and the +/// measurement CORRECTS the claim, so it is kept executable rather than +/// summarized: +/// +/// On an artifact-bearing tree (two populated node_modules, one populated +/// Rust target, one repository with a worktree — 5772 entries): +/// +/// build WALK 249 probes 0.004 s (its consumer PRUNES matched dirs) +/// build SCAN 5772 probes 0.230 s (walk 249 + sizing census 5523) +/// git SCAN 5772 probes 0.212 s (its walk prunes NOTHING — by +/// design: nested repos are quarry) +/// union WALK 5772 probes 0.132 s (zero consumers = fused reach) +/// +/// The two walks are wildly ASYMMETRIC: the duplicated enumeration is their +/// INTERSECTION, which is the build walk — 249 of the session's 11544 entry +/// probes (2.2%). A fused walk must carry the git walk's unpruned reach and +/// the build scanner still pays its sizing census either way, so the fan-in +/// saves ~2% of entry probes on the tree class these scanners exist for. +/// The claim's true half is the tree with NO artifacts and NO repository: +/// there both walks enumerate everything (496 probes each on an 8-project +/// tree) and fusing halves them — of a cost measured in single-digit +/// milliseconds. +/// +/// The cells below PIN the two facts the correction rests on (the pruned +/// build walk is strictly smaller than the git walk; the git walk equals +/// the zero-consumer union reach) and print fresh figures for the record. +/// See the fn-4.18 disposition note at `GitWorktreeScanner.scan`'s walk +/// step for why the fan-in was recorded rather than built. + +import XCTest +@testable import Cacheout + +private final class ProbeCountingProvider: FileSystemIdentityProvider { + private let lock = NSLock() + private var count = 0 + + var probes: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + func reset() { + lock.lock() + count = 0 + lock.unlock() + } + + override func probeKind( + inDirectory parent: Int32, named name: String, logical url: URL + ) -> DescriptorKindProbe { + lock.lock() + count += 1 + lock.unlock() + return super.probeKind(inDirectory: parent, named: name, logical: url) + } +} + +final class DevTreeWalkMeasurementTests: XCTestCase { + + private var base: URL! + private var home: URL! + private var dev: URL! + private let fm = FileManager.default + + override func setUpWithError() throws { + base = fm.temporaryDirectory + .appendingPathComponent("DevTreeWalkMeasurement-\(UUID().uuidString)") + home = base.appendingPathComponent("home") + dev = base.appendingPathComponent("dev") + try fm.createDirectory(at: home, withIntermediateDirectories: true) + try fm.createDirectory(at: dev, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + if let base { try? fm.removeItem(at: base) } + } + + private func file(_ url: URL, bytes: Int = 512) throws { + try Data(repeating: 0x41, count: bytes).write(to: url) + } + + private func makeNodeProject(named name: String, packages: Int) throws { + let project = dev.appendingPathComponent(name) + let src = project.appendingPathComponent("src") + try fm.createDirectory(at: src, withIntermediateDirectories: true) + try file(project.appendingPathComponent("package.json")) + for i in 0..<40 { try file(src.appendingPathComponent("mod\(i).ts")) } + let nm = project.appendingPathComponent("node_modules") + for p in 0.. DirectorySizer { - DirectorySizer(provider: provider) + DirectorySizer(provider: provider, entryCap: entryCap) + } + + /// `count` EMPTY files under `root` — creation speed matters more than + /// bytes for the cancellation/cap fixtures (the walk stats each entry + /// either way). + private func makeManyEmptyFiles(_ root: URL, count: Int) throws { + try mkdir(root) + for index in 0..