mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 16:23:18 -10:00
* fix(sandbox): a temporary root is refcounted, so one holder's cleanup cannot revoke another's Split out of #829 as an independent fix, per @Vasanthdev2004's review asking for the self-contained pieces to arrive separately. A temporary grant was add-then-remove with no notion of who still needed the root. The second caller to ask for a root already present got a NO-OP undo, and the first caller's cleanup then removed the root out from under it — so a live grant lost its access while its holder still believed it had it. Two read-only tools in the same parallel batch, both blocked on the same directory, is exactly that shape, and read-only tools are precisely the ones the batch runs concurrently. tempReads/tempWrites now count live holders, and the root is only stripped when the last one releases. The release also performs both mutations under ONE hold of the lock. Dropping it between them left a window where the root was still in readRoots but no longer in tempReads: a concurrent AddTemporaryRead landing there reads it as a PERMANENT root, hands its caller a no-op undo, and this call then strips the root — so that caller believes it holds access it has already silently lost. Independent of the remaining #829 work; builds and tests against current main on its own. Origin-Session: local-13d543 | Claude Code | 5 prompts Origin-Snapshot: 6b5eb8ba4e5b Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * fix(sandbox): a read covered by a TEMPORARY write root takes a reference too Found by a self-review of this branch: the refcount closed the defect within reads and within writes, but not across them. AddTemporaryRead returned a no-op undo whenever writeRootCoversLocked said a write root already covered the path, and the comment there claimed that cover was permanent. It is not: writeRootCoversLocked scans extraRoots, and AddTemporaryWrite appends TEMPORARY write roots into that same slice. So a read covered by another holder's temporary write took no reference, and that holder's cleanup silently revoked the reader's access — the very defect the refcount exists to close, reached across the read/write boundary instead of within one side of it. Demonstrated before fixing, and the regression fails without it: ReadRoots before release = [ws, …/outer] ReadRoots after release = [ws] the write holder's cleanup revoked a live read grant on …/outer/inner The read now takes a reference on the covering temporary write root, and the genuinely permanent case — the workspace root, or a session-scoped grant — still returns a no-op undo. The regression also asserts the other direction: once the reader releases too, the root is gone, so the reference does not leak the grant past its last holder. Origin-Session: local-0484ba | Claude Code | 6 prompts Origin-Snapshot: e6d90ff3ce46 Origin-Session: local-13d543 | Claude Code | 5 prompts Origin-Snapshot: 6b5eb8ba4e5b Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * test(sandbox): make the concurrency test concurrent, and portable off macOS Both raised by CodeRabbit. The third finding on this PR — a read covered by a TEMPORARY write root taking no reference — was already fixed in 87a722cb with the regression it asked for; this covers the two that were still live, and both are cases of a test reporting more than it checked. THE CONCURRENCY TEST WAS NOT CONCURRENT. The gate spun until hasReadRoot saw somebody holding the root, which is satisfied by the FIRST holder, then opened the release. The spin wins that race against goroutines still being scheduled: instrumented over 200 runs, 194 peaked at a single simultaneous holder and not one ever reached eight. A test named for concurrent holders was exercising one holder at a time. Against a deliberately broken refcount, where the first release strips the root, it passed 33 times in 40 — it was a flaky test that mostly agreed with broken code. Counting all eight acquisitions makes the overlap real: 200 of 200 runs now peak at eight, and the same broken refcount is caught 40 times in 40. Holders signal the barrier on every path including failure, so a holder that returns early fails the test rather than hanging it. THE HELPER SKIPPED ON WINDOWS. scopeOutsideRoots reached for /Users/Shared and fell back to /var/empty, neither of which exists there, so every test built on it skipped and reported green having asserted nothing. It now uses the home directory, which no platform lists as a default write root, matching what scope_extra_read_test.go already does — and then PROVES the choice against defaultTempWriteRoots rather than assuming it, so a future default root turns these tests into an honest skip instead of a silent no-op. Origin-Session: local-abff1c | Claude Code | 2 prompts Origin-Snapshot: d2f269b81f33 Origin-Session: local-13d543 | Claude Code | 5 prompts Origin-Snapshot: 6b5eb8ba4e5b Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * fix(sandbox): a permanent grant outlives the temporary one, and an undo is per holder Both P1s from @anandh8x, both reproduced before changing anything, and both are the same mistake in different places: treating "something covers this path" as though it answered "and will it still be there afterwards". A PERMANENT GRANT WAS REVOKED BY A TEMPORARY HOLDER. extraRoots holds temporary write roots alongside permanent ones, so Add asked only whether the path was already covered. A session-scoped grant made while a temporary holder happened to cover it recorded nothing at all, and disappeared the moment that holder released. Outliving the request that prompted it is the entire difference between Add and AddTemporaryWrite, and it was not happening. AddRead had it too, including across the boundary: a permanent read covered only by a temporary WRITE root died with that root. Permanent coverage is now looked for first and across every root, so a temporary cover cannot decide the answer when a permanent one is also present. The same root held temporarily is promoted in place — its holders' releases become no-ops, which is what permanence means here. A BROADER temporary root over a narrower permanent grant cannot be fixed by promotion, so the narrower root is recorded in its own right; that is the case he specifically called out, and it is the one a promotion-only fix would have missed. ONE HOLDER'S UNDO IS ONE HOLDER'S REFERENCE. The comment on releaseTemporaryRead claimed each undo is called exactly once and that flooring the count at zero covered the rest. Flooring stops the count going negative; it does nothing about a holder's second call consuming a DIFFERENT holder's reference. With two readers, calling the first's undo twice took the count 2 -> 1 -> 0 and removed a root the second was still using — the exact revocation this refcount exists to prevent, reached through a duplicate call rather than a sibling's cleanup, and a deferred cleanup in a retry path is how a caller does that by accident. Every undo handed out is now wrapped so it drops its own reference at most once, and the comment says what is actually true. Both mutation-checked: unwrapping the undo, and ignoring permanence in Add, each fail the test that covers them. Race detector clean. Origin-Session: local-abff1c | Claude Code | 3 prompts Origin-Snapshot: 07900397c62e Origin-Session: local-13d543 | Claude Code | 5 prompts Origin-Snapshot: 6b5eb8ba4e5b Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * chore(sandbox): re-request an automated review of this head No code change. CodeRabbit's verdict on this PR is pinned to ff85dbcc, a commit that no longer exists on the branch, and a stale changes-requested keeps the PR at mergeStateStatus=BLOCKED exactly as a human one would. It re-reviews on push rather than on request, and repeated @coderabbitai triggers over five days produced nothing, so this is the only lever available without maintainer rights. The cost is that this dismisses @Vasanthdev2004's and @anandh8x's approvals of 4a7063aa. The tree they approved is byte-identical to this one. Origin-Session: local-8cd239 | Claude Code | 6 prompts Origin-Snapshot: 149ab98453ad Origin-Session: local-13d543 | Claude Code | 5 prompts Origin-Snapshot: 6b5eb8ba4e5b Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * test(sandbox): a fixture failure fails, a report cannot deadlock, and AddRead's own branches are covered All raised by CodeRabbit on the full review. Every one verified before changing anything. ## The concurrent test could hang instead of failing failures was buffered at one slot per holder, but each goroutine can report at BOTH hasReadRoot checks — its own grant going missing while held, and again after the release gate. Nothing drains the channel until after wg.Wait(), so the ninth report blocks forever, wg.Done() is never reached and the wait hangs. The test would deadlock in exactly the case it exists to catch: a refcount bug severe enough to fail more than eight of eight holders. Buffered at 2*holders. ## A broken fixture reported itself as a pass TestANarrowerRequestHoldsTheCoveringGrant called t.Skip when os.MkdirAll failed. The directory is the fixture, not a precondition the environment may reasonably withhold, and the sibling test above it already uses t.Fatal for the same call. ## Two tests assumed something they never checked Both tests in scope_extra_read_test.go place their grant under $HOME precisely because NewScope seeds /tmp and $TMPDIR as write roots — a grant that collapsed into one of those would be writable for a reason unrelated to the property under test. $HOME is normally clear of them, but a sandboxed or CI environment that points HOME at a temp directory turns both tests into something that proves nothing while still reporting PASS. homeGrantOutsideTempRoots now builds the fixture for both and skips with the reason when the assumption does not hold. Verified by running with HOME set to a temp directory: both tests SKIP naming the covering root, where before they passed. The path is resolved before comparing, because the temp roots are resolved and /var is a symlink to /private/var on macOS. ## AddRead's branches were only covered from the write side Two subtests added: a permanent read under a temporary WRITE root, and a narrow permanent read under a BROAD temporary read root. Both mutation-checked by removing the corresponding temporary-coverage guard in AddRead — each fails. The first mutation I ran did not fail its test, and the finding looked wrong: the pattern being edited occurs in Add, AddRead and AddTemporaryWrite alike, and a first-match edit had changed Add. Re-run against the line inside AddRead, it fails as claimed. Recorded because a mutation that misses its target is indistinguishable from a test that holds. ## Also asserted: the refcount table is empty, not merely invisible hasReadRoot answers what a caller can see. An entry left at zero in tempReads is not visible that way and would still leak one map entry per acquire/release cycle, so the contention test now checks the terminal state directly. go test -race ./internal/sandbox/ -count=5: clean. Unrelated and pre-existing here: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider exit 3 in this environment. Origin-Session: local-8cd239 | Claude Code | 12 prompts Origin-Snapshot: dd397730a138 Origin-Session: local-13d543 | Claude Code | 5 prompts Origin-Snapshot: 6b5eb8ba4e5b Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * fix(sandbox): a reader outliving its writer keeps read authority, not write Reported by @jatmn, and it is a privilege escalation rather than a lifetime bug. AddTemporaryRead, given a path already covered by a temporary WRITE root, took a reference on that write root and returned an undo that released it. The lifetime was then correct — the reader no longer lost its access when the writer cleaned up — but the capability was wrong. extraRoots feeds validate(), which authorises WRITES, so once the write holder released first the path stayed in extraRoots for as long as the read holder lived. A caller that asked only to READ could write anywhere below the former write root after the write grant had ended. His diagnosis names the root cause exactly: one write-capability reference was doing two jobs, tracking a lifetime and standing in for a read-only dependent. The lifetime is now kept as what the caller actually asked for — a temporary READ root on its own path. AddTemporaryRead consults permanentWriteRootCoversLocked rather than writeRootCoversLocked, so a merely temporary write cover no longer short-circuits it into borrowing write authority. Mutation-checked: restoring writeRootCoversLocked in that branch compiles and fails the regression on three separate assertions — the reader keeps no read root of its own, ReadRoots() loses the path, and the writer's cleanup revokes a live read grant. Two things a verification pass turned up that are worth recording: The comment justifying this design said readRoots "feeds ReadRoots() and nothing else". That was false — it also feeds ExtraReadRoots(). The security argument survives, because neither confers write authority and validate() reads workspaceRoot plus extraRoots, which this path never enters. But that sentence IS the written justification for the whole choice, so it now lists both and says why neither grants write. The "the reader releases first" subtest passes on the unfixed tree: the escalation needs the WRITER to go first. It is a real no-regression control for the opposite ordering, not evidence the finding is closed, and it is renamed to say so rather than padding the count. Rebased ontoad34dc8das requested. go test -race ./internal/sandbox/ -count=5: clean. Pre-existing here and on main, unrelated: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider exit 3 in this environment. Origin-Session: local-c962d7 | Claude Code | 3 prompts Origin-Snapshot: d2b8f44a9abc * fix(sandbox): a nested write holder keeps only the subtree it asked for Reported by CodeRabbit, and it is the write-side twin of the read-side escalation @jatmn found — I had disclosed it on this PR as still open, so this closes it rather than deferring it again. AddTemporaryWrite, given a path already covered by a broader TEMPORARY write root, took a reference on that covering root. The lifetime was right and the capability was wrong, exactly as on the read side: extraRoots is what validate() authorises writes from, so keeping the covering root alive handed the nested holder everything its neighbour had asked for. Reproduced: both held tempWrites={outer:2} extraRoots=[... outer] after outer release tempWrites={outer:1} extraRoots=[... outer] -> the inner-only holder can WRITE outer/sibling/not-mine.txt A narrower request now records its own root. Each holder gets exactly the authority it asked for and a lifetime of its own: both held tempWrites={outer:1, inner:1} extraRoots=[... outer inner] after outer release tempWrites={inner:1} extraRoots=[... inner] -> sibling denied, inner retained Only an EXACT match shares a refcount entry; a narrower one never does, which is the distinction the escalation turned on. The nesting costs a redundant extraRoots entry while both holders are live, which is not wrong — the broader root already permits everything the narrower one does. Three regressions: the escalation itself (broad holder releases first), the opposite order as a control, and two holders of the same root sharing one entry. Mutation: restoring the covering-root borrow compiles and fails the escalation test on both the sibling and the covering root itself. Honest note on a second mutation. Removing the permanent-cover early return compiles and fails NOTHING. That is correct rather than a coverage gap: a permanently covered request would then record its own temporary root, which is redundant but harmless, since the permanent root outlives it and already permits the writes. The early return is a cleanliness guard, not a correctness one, and I would rather say so than write a test that pretends otherwise. go test -race ./internal/sandbox/ -count=5: clean. Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider exit 3 in this environment. Origin-Session: local-c962d7 | Claude Code | 5 prompts Origin-Snapshot: 3bb420a0a97b * test(sandbox): the outside-roots fixture fails loudly instead of skipping Raised by CodeRabbit, and the helper had already written the argument against itself: its own comment says "A skip is not a pass" while three of its four exits were skips. Every caller of scopeOutsideRoots asserts an AUTHORIZATION boundary — who may write where, and whose release revokes it. A skipped fixture means that assertion never ran and the build reported green anyway, which is precisely the failure the helper was written to close. It closed it for one case (a home directory that resolves nowhere on Windows) and left the others open. Three exits become fatal: os.UserHomeDir failing — resolves on every platform Zero supports os.MkdirTemp(home) failing — a home that will not take a temp directory is a broken machine, not a limited one os.MkdirAll under base — base was just created successfully, so a subdirectory failing under it is the same If any fires, the right outcome is a red build somebody fixes, not silent non-coverage of the sandbox's write rules. ONE SKIP STAYS, and it is a different kind. When the grant path is already inside a default write root, the fixture built correctly and the machine is fine — the grant simply cannot demonstrate anything, because that path is writable before any temporary grant exists. Running on would assert a permission that was never in question. The comment now says which kind it is, so the distinction survives the next reader. Verified the fatal path fires: forcing the MkdirTemp error compiles and fails TestANestedWriteHolderKeepsOnlyItsOwnSubtree at the fixture with the reason, where it previously reported a pass. go test -race ./internal/sandbox/ -count=3: clean. Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider exit 3 in this environment. Origin-Session: local-c962d7 | Claude Code | 13 prompts Origin-Snapshot: a599377c09e0 * fix(sandbox): least authority for nested readers, and a hermetic grant fixture @jatmn's three findings, taken as one pass over the grant construct rather than as three local patches — his point that the same invariant kept not being reapplied to the symmetric branch was fair, and this is the symmetric branch. ## The nested reader kept its neighbour's tree AddTemporaryRead had exactly the shape AddTemporaryWrite was fixed for one commit earlier: reader B asking for outer/inner took a reference on reader A's covering root. Lifetime correct, capability wrong. readRoots feeds validateRead and the native sandbox profile, so once A released, B could read outer/sibling. Reproduced: tempReads={outer:2}; after A releases, the inner-only reader reads a sibling it never asked for. The rule is now the same on both sides: permanent coverage decides first, an EXACT temporary root shares one refcount, and a narrower request records its own root. Mutation: restoring the borrow fires both escalation assertions. ## Slice order no longer decides permanent coverage permanentReadRootCoversLocked asks the whole question over both lists before any insertion order can answer it, and AddRead now uses the same predicate instead of its own scan, so the two cannot drift. Honest note: with the borrow gone, this mutation is no longer catchable. Making the scan return at the first covering entry compiles and fails nothing, because AddTemporaryRead no longer borrows anything — it either no-ops or records its own root, and neither escalates. The guard now prevents a redundant refcount entry rather than an escalation. Verified by measurement, not assumed. ## A test that asserted the defect TestANarrowerRequestHoldsTheCoveringGrant proved a lifetime property by asserting the BROAD root was still present after the broad holder released — which is the escalation written down as a requirement. It is replaced by tests that assert lifetime and authority separately: the requested path stays readable, the sibling and the covering root do not, plus the reverse release order, exact-root sharing, and a permanent narrow root not borrowing a broad temporary one. ## The obsolete predicate that broke CI writeRootCoversLocked lost its last caller when the three coverage decisions moved to permanentWriteRootCoversLocked and exact-root logic. make lint-static reproduced the Windows `unused` failure locally. Removed rather than suppressed. This is on me: I did not run lint-static before the previous push, which is a required step, and CI caught what I should have. ## Fixtures out of the real home scopeOutsideRoots and homeGrantOutsideTempRoots both built fixtures under os.UserHomeDir(). Fixed at the cause jatmn identified rather than by relaxing the setup checks: NewScope seeds the system temp directory as a permanent write root, which is what made a t.TempDir() grant vacuous and sent the fixture to HOME in the first place. Both helpers now build the Scope DIRECTLY — the pattern scopeOutsideDefaults and temporaryWriteFixture already used — so no defaults are seeded, t.TempDir() is outside by construction, and nothing touches HOME. Each helper now asserts the NEGATIVE against its own scope before returning: the target must be unwritable, and unreadable where relevant, before any grant exists. That is stronger than the old check against defaultTempWriteRoots(), which asked about a scope these tests no longer build. Verified with HOME readable but not writable (caches redirected): the sandbox suite passes. The one remaining failure there is TestSeatbeltEnforcesExtraWriteRoots in seatbelt_integration_darwin_test.go, which is NOT in this PR's diff — pre-existing, same shape, and worth its own issue rather than riding along here. gofmt, go vet, lint-static (0 issues), go test -race ./internal/sandbox/ -count=5: all clean. Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider exit 3 in this environment. Origin-Session: local-c962d7 | Claude Code | 14 prompts Origin-Snapshot: a599377c09e0 * refactor(sandbox): drop the child-grant accessors this PR does not consume Reported by @jatmn. ExtraRoots and ExtraReadRoots have no production caller anywhere in the tree — only their own definitions and this PR's tests. They arrived with the initial split because the extraction was made at the file level: the temporary-root implementation came across while the CLI and runner wiring that consumes them stayed behind in #829's child read-grant propagation area. That leaves unrelated production surface in a branch scoped to refcounting. Both accessors are removed, along with the getter-only propagation assertion that existed to exercise them. The refcount regressions that used ExtraRoots only to inspect beyond-workspace write state now use a test-only helper over Roots(), because that inspection is a test concern rather than an API this branch needs. Nothing about the refcount, permanence, least-authority, atomic-release or hermetic-fixture work changes, and no child propagation is wired here — that would widen the scope rather than fix the extraction boundary. One comment needed correcting rather than deleting. The justification for keeping a reader's lifetime as a READ root named the lists readRoots feeds, and that sentence has now been wrong in both directions: an earlier draft said "ReadRoots and nothing else" while ExtraReadRoots also read it, and the accessor it was corrected to mention is the one being removed here. It now describes what ships and says why the list has to stay exhaustive as accessors change. Rebased onto6fe0d1ed, 0 behind. lint-static: 0 issues. go test -race ./internal/sandbox/ -count=3: clean. Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.
367 lines
14 KiB
Go
367 lines
14 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// scopeOutsideRoots builds a workspace and an unrelated directory that no
|
|
// default write root already covers.
|
|
//
|
|
// NOT t.TempDir(). /tmp and $TMPDIR are default write roots, so a temporary
|
|
// grant for a path under one is a no-op — every assertion here would pass
|
|
// against code that grants nothing at all. That is RULES §2.3 in its exact
|
|
// form, and it caught the first version of this test.
|
|
// scopeOutsideRoots builds a workspace and a directory outside it, both under
|
|
// test-owned storage, and returns a Scope that treats the second as genuinely
|
|
// outside.
|
|
//
|
|
// THE FIXTURE PROBLEM AND ITS ACTUAL CAUSE. NewScope seeds the system temporary
|
|
// directory as a permanent write root, so an ordinary t.TempDir() grant is
|
|
// covered before any temporary grant exists and every assertion built on it is
|
|
// vacuous. The first fix for that reached into os.UserHomeDir() to find ground
|
|
// the defaults do not already own — which worked, and bought two new problems:
|
|
// a hermetic runner may expose HOME read-only, so the tests failed at MkdirTemp
|
|
// before asserting anything; and on an ordinary machine they wrote fixtures into
|
|
// the developer's real home and left zeromax-scope-* debris behind whenever a
|
|
// run was interrupted. Reported by @jatmn.
|
|
//
|
|
// The cause is the seeded defaults, not the location, so the Scope is built
|
|
// DIRECTLY here — the same pattern scopeOutsideDefaults and temporaryWriteFixture
|
|
// already use. With no defaults seeded, t.TempDir() is outside by construction,
|
|
// the fixture is hermetic, and nothing touches HOME.
|
|
//
|
|
// Paths are symlink-resolved because the scope stores resolved roots and macOS
|
|
// hands out /var/... temp directories that resolve to /private/var/...; an
|
|
// unresolved fixture path compares unequal to the root the scope just recorded.
|
|
func scopeOutsideRoots(t *testing.T) (scope *Scope, workspace string, outside string) {
|
|
t.Helper()
|
|
workspace = resolvedFixturePath(t, t.TempDir())
|
|
outside = filepath.Join(resolvedFixturePath(t, t.TempDir()), "outside")
|
|
// FATAL, NOT SKIP. Every caller of this helper asserts an AUTHORIZATION
|
|
// boundary, so a skipped fixture means that assertion never ran and the build
|
|
// reported green anyway. t.TempDir() already failed the test if it could not
|
|
// provide storage, so a subdirectory failing under it is a broken machine.
|
|
if err := os.MkdirAll(outside, 0o700); err != nil {
|
|
t.Fatalf("cannot prepare %s: %v", outside, err)
|
|
}
|
|
scope = &Scope{workspaceRoot: workspace}
|
|
// PROVED AGAINST THIS SCOPE, not against production's default list. The point
|
|
// of the helper is that a grant here changes something, and the only thing
|
|
// that can answer that is the scope the test will actually use. Checking
|
|
// defaultTempWriteRoots() instead asks about a scope nobody builds here — and
|
|
// says "prove nothing" for a t.TempDir() path that this scope, seeded with no
|
|
// defaults, genuinely does not cover.
|
|
//
|
|
// Asserting the NEGATIVE up front is also what keeps every caller honest: it
|
|
// establishes that the target begins unauthorised, so a later success is the
|
|
// grant's doing and not the fixture's placement.
|
|
if scope.validate(filepath.Join(outside, "probe.txt")) == nil {
|
|
t.Fatalf("%s is writable before any grant exists, so a temporary grant here would prove nothing", outside)
|
|
}
|
|
if scope.validateRead(filepath.Join(outside, "probe.txt")) == nil {
|
|
t.Fatalf("%s is readable before any grant exists, so a temporary read grant here would prove nothing", outside)
|
|
}
|
|
return scope, workspace, outside
|
|
}
|
|
|
|
func hasReadRoot(scope *Scope, root string) bool {
|
|
for _, existing := range scope.ReadRoots() {
|
|
if existing == root {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// THE DEFECT: one holder's cleanup revoked another's access. Two read-only
|
|
// tools in the same parallel batch, both blocked on the same directory, is
|
|
// exactly this — and read-only tools are the ones the batch runs concurrently.
|
|
func TestATemporaryReadSurvivesASiblingsCleanup(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
// The probe that the fix must make unnecessary: without it this reads
|
|
// true -> false.
|
|
if hasReadRoot(scope, outside) {
|
|
t.Fatal("the outside root is already covered; this test proves nothing")
|
|
}
|
|
|
|
_, undoA, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undoB, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !hasReadRoot(scope, outside) {
|
|
t.Fatal("the grant did not take effect")
|
|
}
|
|
|
|
undoA()
|
|
if !hasReadRoot(scope, outside) {
|
|
t.Fatal("A's cleanup revoked the root while B still held a grant")
|
|
}
|
|
undoB()
|
|
if hasReadRoot(scope, outside) {
|
|
t.Fatal("the root outlived its last holder")
|
|
}
|
|
}
|
|
|
|
// A NARROWER REQUEST KEEPS ITS OWN SUBTREE AND NOTHING WIDER.
|
|
//
|
|
// THIS TEST USED TO ASSERT THE OPPOSITE, and the earlier version is worth
|
|
// recording because it is how the escalation survived a review round. It was
|
|
// written to prove a LIFETIME property — the broad holder's cleanup must not
|
|
// revoke a live narrow holder — and it proved that by asserting the BROAD root
|
|
// was still present afterwards. That is the defect stated as a requirement: the
|
|
// narrow holder was keeping its neighbour's whole tree alive in order to keep
|
|
// itself alive, so the surviving reader could read siblings it never asked for.
|
|
//
|
|
// Lifetime and capability are two properties and one reference cannot carry
|
|
// both. The assertions below name them separately: the requested path stays
|
|
// readable while its holder lives, AND a sibling under the released broad root
|
|
// is denied as soon as the holder that authorised it exits. Reported by @jatmn.
|
|
func TestANarrowerReaderKeepsOnlyItsOwnSubtree(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
nested := filepath.Join(outside, "nested")
|
|
sibling := filepath.Join(outside, "sibling")
|
|
for _, dir := range []string{nested, sibling} {
|
|
// FATAL, NOT SKIP. These directories are the fixture, not a precondition
|
|
// the environment might reasonably withhold — skipping on them reports a
|
|
// broken setup as a passing run.
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
_, undoBroad, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undoNarrow, err := scope.AddTemporaryRead(nested)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// THE BROAD HOLDER GOES FIRST. The narrow holder outliving it is what exposes
|
|
// whose authority it is actually holding.
|
|
undoBroad()
|
|
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
|
|
t.Errorf("the broad holder's cleanup revoked the subtree the narrow holder asked for: %v", block.Reason)
|
|
}
|
|
if block := scope.validateRead(filepath.Join(sibling, "not-mine.txt")); block == nil {
|
|
t.Error("the narrow reader can read a SIBLING it never asked for: the broad holder's authority outlived the broad holder")
|
|
}
|
|
if block := scope.validateRead(filepath.Join(outside, "not-mine.txt")); block == nil {
|
|
t.Error("the narrow reader can read the covering root itself after its holder released")
|
|
}
|
|
|
|
undoNarrow()
|
|
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block == nil {
|
|
t.Error("the nested root outlived its only holder")
|
|
}
|
|
}
|
|
|
|
// The opposite order, as a control: the narrow reader releasing first must not
|
|
// disturb the broad reader that is still live.
|
|
func TestANarrowerReaderReleasingFirstLeavesTheBroadGrant(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
nested := filepath.Join(outside, "nested")
|
|
if err := os.MkdirAll(nested, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undoBroad, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undoNarrow, err := scope.AddTemporaryRead(nested)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
undoNarrow()
|
|
if block := scope.validateRead(filepath.Join(nested, "still-ok.txt")); block != nil {
|
|
t.Errorf("the narrow holder's release revoked the broad holder's coverage: %v", block.Reason)
|
|
}
|
|
undoBroad()
|
|
if block := scope.validateRead(filepath.Join(nested, "gone.txt")); block == nil {
|
|
t.Error("the tree outlived every holder")
|
|
}
|
|
}
|
|
|
|
// TWO READERS OF THE SAME ROOT SHARE ONE ENTRY. Only an exact match shares; a
|
|
// narrower one never does, which is what the escalation test above pins.
|
|
func TestTwoReadersOfTheSameRootShareOneEntry(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
_, undoFirst, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undoSecond, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
undoFirst()
|
|
if block := scope.validateRead(filepath.Join(outside, "still-held.txt")); block != nil {
|
|
t.Errorf("one reader's release revoked the other's grant on the same root: %v", block.Reason)
|
|
}
|
|
undoSecond()
|
|
if block := scope.validateRead(filepath.Join(outside, "gone.txt")); block == nil {
|
|
t.Error("the shared root outlived both holders")
|
|
}
|
|
}
|
|
|
|
// A NARROW PERMANENT READ IS NOT BORROWED FROM A BROAD TEMPORARY ONE. The old
|
|
// scan walked readRoots in order and stopped at the first entry covering the
|
|
// path, so a broad TEMPORARY root earlier in the slice shadowed a narrower
|
|
// PERMANENT one later — and the caller took a reference it did not need on a
|
|
// grant that was about to end.
|
|
func TestAPermanentReadIsNotShadowedByABroadTemporaryOne(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
nested := filepath.Join(outside, "nested")
|
|
if err := os.MkdirAll(nested, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The broad TEMPORARY grant is taken first, so it sits earlier in readRoots.
|
|
_, undoBroad, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := scope.AddRead(nested); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
undoBroad()
|
|
if block := scope.validateRead(filepath.Join(nested, "mine.txt")); block != nil {
|
|
t.Errorf("a PERMANENT read grant died with a broad temporary one that merely preceded it: %v", block.Reason)
|
|
}
|
|
}
|
|
|
|
// A PERMANENT root is not refcounted and must never be removed by a temporary
|
|
// holder's cleanup — the undo for a request it already covers is genuinely
|
|
// nothing.
|
|
func TestATemporaryGrantNeverRevokesAPermanentRoot(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
if _, err := scope.AddRead(outside); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undo, err := scope.AddTemporaryRead(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
undo()
|
|
if !hasReadRoot(scope, outside) {
|
|
t.Fatal("a temporary holder's cleanup removed a permanent read root")
|
|
}
|
|
}
|
|
|
|
// The same rule for WRITE roots, or a write grant keeps the bug reads no longer
|
|
// have.
|
|
func TestATemporaryWriteSurvivesASiblingsCleanup(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
covered := func() bool {
|
|
for _, existing := range scope.Roots() {
|
|
if existing == outside {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
_, undoA, err := scope.AddTemporaryWrite(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, undoB, err := scope.AddTemporaryWrite(outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !covered() {
|
|
t.Fatal("the write grant did not take effect")
|
|
}
|
|
undoA()
|
|
if !covered() {
|
|
t.Fatal("A's cleanup revoked the write root while B still held a grant")
|
|
}
|
|
undoB()
|
|
if covered() {
|
|
t.Fatal("the write root outlived its last holder")
|
|
}
|
|
}
|
|
|
|
// UNDER REAL CONCURRENCY, which is the shape the parallel tool batch produces:
|
|
// eight holders taking and releasing the same root in overlapping windows. The
|
|
// root must be present the whole time at least one holder has it, and gone once
|
|
// the last releases.
|
|
func TestConcurrentHoldersOfOneRoot(t *testing.T) {
|
|
scope, _, outside := scopeOutsideRoots(t)
|
|
|
|
const holders = 8
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
release := make(chan struct{})
|
|
// TWO PER HOLDER, because each one can report at both hasReadRoot checks. At
|
|
// one slot per holder the channel fills after eight reports, the ninth send
|
|
// blocks forever, wg.Done() is never reached and wg.Wait() hangs — so the
|
|
// test would DEADLOCK rather than fail in exactly the case it exists to
|
|
// catch, since nothing drains failures until after the wait.
|
|
failures := make(chan string, 2*holders)
|
|
// Signalled by EVERY holder once its own AddTemporaryRead has returned,
|
|
// failure included: the gate below waits for all of them, so a holder that
|
|
// returned early without signalling would hang this test rather than fail it.
|
|
acquired := make(chan struct{}, holders)
|
|
for i := 0; i < holders; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, undo, err := scope.AddTemporaryRead(outside)
|
|
acquired <- struct{}{}
|
|
if err != nil {
|
|
failures <- err.Error()
|
|
return
|
|
}
|
|
// Every holder must SEE its own grant for as long as it holds it.
|
|
if !hasReadRoot(scope, outside) {
|
|
failures <- "a holder could not see the root it had just been granted"
|
|
}
|
|
<-release
|
|
if !hasReadRoot(scope, outside) {
|
|
failures <- "a holder lost the root while still holding it"
|
|
}
|
|
undo()
|
|
}()
|
|
}
|
|
close(start)
|
|
// EVERY holder, not the first one. Spinning on hasReadRoot only waited for
|
|
// somebody to hold the root, and the spin is tight enough to win that race
|
|
// against the goroutines still being scheduled: measured over 200 runs, 194
|
|
// of them peaked at a single simultaneous holder and none ever reached
|
|
// eight. A test named for concurrent holders was testing one holder at a
|
|
// time, and would have passed against an implementation with no refcount at
|
|
// all. Counting the acquisitions makes the overlap real instead of hoped for.
|
|
for i := 0; i < holders; i++ {
|
|
<-acquired
|
|
}
|
|
close(release)
|
|
wg.Wait()
|
|
close(failures)
|
|
for failure := range failures {
|
|
t.Fatal(failure)
|
|
}
|
|
if hasReadRoot(scope, outside) {
|
|
t.Fatal("the root outlived every holder")
|
|
}
|
|
// AND THE BOOKKEEPING IS EMPTY, not merely invisible. hasReadRoot answers
|
|
// what a caller can see; a refcount entry left behind at zero is not visible
|
|
// that way and would still leak, one map entry per acquire/release cycle.
|
|
scope.mu.Lock()
|
|
leftover, stillCounted := scope.tempReads[outside]
|
|
scope.mu.Unlock()
|
|
if stillCounted {
|
|
t.Fatalf("the refcount table kept an entry for %q after every holder released: %v", outside, leftover)
|
|
}
|
|
}
|