diff --git a/internal/testutil/userdirs.go b/internal/testutil/userdirs.go new file mode 100644 index 000000000..861d1099c --- /dev/null +++ b/internal/testutil/userdirs.go @@ -0,0 +1,45 @@ +package testutil + +import "os" + +// userDirEnv lists every variable that os.UserHomeDir, os.UserConfigDir and +// os.UserCacheDir (and Zero's own config.UserConfigDir) read, across Linux, +// macOS and Windows. Setting only the XDG ones still leaves macOS resolving +// from HOME and Windows from USERPROFILE, APPDATA and LOCALAPPDATA. +var userDirEnv = []string{ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", +} + +// IsolateUserDirs points every per-user directory root at root so code under +// test cannot read the developer's real config, cache or home directory, or +// write to them. It is meant for a package's TestMain, which has no *testing.T +// (tests that need their own layout still use t.Setenv, which wins while it +// is active). The returned function restores the previous environment. +func IsolateUserDirs(root string) (restore func()) { + type saved struct { + value string + set bool + } + previous := make(map[string]saved, len(userDirEnv)) + for _, name := range userDirEnv { + value, set := os.LookupEnv(name) + previous[name] = saved{value: value, set: set} + _ = os.Setenv(name, root) + } + return func() { + for name, prior := range previous { + if prior.set { + _ = os.Setenv(name, prior.value) + } else { + _ = os.Unsetenv(name) + } + } + } +} diff --git a/internal/testutil/userdirs_test.go b/internal/testutil/userdirs_test.go new file mode 100644 index 000000000..335667160 --- /dev/null +++ b/internal/testutil/userdirs_test.go @@ -0,0 +1,48 @@ +package testutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsolateUserDirsRedirectsAndRestores(t *testing.T) { + // Seed distinctive values so restoring is observable, then make sure the + // helper hands them back (set ones restored, unset ones unset again). + t.Setenv("HOME", "before-home") + t.Setenv("XDG_STATE_HOME", "before-state") + os.Unsetenv("XDG_DATA_HOME") + + root := t.TempDir() + restore := IsolateUserDirs(root) + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + config, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + cache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + for name, dir := range map[string]string{"home": home, "config": config, "cache": cache} { + if rel, err := filepath.Rel(root, dir); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + t.Errorf("%s dir %q is outside the isolated root %q", name, dir, root) + } + } + + restore() + if got := os.Getenv("HOME"); got != "before-home" { + t.Errorf("HOME after restore = %q, want before-home", got) + } + if got := os.Getenv("XDG_STATE_HOME"); got != "before-state" { + t.Errorf("XDG_STATE_HOME after restore = %q, want before-state", got) + } + if _, set := os.LookupEnv("XDG_DATA_HOME"); set { + t.Error("XDG_DATA_HOME was unset before and must be unset again after restore") + } +} diff --git a/internal/tui/hermetic_test.go b/internal/tui/hermetic_test.go new file mode 100644 index 000000000..eddd92ca4 --- /dev/null +++ b/internal/tui/hermetic_test.go @@ -0,0 +1,68 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +func underRoot(root, dir string) bool { + rel, err := filepath.Rel(root, dir) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// Guards TestMain: the per-user directories every newModel reads (and the ones +// tests could write to) must resolve inside the isolated root on every OS. +func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) { + if isolatedUserDirsRoot == "" { + t.Fatal("TestMain did not isolate the user directories") + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + userConfig, err := config.UserConfigDir() + if err != nil { + t.Fatal(err) + } + cache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} { + if !underRoot(isolatedUserDirsRoot, dir) { + t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot) + } + } +} + +// newModel loads slash commands from /zero/commands. A fresh model +// must see none, and must see exactly what is planted in the isolated dir. +func TestNewModelLoadsUserCommandsOnlyFromIsolatedConfig(t *testing.T) { + if got := len(newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands); got != 0 { + t.Fatalf("fresh model loaded %d user command(s) from outside the test sandbox", got) + } + + userConfig, err := config.UserConfigDir() + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(userConfig, "zero", "commands") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "hermetic-probe.md") + if err := os.WriteFile(path, []byte("probe"), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(path) }) + + commands := newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands + if len(commands) != 1 || commands[0].Name != "hermetic-probe" { + t.Fatalf("user commands = %#v, want only the planted hermetic-probe", commands) + } +} diff --git a/internal/tui/main_test.go b/internal/tui/main_test.go new file mode 100644 index 000000000..81a3f0ce0 --- /dev/null +++ b/internal/tui/main_test.go @@ -0,0 +1,29 @@ +package tui + +import ( + "fmt" + "os" + "testing" + + "github.com/Gitlawb/zero/internal/testutil" +) + +// isolatedUserDirsRoot is where TestMain points every per-user directory. +var isolatedUserDirsRoot string + +// TestMain keeps the whole package off the developer's real home, config and +// cache directories. newModel loads the user's slash commands from the config +// dir, so without this every test sees (and could write to) real user state. +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "zero-tui-test-home-") + if err != nil { + fmt.Fprintln(os.Stderr, "tui tests: create isolated user dirs:", err) + os.Exit(1) + } + isolatedUserDirsRoot = root + restore := testutil.IsolateUserDirs(root) + code := m.Run() + restore() + _ = os.RemoveAll(root) + os.Exit(code) +}