fix(testenv): isolate config locks from the real user home

This commit is contained in:
Lancy committed 2026-10-28 11:43:21 +08:00
1 parent cc08d5552b
commit 9e68ee8f25
8 files changed
+222 -7

No files matched your search

+16
View File
@@ -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
+8 -4
View File
@@ -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-*")
@@ -34,6 +35,7 @@ func IsolateUserState() (func(), error) {
set := map[string]string{
"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"),
@@ -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)
})
+54
View File
@@ -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)
@@ -39,6 +42,7 @@ func TestIsolateUserStateRedirectsAndRestoresCallerEnvironment(t *testing.T) {
}
wantIsolatedEnvironment := map[string]string{
"HOME": isolateHome,
"REASONIX_TEST_HOME": isolateHome,
"USERPROFILE": isolateHome,
"XDG_CONFIG_HOME": filepath.Join(isolateHome, ".config"),
"XDG_CACHE_HOME": filepath.Join(isolateHome, ".cache"),
@@ -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)
}
}
+21
View File
@@ -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) }
}
+27
View File
@@ -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)
}
}
+5 -1
View File
@@ -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
+6 -2
View File
@@ -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")
+85
View File
@@ -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)
}
})
}
}