mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -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.
648 lines
24 KiB
Go
648 lines
24 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Scope is the shared set of directories the sandbox allows writes in: the
|
|
// workspace root plus zero or more user-granted extra roots. One instance is
|
|
// created per run and shared by the policy engine, the OS command runners, and
|
|
// the file tools, so a mid-session Add is immediately visible to every layer.
|
|
type Scope struct {
|
|
mu sync.RWMutex
|
|
workspaceRoot string
|
|
readRoots []string
|
|
extraRoots []string
|
|
// tempReads / tempWrites count how many LIVE temporary grants depend on a
|
|
// root, so one holder's cleanup cannot revoke another's access.
|
|
//
|
|
// Without them a temporary grant was add-then-remove with no notion of who
|
|
// still needed it: the second caller to ask for a root already present got a
|
|
// NO-OP undo, and the first caller's cleanup removed the root out from under
|
|
// 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 map[string]int
|
|
tempWrites map[string]int
|
|
}
|
|
|
|
// NewScope builds a scope for workspaceRoot plus the given extra roots. The
|
|
// workspace root is normalized best-effort (it may not exist in tests); every
|
|
// extra root must normalize strictly via Add and an invalid one fails the
|
|
// whole construction so a bad --add-dir/config entry surfaces at startup.
|
|
func NewScope(workspaceRoot string, extras []string) (*Scope, error) {
|
|
scope := &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
|
|
for _, extra := range extras {
|
|
if _, err := scope.Add(extra); err != nil {
|
|
return nil, fmt.Errorf("write root %q: %w", extra, err)
|
|
}
|
|
}
|
|
if scope.workspaceRoot != "" {
|
|
for _, root := range defaultTempWriteRootCandidates() {
|
|
_, _ = scope.Add(root)
|
|
}
|
|
}
|
|
return scope, nil
|
|
}
|
|
|
|
// WorkspaceRoot returns the resolved workspace root. It is safe to call
|
|
// without acquiring the lock because workspaceRoot is immutable after
|
|
// construction.
|
|
func (s *Scope) WorkspaceRoot() string {
|
|
return s.workspaceRoot
|
|
}
|
|
|
|
// Roots returns the workspace root first, then the extra roots, as a copy.
|
|
func (s *Scope) Roots() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
roots := make([]string, 0, 1+len(s.extraRoots))
|
|
roots = append(roots, s.workspaceRoot)
|
|
roots = append(roots, s.extraRoots...)
|
|
return roots
|
|
}
|
|
|
|
// ReadRoots returns the workspace root, write roots, and read-only roots as a
|
|
// copy. Write roots are included because anything writable must also be readable
|
|
// by the tool layer and native sandbox profile.
|
|
func (s *Scope) ReadRoots() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
roots := make([]string, 0, 1+len(s.extraRoots)+len(s.readRoots))
|
|
roots = append(roots, s.workspaceRoot)
|
|
roots = append(roots, s.extraRoots...)
|
|
roots = append(roots, s.readRoots...)
|
|
return dedupeScopeRoots(roots)
|
|
}
|
|
|
|
// Add grants write access under path. The path must be an existing directory;
|
|
// it is home-expanded, made absolute, and symlink-resolved before being
|
|
// trusted, and the filesystem root is rejected outright. Adding a path already
|
|
// covered by an existing root is an idempotent success.
|
|
func (s *Scope) Add(path string) (string, error) {
|
|
root, err := normalizeScopeRoot(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// COVERED BY WHAT. extraRoots holds temporary write roots alongside
|
|
// permanent ones, so "something already covers this" was not the question
|
|
// worth asking: a permanent grant made over a path a temporary holder
|
|
// happened to cover recorded nothing, and vanished the moment that holder
|
|
// released. A session-scoped grant outliving the request that prompted it is
|
|
// the entire difference between Add and AddTemporaryWrite.
|
|
//
|
|
// Permanent coverage is looked for FIRST and across every root, because a
|
|
// path can be covered twice and the temporary cover must not decide the
|
|
// answer when a permanent one is also present.
|
|
if s.permanentWriteRootCoversLocked(root) {
|
|
return root, nil
|
|
}
|
|
for _, existing := range s.extraRoots {
|
|
// The same root, held temporarily: promote it in place. Its holders'
|
|
// releases become no-ops, which is what permanence means here.
|
|
if existing == root && s.temporaryWriteLocked(existing) {
|
|
delete(s.tempWrites, root)
|
|
return root, nil
|
|
}
|
|
}
|
|
// Either uncovered, or covered only by a BROADER temporary root that is
|
|
// going to be released. Recording the narrower root in its own right is what
|
|
// survives that release.
|
|
s.extraRoots = append(s.extraRoots, root)
|
|
return root, nil
|
|
}
|
|
|
|
// temporaryWriteLocked reports whether root is held by a temporary write grant
|
|
// rather than a session-scoped one. Callers must hold the lock.
|
|
func (s *Scope) temporaryWriteLocked(root string) bool {
|
|
_, temporary := s.tempWrites[root]
|
|
return temporary
|
|
}
|
|
|
|
// permanentWriteRootCoversLocked reports whether root sits under write authority
|
|
// that outlives every temporary holder: the workspace root, or a session-scoped
|
|
// grant. Coverage by a TEMPORARY write root deliberately does not count —
|
|
// it is going to be released, so nothing recorded on the strength of it
|
|
// survives. Callers must hold the lock.
|
|
//
|
|
// One helper rather than a copy per caller because Add, AddRead and
|
|
// AddTemporaryRead all turn on the same question, and three copies of "covered,
|
|
// but by what" is how one of them ends up answering it differently.
|
|
// permanentReadRootCoversLocked reports whether root is already readable by a
|
|
// grant that outlives every temporary holder.
|
|
//
|
|
// SLICE ORDER MUST NOT DECIDE THIS. The old check walked readRoots and stopped at
|
|
// the first entry covering the path, so a BROAD TEMPORARY root sitting earlier in
|
|
// the slice shadowed a narrower PERMANENT one later — and the caller was handed a
|
|
// reference on somebody else's grant when it needed no reference at all. Asking
|
|
// the whole question first, over both lists, makes the answer independent of
|
|
// insertion order.
|
|
//
|
|
// Write roots count: a grant that permits writing permits reading the same tree.
|
|
func (s *Scope) permanentReadRootCoversLocked(root string) bool {
|
|
if s.permanentWriteRootCoversLocked(root) {
|
|
return true
|
|
}
|
|
for _, existing := range s.readRoots {
|
|
if pathWithinRoot(existing, root) && !s.temporaryReadLocked(existing) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Scope) permanentWriteRootCoversLocked(root string) bool {
|
|
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
|
|
if pathWithinRoot(existing, root) && !s.temporaryWriteLocked(existing) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// temporaryReadLocked is temporaryWriteLocked for read roots.
|
|
func (s *Scope) temporaryReadLocked(root string) bool {
|
|
_, temporary := s.tempReads[root]
|
|
return temporary
|
|
}
|
|
|
|
// AddRead grants read-only access under path. If the path is already covered by
|
|
// a writable root, no separate read root is stored.
|
|
func (s *Scope) AddRead(path string) (string, error) {
|
|
root, err := normalizeScopeRoot(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// Same rule as Add: coverage that is going to be released is not coverage a
|
|
// permanent grant can rely on, whichever side of the read/write boundary it
|
|
// sits on. A permanent read covered only by a temporary WRITE root died with
|
|
// that root just as surely.
|
|
if s.permanentWriteRootCoversLocked(root) {
|
|
return root, nil
|
|
}
|
|
if s.permanentReadRootCoversLocked(root) {
|
|
return root, nil
|
|
}
|
|
for _, existing := range s.readRoots {
|
|
if existing == root && s.temporaryReadLocked(existing) {
|
|
delete(s.tempReads, root)
|
|
return root, nil
|
|
}
|
|
}
|
|
s.readRoots = append(s.readRoots, root)
|
|
return root, nil
|
|
}
|
|
|
|
func (s *Scope) AddTemporaryRead(path string) (string, func(), error) {
|
|
root, err := normalizeScopeRoot(path)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// Genuinely permanent — the workspace root, or a session-scoped grant. It
|
|
// outlives this reader, so there is nothing to release.
|
|
if s.permanentWriteRootCoversLocked(root) {
|
|
return root, func() {}, nil
|
|
}
|
|
// A TEMPORARY write root covering this path is NOT borrowed, and this is the
|
|
// one place the distinction bites hardest.
|
|
//
|
|
// The dependency on that root is real — the write holder's cleanup must not
|
|
// revoke this reader, which is the defect the refcount exists to close,
|
|
// reached across the read/write boundary. But taking a reference on the
|
|
// WRITE root pays for the lifetime with the authority: the reference keeps
|
|
// the root in extraRoots, extraRoots is what Roots() feeds validate(), and
|
|
// validate() is WRITE authorization. So a reader outliving its writer went on
|
|
// writing anywhere under a write grant that had already ended — not merely
|
|
// under the path it asked to read.
|
|
//
|
|
// One reference cannot be both the lifetime and the capability. The lifetime
|
|
// is kept below, as a temporary READ root for the path this caller actually
|
|
// asked for. readRoots feeds ReadRoots(), which confers no write authority —
|
|
// validate() authorises writes from workspaceRoot plus extraRoots, and this
|
|
// path never enters that list. So the reader survives its writer with read
|
|
// authority and only read authority.
|
|
//
|
|
// That claim is the written justification for the whole design, so it has to
|
|
// stay exhaustive as the accessors change. It has already been wrong once, in
|
|
// both directions: an earlier draft said readRoots fed ReadRoots() and nothing
|
|
// else while ExtraReadRoots() also read it, and that accessor has since been
|
|
// removed as belonging to a different change.
|
|
if s.permanentReadRootCoversLocked(root) {
|
|
return root, func() {}, nil
|
|
}
|
|
if s.tempReads == nil {
|
|
s.tempReads = map[string]int{}
|
|
}
|
|
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an EXACT
|
|
// match shares — a narrower request must not, which is the whole point below.
|
|
if _, held := s.tempReads[root]; held {
|
|
s.tempReads[root]++
|
|
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
|
|
}
|
|
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even under a broader temporary
|
|
// read that currently covers it.
|
|
//
|
|
// Borrowing the covering root's reference kept this reader alive by keeping
|
|
// the BROAD ROOT alive, and readRoots feeds validateRead and the native
|
|
// sandbox profile. So once the broad reader released, a caller that had asked
|
|
// to read one subdirectory was left able to read the whole tree its neighbour
|
|
// had asked for, siblings included. Reported by @jatmn, and it is the read
|
|
// twin of the write escalation fixed above: the refcount key decides not only
|
|
// WHEN an entry disappears but WHICH path stays authorised while it lives.
|
|
s.readRoots = append(s.readRoots, root)
|
|
if s.tempReads == nil {
|
|
s.tempReads = map[string]int{}
|
|
}
|
|
s.tempReads[root] = 1
|
|
return root, oncePerHolder(func() { s.releaseTemporaryRead(root) }), nil
|
|
}
|
|
|
|
func (s *Scope) AddTemporaryWrite(path string) (string, func(), error) {
|
|
root, err := normalizeScopeRoot(path)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// A PERMANENT grant is the only cover that needs no bookkeeping: it outlives
|
|
// every temporary holder, so there is nothing to keep alive and nothing to
|
|
// release.
|
|
if s.permanentWriteRootCoversLocked(root) {
|
|
return root, func() {}, nil
|
|
}
|
|
if s.tempWrites == nil {
|
|
s.tempWrites = map[string]int{}
|
|
}
|
|
// THE SAME ROOT, ANOTHER HOLDER: one entry, two references. Only an exact
|
|
// match shares an entry — a NARROWER request must not, which is the whole
|
|
// point below.
|
|
if _, held := s.tempWrites[root]; held {
|
|
s.tempWrites[root]++
|
|
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
|
|
}
|
|
// A NARROWER REQUEST RECORDS ITS OWN ROOT, even when a broader temporary
|
|
// grant currently covers it.
|
|
//
|
|
// Borrowing a reference on the covering root kept this holder alive, which
|
|
// was the point, but it kept the COVERING ROOT alive to do it — and
|
|
// extraRoots is what validate() authorises writes from. So once the broad
|
|
// holder released, a caller that had asked to write one subdirectory was
|
|
// left holding write authority over the whole tree its neighbour had asked
|
|
// for, including siblings it never named. Reported by CodeRabbit, and it is
|
|
// the write-side twin of the read-side escalation @jatmn found: one
|
|
// reference cannot be both a lifetime and a capability, whichever side of
|
|
// the read/write boundary it sits on.
|
|
//
|
|
// Recording the requested root separately gives each holder exactly the
|
|
// authority it asked for and a lifetime of its own. The nesting costs an
|
|
// extra entry in extraRoots while both are live, which is redundant but not
|
|
// wrong — the broader root already permits everything the narrower one does.
|
|
s.extraRoots = append(s.extraRoots, root)
|
|
s.tempWrites[root] = 1
|
|
return root, oncePerHolder(func() { s.releaseTemporaryWrite(root) }), nil
|
|
}
|
|
|
|
// releaseTemporaryRead drops ONE holder's reference and removes the root only
|
|
// when the last one is gone.
|
|
//
|
|
// The count alone cannot make this safe, and an earlier comment here claimed it
|
|
// could — that each undo is called exactly once, and flooring at zero handled
|
|
// the rest. Flooring stops the count going negative; it does not stop one
|
|
// 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. Idempotency belongs to the closure, which
|
|
// is the thing that knows whose reference it is, so every undo handed out is
|
|
// wrapped in oncePerHolder.
|
|
func (s *Scope) releaseTemporaryRead(root string) {
|
|
s.mu.Lock()
|
|
remaining, tracked := s.tempReads[root]
|
|
if !tracked {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
remaining--
|
|
if remaining > 0 {
|
|
s.tempReads[root] = remaining
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
// Both mutations under ONE hold. Dropping the lock between them opened 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 then this call strips the root — so
|
|
// that caller believes it holds access it has already silently lost.
|
|
delete(s.tempReads, root)
|
|
s.readRoots = removeScopeRoot(s.readRoots, root)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// releaseTemporaryWrite is releaseTemporaryRead for write roots. Two functions
|
|
// rather than one generic helper because they guard different slices and the
|
|
// generic version would take the slice by name — which is how the wrong one
|
|
// gets passed.
|
|
func (s *Scope) releaseTemporaryWrite(root string) {
|
|
s.mu.Lock()
|
|
remaining, tracked := s.tempWrites[root]
|
|
if !tracked {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
remaining--
|
|
if remaining > 0 {
|
|
s.tempWrites[root] = remaining
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
// Same single-hold rule as releaseTemporaryRead above.
|
|
delete(s.tempWrites, root)
|
|
s.extraRoots = removeScopeRoot(s.extraRoots, root)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// oncePerHolder makes one holder's undo safe to call more than once. The
|
|
// reference it drops is that holder's own, so a second call must do nothing
|
|
// rather than reach into the shared count and take somebody else's.
|
|
func oncePerHolder(release func()) func() {
|
|
var once sync.Once
|
|
return func() { once.Do(release) }
|
|
}
|
|
|
|
func removeScopeRoot(roots []string, root string) []string {
|
|
next := roots[:0]
|
|
for _, existing := range roots {
|
|
if existing != root {
|
|
next = append(next, existing)
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
func dedupeScopeRoots(roots []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := roots[:0]
|
|
for _, root := range roots {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[root]; ok {
|
|
continue
|
|
}
|
|
seen[root] = struct{}{}
|
|
out = append(out, root)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// validate reports whether requestedPath is allowed by any scope root.
|
|
// Relative paths resolve against the workspace root only; absolute paths are
|
|
// accepted if they validate (including per-segment symlink checks) under ANY
|
|
// root. A symlink whose final target lies inside any granted root is allowed —
|
|
// this is a deliberate semantic widening compared with single-root validation,
|
|
// because the true write target is inside an allowed root.
|
|
//
|
|
// When all roots deny, a BlockSymlinkTraversal result from any root is
|
|
// preferred over BlockOutsideWorkspace; the --add-dir hint is appended
|
|
// only on outside_workspace results. The returned block always carries
|
|
// the caller's original requestedPath.
|
|
func (s *Scope) validate(requestedPath string) *pathBlock {
|
|
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.Roots()), requestedPath, SideEffectWrite)
|
|
}
|
|
|
|
func (s *Scope) validateRead(requestedPath string) *pathBlock {
|
|
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.ReadRoots()), requestedPath, SideEffectRead)
|
|
}
|
|
|
|
func withOutsideWorkspaceAccessReason(block *pathBlock, requestedPath string, sideEffect SideEffect) *pathBlock {
|
|
if block == nil || block.Code != BlockOutsideWorkspace || !strings.Contains(block.Reason, " is outside the workspace") {
|
|
return block
|
|
}
|
|
switch sideEffect {
|
|
case SideEffectRead:
|
|
block.Reason = fmt.Sprintf("Reading %s requires access outside the workspace.", requestedPath)
|
|
case SideEffectWrite, SideEffectOutOfWorkspace:
|
|
block.Reason = fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", requestedPath)
|
|
}
|
|
return block
|
|
}
|
|
|
|
func (s *Scope) validateAgainstRoots(requestedPath string, roots []string) *pathBlock {
|
|
if len(roots) == 0 {
|
|
return &pathBlock{
|
|
Code: BlockOutsideWorkspace,
|
|
Path: requestedPath,
|
|
Reason: fmt.Sprintf("%s is outside the workspace", requestedPath),
|
|
}
|
|
}
|
|
if !filepath.IsAbs(requestedPath) {
|
|
return validateWorkspacePath(roots[0], requestedPath)
|
|
}
|
|
// For each root, normalize the leading path prefix so that platform-level
|
|
// symlinks (e.g. macOS /var -> /private/var) are resolved before comparing
|
|
// against the symlink-resolved scope roots, while leaving workspace-internal
|
|
// symlinks intact so validateWorkspacePath can detect traversal blocks.
|
|
var outsideBlock *pathBlock
|
|
var traversalBlock *pathBlock
|
|
for _, root := range roots {
|
|
normalized := NormalizePrefixForRoot(requestedPath, root)
|
|
block := validateWorkspacePath(root, normalized)
|
|
if block == nil {
|
|
return nil
|
|
}
|
|
switch block.Code {
|
|
case BlockSymlinkTraversal:
|
|
if traversalBlock == nil {
|
|
traversalBlock = block
|
|
}
|
|
default:
|
|
if outsideBlock == nil {
|
|
outsideBlock = block
|
|
}
|
|
}
|
|
}
|
|
// Prefer symlink-traversal: the path was lexically inside a granted root
|
|
// but crossed an in-root symlink — the --add-dir hint would be misleading.
|
|
if traversalBlock != nil {
|
|
return &pathBlock{
|
|
Code: BlockSymlinkTraversal,
|
|
Path: requestedPath,
|
|
Reason: traversalBlock.Reason,
|
|
}
|
|
}
|
|
// Plain outside-workspace denial — rebuild with the original path and hint.
|
|
return &pathBlock{
|
|
Code: BlockOutsideWorkspace,
|
|
Path: requestedPath,
|
|
Reason: fmt.Sprintf("%s is outside the workspace (use /add-dir or --add-dir to allow writes there)", requestedPath),
|
|
}
|
|
}
|
|
|
|
// NormalizePrefixForRoot resolves platform-level symlinks (e.g. macOS
|
|
// /var -> /private/var) in the portion of absPath that lies outside
|
|
// resolvedRoot, while leaving workspace-internal path components intact so
|
|
// that validateWorkspacePath can detect symlink traversal blocks there.
|
|
// It is exported because the tools layer shares it to normalize absolute
|
|
// paths per scope root before running its own single-root checks.
|
|
//
|
|
// Algorithm: walk absPath component-by-component, resolving each via
|
|
// EvalSymlinks. Once the running resolved prefix equals resolvedRoot we are
|
|
// inside the root — stop resolving and append the remaining components
|
|
// verbatim. If a component inside the root is a symlink, leave it for
|
|
// validateWorkspacePath to handle. Non-existent tail components are always
|
|
// appended verbatim.
|
|
//
|
|
// The walk is volume-aware so it works on Windows as well as POSIX. On
|
|
// Windows the same alias problem appears in a different guise — a workspace
|
|
// created under an 8.3 short path (C:\Users\RUNNER~1\...) is resolved by
|
|
// EvalSymlinks to its long form (C:\Users\runneradmin\...), so a raw
|
|
// short-form request would escape the long-form root unless its prefix is
|
|
// resolved here first. The component walk must therefore start from the
|
|
// volume root (C:\ or \\host\share\), not "/", or it would mangle a drive
|
|
// path into a drive-relative form (C:\Users -> C:Users) that the single-root
|
|
// checks treat as RELATIVE — failing the policy gate OPEN. On POSIX
|
|
// VolumeName is empty and the volume root reduces to "/", so behavior there
|
|
// is byte-identical to a plain "/"-rooted walk.
|
|
func NormalizePrefixForRoot(absPath, resolvedRoot string) string {
|
|
volume := filepath.VolumeName(absPath)
|
|
volumeRoot := volume + string(filepath.Separator)
|
|
tail := strings.TrimPrefix(filepath.Clean(absPath), volume)
|
|
parts := strings.Split(strings.TrimPrefix(tail, string(filepath.Separator)), string(filepath.Separator))
|
|
current := volumeRoot
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
// If we've reached the resolved root boundary, stop resolving and
|
|
// append the remaining tail verbatim so validateWorkspacePath sees the
|
|
// original symlink names.
|
|
if current == resolvedRoot {
|
|
return filepath.Join(append([]string{current}, parts[i:]...)...)
|
|
}
|
|
next := filepath.Join(current, part)
|
|
info, lerr := os.Lstat(next)
|
|
if lerr != nil {
|
|
// Non-existent component — append rest verbatim.
|
|
return filepath.Join(append([]string{current}, parts[i:]...)...)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
// Symlink. Only resolve it if we're still outside the root.
|
|
if pathWithinRoot(resolvedRoot, current) {
|
|
// Inside root — leave this symlink for validateWorkspacePath.
|
|
return filepath.Join(append([]string{current}, parts[i:]...)...)
|
|
}
|
|
// Outside root (or a jump into the root) — resolve this platform-level symlink.
|
|
resolved, err := filepath.EvalSymlinks(next)
|
|
if err != nil {
|
|
return filepath.Join(append([]string{current}, parts[i:]...)...)
|
|
}
|
|
current = resolved
|
|
continue
|
|
}
|
|
// Regular component outside root — resolve it.
|
|
resolved, err := filepath.EvalSymlinks(next)
|
|
if err != nil {
|
|
current = next
|
|
} else {
|
|
current = resolved
|
|
}
|
|
}
|
|
return current
|
|
}
|
|
|
|
func normalizeWorkspaceRootBestEffort(workspaceRoot string) string {
|
|
trimmed := strings.TrimSpace(workspaceRoot)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
absolute, err := filepath.Abs(trimmed)
|
|
if err != nil {
|
|
return filepath.Clean(trimmed)
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
|
|
return resolved
|
|
}
|
|
return filepath.Clean(absolute)
|
|
}
|
|
|
|
func normalizeScopeRoot(path string) (string, error) {
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return "", errors.New("write root path is empty")
|
|
}
|
|
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("expand ~: %w", err)
|
|
}
|
|
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
|
|
}
|
|
absolute, err := filepath.Abs(trimmed)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(absolute)
|
|
if err != nil {
|
|
return "", fmt.Errorf("write root must exist: %w", err)
|
|
}
|
|
info, err := os.Stat(resolved)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("write root %s is not a directory", resolved)
|
|
}
|
|
if filepath.Dir(resolved) == resolved {
|
|
return "", fmt.Errorf("refusing filesystem root %s as a write root", resolved)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func defaultTempWriteRootCandidates() []string {
|
|
return defaultTempWriteRootCandidatesForGOOS(runtime.GOOS, os.Getenv)
|
|
}
|
|
|
|
func defaultTempWriteRootCandidatesForGOOS(goos string, getenv func(string) string) []string {
|
|
var roots []string
|
|
if goos == "windows" {
|
|
for _, key := range []string{"TEMP", "TMP"} {
|
|
if root := strings.TrimSpace(getenv(key)); root != "" {
|
|
roots = append(roots, root)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
if goos != "windows" {
|
|
roots = append(roots, "/tmp")
|
|
}
|
|
if tmpdir := strings.TrimSpace(getenv("TMPDIR")); tmpdir != "" {
|
|
roots = append(roots, tmpdir)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
func defaultTempWriteRoots() []string {
|
|
return normalizeProfileDirs(defaultTempWriteRootCandidates())
|
|
}
|
|
|
|
func pathWithinRoot(root string, candidate string) bool {
|
|
if root == "" {
|
|
return false
|
|
}
|
|
relative, err := filepath.Rel(root, candidate)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative))
|
|
}
|