diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 2ae6d90f..052dd8ff 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -13,6 +13,24 @@ import ( "github.com/spf13/cobra" ) +// TestMain clears user config and pager env vars so host settings don't break cmd tests. +func TestMain(m *testing.M) { + for _, env := range os.Environ() { + if k, _, ok := strings.Cut(env, "="); ok { + if strings.HasPrefix(k, "DIFFM_") && !strings.HasPrefix(k, "DIFFM_TEST_") { + _ = os.Unsetenv(k) + continue + } + switch k { + case "PAGER", "GIT_PAGER", "COLUMNS", "NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE", + "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE": + _ = os.Unsetenv(k) + } + } + } + os.Exit(m.Run()) +} + func TestRootCmdFlags(t *testing.T) { flags := []struct { name string diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 5af4b462..0dc454d1 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -9,6 +9,14 @@ import ( "testing" ) +// TestMain clears Git pointer env vars so the host repo state doesn't leak into tests. +func TestMain(m *testing.M) { + for _, k := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} { + _ = os.Unsetenv(k) + } + os.Exit(m.Run()) +} + func TestIsGitRepository(t *testing.T) { cwd, err := os.Getwd() if err != nil { diff --git a/internal/pager/pager_test.go b/internal/pager/pager_test.go index e8817d8b..d69497f7 100644 --- a/internal/pager/pager_test.go +++ b/internal/pager/pager_test.go @@ -9,6 +9,14 @@ import ( "testing" ) +// TestMain clears pager env vars so host settings don't change test expectations. +func TestMain(m *testing.M) { + for _, k := range []string{"DIFFM_NO_PAGER", "GIT_PAGER", "PAGER"} { + _ = os.Unsetenv(k) + } + os.Exit(m.Run()) +} + func TestPager_Disabled(t *testing.T) { p, w := Start(true) if p != nil { diff --git a/tests/e2e/cli_test.go b/tests/e2e/cli_test.go index d9b4a3e9..a457597a 100644 --- a/tests/e2e/cli_test.go +++ b/tests/e2e/cli_test.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "testing" @@ -43,9 +44,27 @@ func TestMain(m *testing.M) { panic("failed to build diffm: " + string(out) + ": " + err.Error()) } + sanitizeTestEnv() os.Exit(m.Run()) } +// sanitizeTestEnv strips user config and pager env vars so shell settings don't bleed into e2e runs. +func sanitizeTestEnv() { + for _, env := range os.Environ() { + if k, _, ok := strings.Cut(env, "="); ok { + if strings.HasPrefix(k, "DIFFM_") && !strings.HasPrefix(k, "DIFFM_TEST_") { + _ = os.Unsetenv(k) + continue + } + switch k { + case "PAGER", "GIT_PAGER", "COLUMNS", "NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE", + "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE": + _ = os.Unsetenv(k) + } + } + } +} + // testdataDir returns the absolute path to tests/testdata/. func testdataDir(t *testing.T) string { t.Helper() @@ -81,9 +100,27 @@ func fixtureFiles(t *testing.T, name string) (string, string) { return oldPath, newPath } -// runDiffm runs the diffm binary with the given args. +// isolatedEnv strips Git repo pointers and blocks host git configs so subprocesses run clean. +func isolatedEnv() []string { + env := slices.DeleteFunc(os.Environ(), func(kv string) bool { + k, _, ok := strings.Cut(kv, "=") + if !ok { + return true + } + switch k { + case "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM": + return true + default: + return false + } + }) + return append(env, "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1") +} + +// runDiffm runs the diffm binary with the given args in an isolated environment. func runDiffm(args ...string) (stdout, stderr string, err error) { cmd := exec.Command(binaryPath, args...) + cmd.Env = isolatedEnv() var outBuf, errBuf strings.Builder cmd.Stdout = &outBuf cmd.Stderr = &errBuf @@ -536,6 +573,35 @@ func TestCLI_EnvVarDefaults(t *testing.T) { } } +func TestCLI_SubprocessEnvironmentIsolation(t *testing.T) { + t.Setenv("GIT_DIR", "/invalid/git/dir") + t.Setenv("GIT_WORK_TREE", "/invalid/work/tree") + t.Setenv("GIT_INDEX_FILE", "/invalid/index/file") + + env := isolatedEnv() + for _, kv := range env { + k, _, _ := strings.Cut(kv, "=") + if k == "GIT_DIR" || k == "GIT_WORK_TREE" || k == "GIT_INDEX_FILE" { + t.Errorf("isolatedEnv leaked %s", k) + } + } + if !slices.Contains(env, "GIT_CONFIG_GLOBAL="+os.DevNull) { + t.Errorf("isolatedEnv missing GIT_CONFIG_GLOBAL=%s", os.DevNull) + } + if !slices.Contains(env, "GIT_CONFIG_NOSYSTEM=1") { + t.Error("isolatedEnv missing GIT_CONFIG_NOSYSTEM=1") + } + + oldPath, newPath := fixtureFiles(t, sampleFixture(t)) + stdout, stderr, err := runDiffm(oldPath, newPath) + if err != nil { + t.Fatalf("runDiffm failed under poisoned Git env: %v\nstderr: %s", err, stderr) + } + if stdout == "" { + t.Fatal("expected non-empty diff output under isolated env") + } +} + func TestCLI_GitMode_TypoFailFast(t *testing.T) { // In the current git repo, diffm with a typo file should fail fast _, stderr, err := runDiffm("HEAD", "nonexistent_typo_file_12345.go")