From 9e68ee8f25879d82c3da8c94650245394c76e3ac Mon Sep 17 00:00:00 2001 From: Lancy Date: Wed, 28 Oct 2026 11:43:21 +0800 Subject: [PATCH] fix(testenv): isolate config locks from the real user home --- CONTRIBUTING.md | 16 +++++ internal/base/testenv/home.go | 26 ++++---- internal/base/testenv/home_test.go | 68 ++++++++++++++++++-- internal/base/testscope/home.go | 21 ++++++ internal/base/testscope/home_test.go | 27 ++++++++ internal/contract/config/main_test.go | 6 +- internal/contract/config/mutate.go | 8 ++- internal/contract/config/mutate_test.go | 85 +++++++++++++++++++++++++ 8 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 internal/base/testscope/home.go create mode 100644 internal/base/testscope/home_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c5048cdd8..5895e901a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package go test ./internal/tools/builtin/ -run TestGrep # one test ``` +`testenv.IsolateUserState` installs a disposable user home and config-edit lock +scope. Default-path tests can clear `REASONIX_HOME` without losing the test +registry, while explicitly isolated Reasonix homes retain their own locks. + +`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that +skips the isolation guard must install the inherited home with +`testscope.InstallHome` before running tests. + +The environment variable alone cannot activate a test scope. Production +executables still use the OS account's home for shared config locks. + +The guard removes its scratch home on normal completion. Scratch homes are +created under the original home, so an interrupted run can still leave +`.reasonix-test-home-*` directories; this is separate from persistent +config-edit locks in the real `.reasonix/locks` directory. + ### Code style - `gofmt` is enforced by CI — format before committing diff --git a/internal/base/testenv/home.go b/internal/base/testenv/home.go index 30525a4d76..4e3c97f2cf 100644 --- a/internal/base/testenv/home.go +++ b/internal/base/testenv/home.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "sync" + + "reasonix/internal/base/testscope" ) // TestingM is the subset of testing.M needed by RunWithIsolatedUserState. @@ -17,10 +19,9 @@ type savedEnvironment struct { set bool } -// IsolateUserState redirects default user-scoped Reasonix paths to a disposable -// home and clears inherited explicit path overrides. Tests may still override -// any of these variables for a focused scenario after this process-level guard -// is installed. +// IsolateUserState redirects user-scoped paths and test-only config edit locks +// to a disposable home, clearing inherited explicit path overrides. Tests may +// override these variables for a focused scenario after this guard is installed. func IsolateUserState() (func(), error) { originalHome, _ := os.UserHomeDir() home, err := os.MkdirTemp(originalHome, ".reasonix-test-home-*") @@ -32,13 +33,14 @@ func IsolateUserState() (func(), error) { } set := map[string]string{ - "HOME": home, - "USERPROFILE": home, - "XDG_CONFIG_HOME": filepath.Join(home, ".config"), - "XDG_CACHE_HOME": filepath.Join(home, ".cache"), - "XDG_STATE_HOME": filepath.Join(home, ".local", "state"), - "AppData": filepath.Join(home, "AppData", "Roaming"), - "LocalAppData": filepath.Join(home, "AppData", "Local"), + "HOME": home, + "USERPROFILE": home, + "REASONIX_TEST_HOME": home, + "XDG_CONFIG_HOME": filepath.Join(home, ".config"), + "XDG_CACHE_HOME": filepath.Join(home, ".cache"), + "XDG_STATE_HOME": filepath.Join(home, ".local", "state"), + "AppData": filepath.Join(home, "AppData", "Roaming"), + "LocalAppData": filepath.Join(home, "AppData", "Local"), } unset := []string{ "REASONIX_HOME", @@ -82,9 +84,11 @@ func IsolateUserState() (func(), error) { } } + restoreScope := testscope.InstallHome(home) var once sync.Once return func() { once.Do(func() { + restoreScope() restore() _ = os.RemoveAll(home) }) diff --git a/internal/base/testenv/home_test.go b/internal/base/testenv/home_test.go index 6c1603d3e4..220f972999 100644 --- a/internal/base/testenv/home_test.go +++ b/internal/base/testenv/home_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "reasonix/internal/base/testscope" ) func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) { @@ -20,6 +22,7 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) { "REASONIX_HOME": filepath.Join(callerHome, "explicit-reasonix-home"), "REASONIX_STATE_HOME": filepath.Join(callerHome, "caller-state"), "REASONIX_CACHE_HOME": filepath.Join(callerHome, "caller-cache"), + "REASONIX_TEST_HOME": filepath.Join(callerHome, "caller-test-home"), } for key, value := range callerEnvironment { t.Setenv(key, value) @@ -38,13 +41,14 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) { t.Fatalf("isolated home %q is not contained by caller home %q", isolateHome, callerHome) } wantIsolatedEnvironment := map[string]string{ - "HOME": isolateHome, - "USERPROFILE": isolateHome, - "XDG_CONFIG_HOME": filepath.Join(isolateHome, ".config"), - "XDG_CACHE_HOME": filepath.Join(isolateHome, ".cache"), - "XDG_STATE_HOME": filepath.Join(isolateHome, ".local", "state"), - "AppData": filepath.Join(isolateHome, "AppData", "Roaming"), - "LocalAppData": filepath.Join(isolateHome, "AppData", "Local"), + "HOME": isolateHome, + "REASONIX_TEST_HOME": isolateHome, + "USERPROFILE": isolateHome, + "XDG_CONFIG_HOME": filepath.Join(isolateHome, ".config"), + "XDG_CACHE_HOME": filepath.Join(isolateHome, ".cache"), + "XDG_STATE_HOME": filepath.Join(isolateHome, ".local", "state"), + "AppData": filepath.Join(isolateHome, "AppData", "Roaming"), + "LocalAppData": filepath.Join(isolateHome, "AppData", "Local"), } for key, want := range wantIsolatedEnvironment { if got := os.Getenv(key); got != want { @@ -67,3 +71,53 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) { t.Fatalf("isolated home still exists after cleanup: %v", err) } } + +func TestIsolateUserStateRestoresUnsetTestHomeAndNestedScopes(t *testing.T) { + callerScope := testscope.Home() + t.Setenv("REASONIX_TEST_HOME", "") + if err := os.Unsetenv("REASONIX_TEST_HOME"); err != nil { + t.Fatal(err) + } + cleanup, err := IsolateUserState() + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanup) + outer := os.Getenv("REASONIX_TEST_HOME") + if got := testscope.Home(); got != outer { + t.Fatalf("installed scope = %q, want %q", got, outer) + } + nestedCleanup, err := IsolateUserState() + if err != nil { + t.Fatal(err) + } + t.Cleanup(nestedCleanup) + inner := os.Getenv("REASONIX_TEST_HOME") + if inner == "" || inner == outer { + t.Fatalf("nested test home = %q, outer = %q", inner, outer) + } + if got := testscope.Home(); got != inner { + t.Fatalf("nested scope = %q, want %q", got, inner) + } + nestedCleanup() + if got := os.Getenv("REASONIX_TEST_HOME"); got != outer { + t.Fatalf("restored test home = %q, want %q", got, outer) + } + if got := testscope.Home(); got != outer { + t.Fatalf("restored scope = %q, want %q", got, outer) + } + if _, err := os.Stat(inner); !os.IsNotExist(err) { + t.Fatalf("nested test home remains after cleanup: %v", err) + } + cleanup() + cleanup() + if got := testscope.Home(); got != callerScope { + t.Fatalf("scope after cleanup = %q, want %q", got, callerScope) + } + if _, ok := os.LookupEnv("REASONIX_TEST_HOME"); ok { + t.Fatal("test home remained set after cleanup") + } + if _, err := os.Stat(outer); !os.IsNotExist(err) { + t.Fatalf("test home remains after cleanup: %v", err) + } +} diff --git a/internal/base/testscope/home.go b/internal/base/testscope/home.go new file mode 100644 index 0000000000..db4c35e6d0 --- /dev/null +++ b/internal/base/testscope/home.go @@ -0,0 +1,21 @@ +// Package testscope holds the user-state home explicitly installed by a test harness. +package testscope + +import "sync/atomic" + +var userHome atomic.Pointer[string] + +// Home returns the installed user-state home, or empty outside a test scope. +func Home() string { + if home := userHome.Load(); home != nil { + return *home + } + return "" +} + +// InstallHome installs a test harness's user-state home and returns its restore. +// Restore nested scopes in reverse installation order. +func InstallHome(home string) func() { + previous := userHome.Swap(&home) + return func() { userHome.Store(previous) } +} diff --git a/internal/base/testscope/home_test.go b/internal/base/testscope/home_test.go new file mode 100644 index 0000000000..16e64dbf35 --- /dev/null +++ b/internal/base/testscope/home_test.go @@ -0,0 +1,27 @@ +package testscope + +import "testing" + +func TestInstalledHomesRestoreInReverseOrder(t *testing.T) { + if got := Home(); got != "" { + t.Fatalf("uninstalled home = %q", got) + } + t.Run("outer", func(t *testing.T) { + t.Cleanup(InstallHome("outer")) + if got := Home(); got != "outer" { + t.Fatalf("installed home = %q, want outer", got) + } + t.Run("inner", func(t *testing.T) { + t.Cleanup(InstallHome("inner")) + if got := Home(); got != "inner" { + t.Fatalf("nested home = %q, want inner", got) + } + }) + if got := Home(); got != "outer" { + t.Fatalf("restored home = %q, want outer", got) + } + }) + if got := Home(); got != "" { + t.Fatalf("home after restoration = %q", got) + } +} diff --git a/internal/contract/config/main_test.go b/internal/contract/config/main_test.go index c498259455..1f384114d3 100644 --- a/internal/contract/config/main_test.go +++ b/internal/contract/config/main_test.go @@ -6,11 +6,15 @@ import ( "testing" "reasonix/internal/base/testenv" + "reasonix/internal/base/testscope" ) func TestMain(m *testing.M) { if os.Getenv("REASONIX_CONFIG_LOCK_HELPER") == "1" { - os.Exit(m.Run()) + restore := testscope.InstallHome(os.Getenv("REASONIX_TEST_HOME")) + code := m.Run() + restore() + os.Exit(code) } // RunWithIsolatedUserState redirects every path-shaped location, but the OS // keyring has no environment variable in front of it, so legacy migration diff --git a/internal/contract/config/mutate.go b/internal/contract/config/mutate.go index d0c0a258b6..e639705b30 100644 --- a/internal/contract/config/mutate.go +++ b/internal/contract/config/mutate.go @@ -16,6 +16,7 @@ import ( "time" "reasonix/internal/base/filelock" + "reasonix/internal/base/testscope" ) // userEditMu serializes in-process read-modify-write cycles. The public lock @@ -339,9 +340,12 @@ func configEditLockRegistryDir() (string, error) { return "", fmt.Errorf("lock config edits: OS user identity unavailable") } digest := sha256.Sum256([]byte(identity)) - // Per-user home from the account database, not $HOME or TMPDIR; a shared - // /tmp path let another identity pre-create it. The owner check backs this up. + // Production locks use the account database, not HOME or TMPDIR; the owner + // check also rejects a registry another identity pre-created. home := strings.TrimSpace(current.HomeDir) + if testHome := testscope.Home(); testHome != "" { + home = testHome + } if home == "" { if runtime.GOOS == "windows" { return "", fmt.Errorf("lock config edits: OS user home unavailable") diff --git a/internal/contract/config/mutate_test.go b/internal/contract/config/mutate_test.go index f562bee92a..6996a57e1c 100644 --- a/internal/contract/config/mutate_test.go +++ b/internal/contract/config/mutate_test.go @@ -16,6 +16,7 @@ import ( "time" "reasonix/internal/base/testenv" + "reasonix/internal/base/testscope" ) // TestLockUserConfigEditsSerializesRMW drives concurrent load-modify-save @@ -617,6 +618,9 @@ func TestAcquireConfigEditLockPathTightensOwnLockDir(t *testing.T) { // TestConfigEditLockRegistryDirIsUnderUserHome: the lock directory must not be a // shared, predictable /tmp path another identity could create first. func TestConfigEditLockRegistryDirIsUnderUserHome(t *testing.T) { + restore := testscope.InstallHome("") + t.Cleanup(restore) + t.Setenv("REASONIX_TEST_HOME", testenv.TempDir(t)) current, err := osuser.Current() if err != nil || strings.TrimSpace(current.HomeDir) == "" { t.Skip("OS user home unavailable") @@ -662,6 +666,8 @@ func TestIsolatedHomeKeepsItsConfigEditLocksInsideIt(t *testing.T) { // Only what the isolated home holds moves: a file elsewhere (a project's // reasonix.toml) keeps its lock where every other launch will look for it. func TestIsolatedHomeLeavesOtherFilesLockedUnderTheAccountHome(t *testing.T) { + restore := testscope.InstallHome("") + t.Cleanup(restore) t.Setenv("REASONIX_HOME", testenv.TempDir(t)) elsewhere := filepath.Join(testenv.TempDir(t), "reasonix.toml") lock, err := configFileEditLockPath(elsewhere) @@ -705,3 +711,82 @@ func TestPathWithin(t *testing.T) { } } } + +func TestIsolatedTestUserStateContainsConfigEditLocks(t *testing.T) { + cleanup, err := testenv.IsolateUserState() + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanup) + home := os.Getenv("HOME") + for _, path := range []string{UserConfigPath(), filepath.Join(testenv.TempDir(t), "reasonix.toml")} { + lock, err := configFileEditLockPath(path) + if err != nil { + t.Fatal(err) + } + t.Logf("config %q chooses lock %q with isolated HOME %q", path, lock, home) + if !homeHolds(home, lock) { + t.Errorf("config edit lock escaped isolated test home: %q", lock) + continue + } + unlock, err := LockConfigFileEdits(path) + if err != nil { + t.Fatal(err) + } + unlock() + if _, err := os.Stat(lock); err != nil { + t.Fatalf("lock file was not created in isolated home: %v", err) + } + } +} + +func TestIsolatedTestUserStateConfigEditLocksInChildProcess(t *testing.T) { + if os.Getenv("REASONIX_CONFIG_TEST_SCOPE_HELPER") == "1" { + path := os.Getenv("REASONIX_CONFIG_TEST_SCOPE_PATH") + if path == "" { + path = UserConfigPath() + } + lock, err := configFileEditLockPath(path) + if err != nil { + t.Fatal(err) + } + if !homeHolds(os.Getenv("REASONIX_TEST_HOME"), lock) { + t.Fatalf("child process lock escaped isolated test home: %q", lock) + } + unlock, err := LockConfigFileEdits(path) + if err != nil { + t.Fatal(err) + } + unlock() + t.Logf("child config %q uses isolated lock %q", path, lock) + return + } + cleanup, err := testenv.IsolateUserState() + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanup) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, scenario := range []struct { + name string + path string + }{ + {name: "default"}, + {name: "project", path: filepath.Join(testenv.TempDir(t), "reasonix.toml")}, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Setenv("REASONIX_CONFIG_LOCK_HELPER", "1") + t.Setenv("REASONIX_CONFIG_TEST_SCOPE_HELPER", "1") + t.Setenv("REASONIX_CONFIG_TEST_SCOPE_PATH", scenario.path) + cmd := exec.Command(exe, "-test.run=^TestIsolatedTestUserStateConfigEditLocksInChildProcess$", "-test.v") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("child process: %v\n%s", err, output) + } else { + t.Logf("%s", output) + } + }) + } +}