carlos / platform Public

cli tests: the temp-root guard covers GOTMPDIR and exported config variables, and never moves to /tmp

Review of the guard added for #226 and #258 found three holes.

GOTMPDIR. t.TempDir() makes its directories under GOTMPDIR when that is
set, and under os.TempDir() only when it is not (testing.go makeTempDir).
The guard checked TMPDIR alone, so a clear TMPDIR with GOTMPDIR under a
logged-in home passed and the tests still worked beneath the operator's
.carlos/config. Both roots are checked now, and each one that is under a
config is pointed at the one new directory. An explicit GOTMPDIR is
pointed there rather than cleared: cleared, t.TempDir() would fall back to
TMPDIR, which may be where the developer set GOTMPDIR to keep binaries
off.

Exported variables. Taking the CARLOS_CONFIG pin off TestShipVerbAPIMode
left nothing between an exported CARLOS_CONFIG and every test that relies
on its scratch directory: the named file answered, and the verb reached
log.Fatal for the console in it. TestMain drops an inherited CARLOS_CONFIG,
and CARLOS_DESTINATION, which has the same shape (no shared in-process
helper pins it, and exported it fails useTempCredentials tests with "no
.carlos/config defines the destination"). CARLOS_CONSOLE and
CARLOS_CREDENTIALS are pinned by the shared helpers and are left alone.

/tmp. The fallback list was /var/tmp then /tmp. The tests build binaries
into the temp root, and where /tmp is shared memory the move put them
there unasked. /var/tmp is the only base now; without it the binary stops
and names the variable to set.

The cleanup still runs only when the tests return, so a log.Fatal, a
panic or a timeout leaves the one /var/tmp/carlos-test-* directory. The
line on stderr and AGENTS.md both say so. Nothing reaps old ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8c5587c91b503ff475b6c23e27ec64a058cf3501 parent ce7b944
2 files changed, +195 −61
  • AGENTS.md +12 −4
  • main_test.go +183 −57

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	334.739s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.099s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.208s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.389s
ok  	github.com/carlosframework/platform/internal/activator	227.342s
ok  	github.com/carlosframework/platform/internal/agentsock	0.014s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.021s
ok  	github.com/carlosframework/platform/internal/appconfig	2.341s
ok  	github.com/carlosframework/platform/internal/apperr	0.072s
ok  	github.com/carlosframework/platform/internal/applog	0.144s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.006s
ok  	github.com/carlosframework/platform/internal/boxid	1.093s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.009s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.003s
ok  	github.com/carlosframework/platform/internal/cgroup	0.713s
ok  	github.com/carlosframework/platform/internal/changelog	0.292s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	363.292s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.784s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.168s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.003s
ok  	github.com/carlosframework/platform/internal/edge	25.959s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.123s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.015s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/host	96.181s
ok  	github.com/carlosframework/platform/internal/hostactors	0.393s
ok  	github.com/carlosframework/platform/internal/hostbox	2.133s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.492s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.012s
ok  	github.com/carlosframework/platform/internal/hostexec	4.911s
ok  	github.com/carlosframework/platform/internal/hostfence	0.035s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.498s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.014s
ok  	github.com/carlosframework/platform/internal/hostguard	1.010s
ok  	github.com/carlosframework/platform/internal/hostops	1.427s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.012s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.012s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.435s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.139s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.011s
ok  	github.com/carlosframework/platform/internal/infraview	0.011s
ok  	github.com/carlosframework/platform/internal/instancectl	1.815s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.703s
ok  	github.com/carlosframework/platform/internal/instancefs	0.017s
ok  	github.com/carlosframework/platform/internal/instancekey	0.013s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.018s
ok  	github.com/carlosframework/platform/internal/layout	1.421s
ok  	github.com/carlosframework/platform/internal/lease	9.434s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.012s
ok  	github.com/carlosframework/platform/internal/mail	0.173s
ok  	github.com/carlosframework/platform/internal/maintenance	0.087s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.534s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.067s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.994s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.764s
ok  	github.com/carlosframework/platform/internal/objstore	0.168s
ok  	github.com/carlosframework/platform/internal/ops	0.151s
ok  	github.com/carlosframework/platform/internal/park	0.202s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	13.152s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.018s
ok  	github.com/carlosframework/platform/internal/relay	7.228s
ok  	github.com/carlosframework/platform/internal/release	19.308s
ok  	github.com/carlosframework/platform/internal/rollout	3.691s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.006s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.003s
ok  	github.com/carlosframework/platform/internal/schedule	2.313s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.537s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.744s
ok  	github.com/carlosframework/platform/internal/status	0.041s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.172s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.005s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.564s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.023s
=== RUN   TestT4StorageProbeMinio
2026/10/13 23:13:48 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE39C6DCBC7ABD, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/AGENTS.md b/AGENTS.md
index 5eda67132..8b81caa89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -298,10 +298,18 @@ anything.
console; a logged-in laptop running `go test ./...` minted 40+ real accounts
on the production console before this was isolated. A `t.TempDir()` is
scratch in that sense only because the root package's `TestMain`
- (`isolateTempRoot`, main_test.go) moves the temp root when a
- `.carlos/config` sits above it, which it does wherever TMPDIR is under a
- logged-in home (#226, #258). A test that leaves its cwd at the checkout is
- not covered by that.
+ (`isolateTempRoot`, main_test.go) checks both temp roots, TMPDIR and
+ GOTMPDIR (`t.TempDir()` uses GOTMPDIR when it is set), and moves whichever
+ has a `.carlos/config` above it, which is the case wherever one is under a
+ logged-in home (#226, #258). The move is to a new
+ `/var/tmp/carlos-test-*` directory, named on stderr and removed when the
+ run ends. A run that is aborted (`log.Fatal`, a panic, a timeout) leaves
+ that one directory behind: remove it by the name that was printed, never
+ by pattern. `/tmp` is never used, and with `/var/tmp` unavailable the
+ binary stops and says which variable to set. `TestMain` also drops an
+ exported `CARLOS_CONFIG` and `CARLOS_DESTINATION`, which no shared helper
+ pins. A test that leaves its cwd at the checkout is not covered by any of
+ that.
- **A sidecar is a member that nothing can dial.** `Kind == "sidecar"` rows
carry no socket and no addr, and are refused by the edge ON KIND — not
merely unmatched by it — before dispatch ever looks at shape. That refusal
diff --git a/main_test.go b/main_test.go
index ae9398bb1..c37400943 100644
--- a/main_test.go
+++ b/main_test.go
@@ -12,16 +12,33 @@ import (
// TestMain keeps the operator's own state out of the root package's tests.
//
-// An exported deploy token: resolveMode reads CARLOS_SERVICE_TOKEN, and a CI
-// shell running `go test` may well export one. In-process tests that call
-// run() or a verb directly would then inherit it. Tests that want a token set
-// it with t.Setenv, which still works after this Unsetenv.
+// Three exported variables are dropped before any test runs. No shared
+// helper pins all three, and each steers a test that calls run() or a verb
+// directly, and every child that inherits os.Environ(). A test that wants
+// one sets it with t.Setenv, which still works after an Unsetenv.
+//
+// - CARLOS_SERVICE_TOKEN: resolveMode reads it, and a CI shell running
+// `go test` may well export one.
+// - CARLOS_CONFIG: it names the one config file read and switches
+// discovery off, so with it exported every test that relies on its
+// scratch directory holding no config reads the developer's file
+// instead, and ships to the console it names.
+// - CARLOS_DESTINATION: it selects a block in whatever config a test
+// wrote, and a config that does not define it is an error.
+//
+// CARLOS_CONSOLE and CARLOS_CREDENTIALS stay: the shared helpers pin both.
+// HOME stays too, because the tests' own `go build` needs the real one.
//
// The operator's .carlos/config: see isolateTempRoot. The binary refuses to
// run rather than work under one.
func TestMain(m *testing.M) {
- os.Unsetenv("CARLOS_SERVICE_TOKEN")
- moved, cleanup, err := isolateTempRoot([]string{"/var/tmp", "/tmp"})
+ for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} {
+ os.Unsetenv(name)
+ }
+ // /var/tmp alone. /tmp is not offered: the tests build binaries into
+ // the temp root, and on a machine where /tmp is shared memory the move
+ // would put them there without anyone having asked.
+ moved, cleanup, err := isolateTempRoot([]string{"/var/tmp"})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -34,8 +51,13 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
-// isolateTempRoot makes sure no .carlos/config sits above the directory
-// t.TempDir() hands out, before any test asks for one.
+// tempRootPrefix names the directory isolateTempRoot makes. Its cleanup runs
+// only when the tests return: a log.Fatal, a panic or a timeout leaves that
+// one directory behind, and the line TestMain prints says where it is.
+const tempRootPrefix = "carlos-test-"
+
+// isolateTempRoot makes sure no .carlos/config sits above the directories
+// the tests' scratch space comes from, before any test asks for one.
//
// The tests pin HOME, but the CLI's config discovery (config.go
// walkConfigFiles) walks up from the working directory, and that directory
@@ -48,41 +70,81 @@ func TestMain(m *testing.M) {
// in the shared helpers is not the fix, because it switches discovery off
// for the tests that write a .carlos/config of their own.
//
-// A temp root with nothing above it is left alone, which is every CI run.
-// Otherwise the tests get a fresh directory under the first of bases that is
-// clear, through TMPDIR so that child processes follow: moved says where,
-// and the returned cleanup removes it. When none can be made the error says
-// so and TestMain stops.
+// There are two roots. os.TempDir() is TMPDIR. t.TempDir() is GOTMPDIR
+// when that is set and os.TempDir() only when it is not (testing.go
+// makeTempDir: os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern)), so a clear
+// TMPDIR says nothing about a GOTMPDIR under a home.
+//
+// Roots with nothing above them are left alone, which is every CI run.
+// Otherwise the tests get one fresh directory under the first of bases that
+// is clear, and each variable whose root was not clear is pointed at it, so
+// child processes follow. An explicit GOTMPDIR is pointed there rather than
+// cleared: cleared, t.TempDir() would fall back to TMPDIR, which may be the
+// very place the developer set GOTMPDIR to keep the tests' binaries off.
+// moved says where, and the returned cleanup removes it. When no directory
+// can be made the variables are put back, the error names the ones to set,
+// and TestMain stops.
func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) {
- root := os.TempDir()
- found := configAbove(root)
- if found == "" {
+ var why, names []string
+ if p := configAbove(os.TempDir()); p != "" {
+ why = append(why, fmt.Sprintf("%s is above the temp directory %s", p, os.TempDir()))
+ names = append(names, "TMPDIR")
+ }
+ if root := os.Getenv("GOTMPDIR"); root != "" {
+ if p := configAbove(root); p != "" {
+ why = append(why, fmt.Sprintf("%s is above GOTMPDIR %s", p, root))
+ names = append(names, "GOTMPDIR")
+ }
+ }
+ if len(names) == 0 {
return "", func() {}, nil
}
+ type was struct {
+ value string
+ set bool
+ }
+ before := map[string]was{}
+ for _, name := range names {
+ v, ok := os.LookupEnv(name)
+ before[name] = was{v, ok}
+ }
for _, base := range bases {
- dir, err := os.MkdirTemp(base, "carlos-test-")
+ dir, err := os.MkdirTemp(base, tempRootPrefix)
if err != nil {
continue
}
- os.Setenv("TMPDIR", dir)
- // Asked of os.TempDir() again rather than assumed: the base may sit
+ for _, name := range names {
+ os.Setenv(name, dir)
+ }
+ // Asked of both roots again rather than assumed: the base may sit
// under a config too, and not every platform reads TMPDIR.
- if configAbove(os.TempDir()) == "" {
- moved = fmt.Sprintf("carlos tests: %s is above the temp directory %s, so the tests work in %s instead", found, root, dir)
+ if configAbove(os.TempDir()) == "" && configAbove(os.Getenv("GOTMPDIR")) == "" {
+ moved = fmt.Sprintf("carlos tests: %s, so the tests work in %s instead (an aborted run leaves it behind)", strings.Join(why, " and "), dir)
return moved, func() { os.RemoveAll(dir) }, nil
}
os.Remove(dir)
+ for name, w := range before {
+ if w.set {
+ os.Setenv(name, w.value)
+ } else {
+ os.Unsetenv(name)
+ }
+ }
}
- return "", nil, fmt.Errorf("carlos tests: %s is above the temp directory %s and the CLI's config walk would read it; set TMPDIR to a directory with no .carlos/config above it", found, root)
+ return "", nil, fmt.Errorf("carlos tests: %s, and the CLI's config walk would read it. No directory could be made under %s to work in instead: set %s to a directory with no .carlos/config above it",
+ strings.Join(why, " and "), strings.Join(bases, " or "), strings.Join(names, " and "))
}
// configAbove is the first .carlos/config the CLI's discovery would read
-// walking up from dir, "" when there is none. The walk is over the path as
-// written, which is what discovery follows after a t.Chdir, and over the
-// path with its symlinks resolved, which is what it follows when PWD is
-// unset. Anything but "does not exist" counts as there: discovery fails on
-// a config it cannot read.
+// walking up from dir, "" when there is none or dir is "". The walk is over
+// the path as written, which is what discovery follows after a t.Chdir, and
+// over the path with its symlinks resolved, which is what it follows when
+// PWD is unset. Anything but "does not exist" counts as there: discovery
+// fails on a config it cannot read.
func configAbove(dir string) string {
+ if dir == "" {
+ return ""
+ }
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
@@ -106,22 +168,34 @@ func configAbove(dir string) string {
return ""
}
-// The guard, against a tree of its own: a temp root under a config moves to
-// the first base that is clear, the move is undone by the cleanup alone,
-// and with no clear base the binary is told to stop.
-func TestIsolateTempRoot(t *testing.T) {
- if p := configAbove(os.TempDir()); p != "" {
- t.Fatalf("TestMain let the tests run with %s above the temp directory", p)
+// TestMain's own promises, read back from inside a test: neither root has a
+// config above it, and none of the variables it drops came through.
+func TestMainLeavesNoOperatorState(t *testing.T) {
+ for _, root := range []string{os.TempDir(), os.Getenv("GOTMPDIR"), t.TempDir()} {
+ if p := configAbove(root); p != "" {
+ t.Errorf("TestMain let the tests run with %s above %s", p, root)
+ }
}
+ for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} {
+ if v, ok := os.LookupEnv(name); ok {
+ t.Errorf("%s=%q reached the tests", name, v)
+ }
+ }
+}
+
+// The guard, against a tree of its own. A root under a config moves to the
+// first base that is clear: TMPDIR, GOTMPDIR or both, whichever was under
+// it, and only those. The cleanup removes the directory and nothing else.
+// With no clear base the binary is told to stop and what to set.
+func TestIsolateTempRoot(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it")
}
-
home := t.TempDir()
planted := writeConfig(t, home, `{"console": "https://operator.example"}`)
under := filepath.Join(home, "cache", "tmp")
alsoUnder := filepath.Join(home, "var")
- clear := t.TempDir()
+ clear, otherClear := t.TempDir(), t.TempDir()
for _, d := range []string{under, alsoUnder} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
@@ -130,35 +204,87 @@ func TestIsolateTempRoot(t *testing.T) {
if got := configAbove(under); got != planted {
t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted)
}
+ bases := []string{filepath.Join(home, "missing"), alsoUnder, clear}
- t.Setenv("TMPDIR", under)
- said, cleanup, err := isolateTempRoot([]string{filepath.Join(home, "missing"), alsoUnder, clear})
- if err != nil {
- t.Fatal(err)
- }
- moved := os.TempDir()
- if filepath.Dir(moved) != clear || !strings.Contains(said, planted) || !strings.Contains(said, moved) {
- t.Fatalf("the temp root moved to %s (%q), want a directory under %s and both paths named", moved, said, clear)
- }
- if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
- t.Fatalf("the refused base kept %v", left)
+ for _, c := range []struct {
+ name string
+ tmpdir, gotmp string // as the run starts; "" is unset
+ moveTmp bool
+ moveGo bool
+ }{
+ {"TMPDIR under a config, no GOTMPDIR", under, "", true, false},
+ {"TMPDIR under a config, GOTMPDIR clear", under, otherClear, true, false},
+ {"GOTMPDIR under a config, TMPDIR clear", otherClear, under, false, true},
+ {"both under a config", under, alsoUnder, true, true},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ t.Setenv("TMPDIR", c.tmpdir)
+ t.Setenv("GOTMPDIR", c.gotmp)
+ said, cleanup, err := isolateTempRoot(bases)
+ if err != nil {
+ t.Fatal(err)
+ }
+ made, _ := os.ReadDir(clear)
+ if len(made) != 1 || !strings.HasPrefix(made[0].Name(), tempRootPrefix) {
+ t.Fatalf("the clear base holds %v, want one %s directory", made, tempRootPrefix)
+ }
+ moved := filepath.Join(clear, made[0].Name())
+ wantTmp, wantGo := c.tmpdir, c.gotmp
+ if c.moveTmp {
+ wantTmp = moved
+ }
+ if c.moveGo {
+ wantGo = moved
+ }
+ if os.TempDir() != wantTmp || os.Getenv("GOTMPDIR") != wantGo {
+ t.Fatalf("TMPDIR=%s GOTMPDIR=%s, want %s and %s", os.TempDir(), os.Getenv("GOTMPDIR"), wantTmp, wantGo)
+ }
+ if !strings.Contains(said, planted) || !strings.Contains(said, moved) ||
+ strings.Contains(said, "above GOTMPDIR") != c.moveGo || strings.Contains(said, "above the temp directory") != c.moveTmp {
+ t.Fatalf("said %q, want %s, %s and the roots that moved", said, planted, moved)
+ }
+ // What the guard is for: a test's own temp directory is clear now.
+ t.Run("a test's temp directory", func(t *testing.T) {
+ if p := configAbove(t.TempDir()); p != "" {
+ t.Fatalf("t.TempDir() is still under %s", p)
+ }
+ })
+ if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
+ t.Fatalf("the refused base kept %v", left)
+ }
+ cleanup()
+ if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the cleanup left %s (%v)", moved, err)
+ }
+ if _, err := os.Stat(clear); err != nil {
+ t.Fatalf("the cleanup took the base with it: %v", err)
+ }
+ })
}
- cleanup()
- if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("the cleanup left %s (%v)", moved, err)
+
+ // No clear base: an error naming the config and each variable to set,
+ // with both variables as they were.
+ t.Setenv("TMPDIR", otherClear)
+ t.Setenv("GOTMPDIR", under)
+ _, _, err := isolateTempRoot([]string{alsoUnder})
+ if err == nil || !strings.Contains(err.Error(), planted) || !strings.Contains(err.Error(), "set GOTMPDIR to") {
+ t.Fatalf("no clear base = %v, want an error naming %s and GOTMPDIR", err, planted)
}
- if _, err := os.Stat(clear); err != nil {
- t.Fatalf("the cleanup took the base with it: %v", err)
+ if os.TempDir() != otherClear || os.Getenv("GOTMPDIR") != under {
+ t.Fatalf("a refusal left TMPDIR=%s GOTMPDIR=%s", os.TempDir(), os.Getenv("GOTMPDIR"))
}
-
t.Setenv("TMPDIR", under)
- if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), planted) {
- t.Fatalf("no clear base = %v, want an error naming %s", err, planted)
+ if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), "set TMPDIR and GOTMPDIR to") {
+ t.Fatalf("no clear base, both roots = %v, want both variables named", err)
+ }
+ if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
+ t.Fatalf("the refused base kept %v", left)
}
- // A clear temp root is not moved at all.
+ // Clear roots are not moved at all.
t.Setenv("TMPDIR", clear)
- if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear {
- t.Fatalf("a clear temp root = %s, %q, %v; want it left alone", os.TempDir(), said, err)
+ t.Setenv("GOTMPDIR", otherClear)
+ if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear || os.Getenv("GOTMPDIR") != otherClear {
+ t.Fatalf("clear roots = %s, %s, %q, %v; want them left alone", os.TempDir(), os.Getenv("GOTMPDIR"), said, err)
}
}