diff --git a/internal/testutil/userdirs.go b/internal/testutil/userdirs.go new file mode 100644 index 000000000..82cfd9fdc --- /dev/null +++ b/internal/testutil/userdirs.go @@ -0,0 +1,96 @@ +package testutil + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// setenv is os.Setenv, swappable so tests can fail a redirect part way through. +var setenv = os.Setenv + +// 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. +// +// It fails closed: if any variable cannot be redirected, the variables it had +// already changed are restored and an error is returned, so the caller must +// not run the tests against a partially isolated environment. +func IsolateUserDirs(root string) (restore func() error, err error) { + type saved struct { + name string + value string + set bool + } + previous := make([]saved, 0, len(userDirEnv)) + restore = func() error { + var errs []error + for _, prior := range previous { + var err error + if prior.set { + err = os.Setenv(prior.name, prior.value) + } else { + err = os.Unsetenv(prior.name) + } + if err != nil { + errs = append(errs, fmt.Errorf("restore %s: %w", prior.name, err)) + } + } + return errors.Join(errs...) + } + for _, name := range userDirEnv { + value, set := os.LookupEnv(name) + if err := setenv(name, root); err != nil { + setErr := fmt.Errorf("isolate %s: %w", name, err) + return nil, errors.Join(setErr, restore()) + } + previous = append(previous, saved{name: name, value: value, set: set}) + } + return restore, nil +} + +// PathWithin reports whether path is root or lies beneath it, comparing the +// physical forms so symlinked temp dirs (/var vs /private/var on macOS) and +// Windows short names do not cause false results. Components that do not +// exist yet are kept as spelled under their nearest existing ancestor. +func PathWithin(root, path string) bool { + rel, err := filepath.Rel(physicalPath(root), physicalPath(path)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func physicalPath(path string) string { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + var missing []string + for current := path; ; { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + return filepath.Join(append([]string{resolved}, missing...)...) + } + parent := filepath.Dir(current) + if parent == current { + return path + } + missing = append([]string{filepath.Base(current)}, missing...) + current = parent + } +} diff --git a/internal/testutil/userdirs_test.go b/internal/testutil/userdirs_test.go new file mode 100644 index 000000000..9d6e18c32 --- /dev/null +++ b/internal/testutil/userdirs_test.go @@ -0,0 +1,141 @@ +package testutil + +import ( + "errors" + "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") + // t.Setenv registers restoration of the inherited value before it is + // cleared, so later tests in this process still see it. + t.Setenv("XDG_DATA_HOME", "") + os.Unsetenv("XDG_DATA_HOME") + + root := t.TempDir() + restore, err := IsolateUserDirs(root) + if err != nil { + t.Fatal(err) + } + + 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 !PathWithin(root, dir) { + t.Errorf("%s dir %q is outside the isolated root %q", name, dir, root) + } + } + + if err := restore(); err != nil { + t.Fatal(err) + } + 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") + } +} + +func TestIsolateUserDirsFailsClosed(t *testing.T) { + for _, name := range userDirEnv { + t.Setenv(name, "before-"+strings.ToLower(name)) + } + + // A NUL byte is rejected by Setenv on every supported platform, so the + // very first redirect fails. + restore, err := IsolateUserDirs("bad\x00root") + if err == nil { + if restore != nil { + _ = restore() + } + t.Fatal("IsolateUserDirs succeeded with a root Setenv must reject") + } + if restore != nil { + t.Error("IsolateUserDirs returned a restore func alongside an error") + } + for _, name := range userDirEnv { + if got, want := os.Getenv(name), "before-"+strings.ToLower(name); got != want { + t.Errorf("%s after failed isolation = %q, want %q", name, got, want) + } + } +} + +func TestIsolateUserDirsRollsBackPartialRedirect(t *testing.T) { + for _, name := range userDirEnv { + t.Setenv(name, "before-"+strings.ToLower(name)) + } + + // Let the first redirect (HOME) succeed, then fail the second, so the + // rollback has to undo a variable that really was changed. + failing := userDirEnv[1] + t.Cleanup(func() { setenv = os.Setenv }) + setenv = func(name, value string) error { + if name == failing { + return errors.New("injected setenv failure") + } + return os.Setenv(name, value) + } + + restore, err := IsolateUserDirs(t.TempDir()) + if err == nil { + if restore != nil { + _ = restore() + } + t.Fatalf("IsolateUserDirs succeeded although setting %s failed", failing) + } + if !strings.Contains(err.Error(), failing) { + t.Errorf("error %q does not name the failing variable %s", err, failing) + } + for _, name := range userDirEnv { + if got, want := os.Getenv(name), "before-"+strings.ToLower(name); got != want { + t.Errorf("%s after partial isolation = %q, want %q", name, got, want) + } + } +} + +func TestPathWithinComparesPhysicalPaths(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "root") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + alias := filepath.Join(base, "alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + for _, tc := range []struct { + root, path string + want bool + }{ + {root, root, true}, + {root, filepath.Join(alias, "not", "created", "yet"), true}, + {alias, filepath.Join(root, "child"), true}, + {root, base, false}, + {alias, filepath.Join(base, "sibling"), false}, + } { + if got := PathWithin(tc.root, tc.path); got != tc.want { + t.Errorf("PathWithin(%q, %q) = %v, want %v", tc.root, tc.path, got, tc.want) + } + } +} diff --git a/internal/tui/hermetic_test.go b/internal/tui/hermetic_test.go new file mode 100644 index 000000000..8adfd752c --- /dev/null +++ b/internal/tui/hermetic_test.go @@ -0,0 +1,63 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/testutil" +) + +// 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 !testutil.PathWithin(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..dd1fafed1 --- /dev/null +++ b/internal/tui/main_test.go @@ -0,0 +1,40 @@ +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, err := testutil.IsolateUserDirs(root) + if err != nil { + fmt.Fprintln(os.Stderr, "tui tests: isolate user dirs:", err) + _ = os.RemoveAll(root) + os.Exit(1) + } + code := m.Run() + if err := restore(); err != nil { + fmt.Fprintln(os.Stderr, "tui tests: restore user dirs:", err) + code = 1 + } + if err := os.RemoveAll(root); err != nil { + fmt.Fprintln(os.Stderr, "tui tests: remove isolated user dirs:", err) + code = 1 + } + os.Exit(code) +}