From 60c2a6ec8f282f3c405e102a27bc559e0ff05547 Mon Sep 17 00:00:00 2001 From: verse91 Date: Mon, 5 Oct 2026 16:28:47 +0700 Subject: [PATCH] fix(shell): prevent duplicate fnm multishell entries in PATH --- integration/shell/adapter.go | 48 +++++++- integration/shell/adapter_test.go | 41 +++++++ root/init.go | 17 +++ tests/tui/fnm_test.go | 194 ++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 tests/tui/fnm_test.go diff --git a/integration/shell/adapter.go b/integration/shell/adapter.go index 2d17865a..02f0f0e3 100644 --- a/integration/shell/adapter.go +++ b/integration/shell/adapter.go @@ -67,10 +67,52 @@ func Init(name string) { // BashAdapter implementation type BashAdapter struct{} +// clean child environment to avoid duplicate multishell entries and deduplicate path +func CleanChildEnv(envList []string, fd int, pid int) []string { + multishell := os.Getenv("FNM_MULTISHELL_PATH") + if multishell != "" { + _ = os.RemoveAll(multishell) + _ = os.RemoveAll(filepath.Dir(multishell)) + } + + var res []string + for _, env := range envList { + key, val, ok := strings.Cut(env, "=") + if !ok { + continue + } + if key == "FNM_MULTISHELL_PATH" { + continue + } + if key == "PATH" { + entries := filepath.SplitList(val) + seen := make(map[string]bool, len(entries)) + var clean []string + for _, entry := range entries { + if entry == "" { + continue + } + if strings.Contains(entry, "fnm_multishells") || (multishell != "" && (entry == multishell || entry == filepath.Join(multishell, "bin"))) { + continue + } + if !seen[entry] { + seen[entry] = true + clean = append(clean, entry) + } + } + res = append(res, "PATH="+strings.Join(clean, string(os.PathListSeparator))) + continue + } + res = append(res, env) + } + + return append(res, "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) +} + func (b *BashAdapter) GetName() string { return "bash" } func (b *BashAdapter) GetShellPath() string { return "bash" } func (b *BashAdapter) GetEnv(fd int, pid int) []string { - return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) + return CleanChildEnv(os.Environ(), fd, pid) } func (b *BashAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte { return ReplaceLine([]byte(selected), cursorFromEnd) @@ -85,7 +127,7 @@ type ZshAdapter struct{} func (z *ZshAdapter) GetName() string { return "zsh" } func (z *ZshAdapter) GetShellPath() string { return "zsh" } func (z *ZshAdapter) GetEnv(fd int, pid int) []string { - return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) + return CleanChildEnv(os.Environ(), fd, pid) } func (z *ZshAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte { return ReplaceLine([]byte(selected), cursorFromEnd) @@ -145,7 +187,7 @@ type FishAdapter struct{} func (f *FishAdapter) GetName() string { return "fish" } func (f *FishAdapter) GetShellPath() string { return "fish" } func (f *FishAdapter) GetEnv(fd int, pid int) []string { - return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid)) + return CleanChildEnv(os.Environ(), fd, pid) } func (f *FishAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte { return ReplaceLine([]byte(selected), cursorFromEnd) diff --git a/integration/shell/adapter_test.go b/integration/shell/adapter_test.go index d6e55781..15ef3298 100644 --- a/integration/shell/adapter_test.go +++ b/integration/shell/adapter_test.go @@ -243,3 +243,44 @@ func TestPrepareSelectSequenceUsesReplaceLine(t *testing.T) { } } } + +func TestCleanChildEnv(t *testing.T) { + inputEnv := []string{ + "USER=test", + "FNM_MULTISHELL_PATH=/Users/user/.local/state/fnm_multishells/5487_123/bin", + "PATH=/Users/user/.local/state/fnm_multishells/5487_123/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/bin", + } + + got := CleanChildEnv(inputEnv, 13, 9999) + + var hasFnmVar bool + var pathVal string + var hasFd, hasPid bool + + for _, e := range got { + if e == "FNM_MULTISHELL_PATH" || len(e) > 19 && e[:19] == "FNM_MULTISHELL_PATH=" { + hasFnmVar = true + } + if len(e) > 5 && e[:5] == "PATH=" { + pathVal = e[5:] + } + if e == "IRIS_FD=13" { + hasFd = true + } + if e == "IRIS_PID=9999" { + hasPid = true + } + } + + if hasFnmVar { + t.Errorf("CleanChildEnv must unset FNM_MULTISHELL_PATH, got: %v", got) + } + if !hasFd || !hasPid { + t.Errorf("CleanChildEnv must include IRIS_FD=13 and IRIS_PID=9999, got: %v", got) + } + wantPath := "/usr/local/bin:/usr/bin:/bin" + if pathVal != wantPath { + t.Errorf("CleanChildEnv PATH = %q; want %q", pathVal, wantPath) + } +} + diff --git a/root/init.go b/root/init.go index 8b3d8a97..b33776f1 100644 --- a/root/init.go +++ b/root/init.go @@ -35,6 +35,10 @@ fi # prompt to complete, and exec'ing here would seize the tty from the real iris if [[ -o interactive ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then export IRIS_ACTIVE_SHELL="zsh" + if [ -n "$FNM_MULTISHELL_PATH" ]; then + path=(${path:#*fnm_multishells*}) + unset FNM_MULTISHELL_PATH + fi exec iris fi @@ -82,6 +86,15 @@ fi # prompt to complete, and exec'ing here would seize the tty from the real iris if [[ $- == *i* ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then export IRIS_ACTIVE_SHELL="bash" + if [ -n "$FNM_MULTISHELL_PATH" ]; then + PATH=":$PATH:" + PATH="${PATH//:$FNM_MULTISHELL_PATH\/bin:/:}" + PATH="${PATH//:$FNM_MULTISHELL_PATH:/:}" + PATH="${PATH#:}" + PATH="${PATH%%:}" + export PATH + unset FNM_MULTISHELL_PATH + fi exec iris fi @@ -123,6 +136,10 @@ end if status is-interactive; and not set -q IRIS_PID; and not set -q IRIS_RESCUE set -gx IRIS_ACTIVE_SHELL "fish" + if set -q FNM_MULTISHELL_PATH + set -gx PATH (string match -v "*fnm_multishells*" $PATH) + set -e FNM_MULTISHELL_PATH + end exec iris end diff --git a/tests/tui/fnm_test.go b/tests/tui/fnm_test.go new file mode 100644 index 00000000..4d6da50a --- /dev/null +++ b/tests/tui/fnm_test.go @@ -0,0 +1,194 @@ +package tui + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gaurav-Gosain/tuitest" +) + +func TestFnmMultishellNotDuplicatedInPath(t *testing.T) { + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh not installed") + } + + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + bin := binary(t) + + outerFnmPath := filepath.Join(home, ".local/state/fnm_multishells/5487_outer/bin") + innerFnmPath := filepath.Join(home, ".local/state/fnm_multishells/5450_inner/bin") + _ = os.MkdirAll(outerFnmPath, 0o755) + _ = os.MkdirAll(innerFnmPath, 0o755) + + // simulate .zshrc where fnm runs before iris init + zshrc := ` +PROMPT='> ' +RPROMPT='' +unsetopt PROMPT_SP +if [ -z "$IRIS_PID" ]; then + export FNM_MULTISHELL_PATH="` + outerFnmPath + `" + export PATH="$FNM_MULTISHELL_PATH:$PATH" +fi +eval "$(` + bin + ` init zsh)" +if [ -n "$IRIS_PID" ]; then + export FNM_MULTISHELL_PATH="` + innerFnmPath + `" + export PATH="$FNM_MULTISHELL_PATH:$PATH" +fi +` + if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0o644); err != nil { + t.Fatal(err) + } + + env := []string{ + "HOME=" + home, + "ZDOTDIR=" + home, + "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), + "XDG_DATA_HOME=" + filepath.Join(home, ".local/share"), + "XDG_CACHE_HOME=" + filepath.Join(home, ".cache"), + "SHELL=/bin/zsh", + "IRIS_ACTIVE_SHELL=zsh", + "PATH=" + os.Getenv("PATH"), + "TERM=xterm-256color", + } + + term := tuitest.StartT(t, []string{bin}, + tuitest.WithSize(cols, rows), + tuitest.WithEnv(env...), + tuitest.WithDir(home), + ) + defer func() { _ = term.Close() }() + + if err := term.WaitForText(">", 20*time.Second); err != nil { + t.Fatalf("iris never reached a prompt: %v\n%s", err, term.Snapshot()) + } + + outLog := filepath.Join(home, "path_out.txt") + cmd := "echo $PATH > " + outLog + "\n" + if err := term.SendKeys(cmd); err != nil { + t.Fatal(err) + } + + // wait for path dump file to be written by the shell + var writtenPath string + for range 20 { + time.Sleep(100 * time.Millisecond) + data, err := os.ReadFile(outLog) + if err == nil && len(data) > 0 { + writtenPath = string(data) + break + } + } + + if writtenPath == "" { + t.Fatalf("path file was not written, screen:\n%s", screen(term)) + } + + if strings.Contains(writtenPath, "5487_outer") { + t.Fatalf("PATH should not contain dead outer fnm multishell (5487_outer), got:\n%s", writtenPath) + } + if !strings.Contains(writtenPath, "5450_inner") { + t.Fatalf("PATH must contain active inner fnm multishell (5450_inner), got:\n%s", writtenPath) + } +} + +func TestRealFnmWithIris(t *testing.T) { + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh not installed") + } + + fnmBin := "/tmp/fnm-bin/fnm" + if _, err := os.Stat(fnmBin); err != nil { + if path, errLook := exec.LookPath("fnm"); errLook == nil { + fnmBin = path + } else { + t.Skip("fnm binary not available") + } + } + + home := t.TempDir() + for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatal(err) + } + } + + bin := binary(t) + + // exact reproduction steps from issue #170 + zshrc := ` +PROMPT='> ' +RPROMPT='' +unsetopt PROMPT_SP +eval "$(` + fnmBin + ` env --use-on-cd --shell zsh)" +eval "$(` + bin + ` init zsh)" +` + if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0o644); err != nil { + t.Fatal(err) + } + + env := []string{ + "HOME=" + home, + "ZDOTDIR=" + home, + "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), + "XDG_DATA_HOME=" + filepath.Join(home, ".local/share"), + "XDG_CACHE_HOME=" + filepath.Join(home, ".cache"), + "SHELL=/bin/zsh", + "IRIS_ACTIVE_SHELL=zsh", + "PATH=" + os.Getenv("PATH"), + "TERM=xterm-256color", + } + + term := tuitest.StartT(t, []string{bin}, + tuitest.WithSize(cols, rows), + tuitest.WithEnv(env...), + tuitest.WithDir(home), + ) + defer func() { _ = term.Close() }() + + if err := term.WaitForText(">", 20*time.Second); err != nil { + t.Fatalf("iris never reached a prompt: %v\n%s", err, term.Snapshot()) + } + + outLog := filepath.Join(home, "path_out.txt") + cmd := "echo $PATH > " + outLog + "\n" + if err := term.SendKeys(cmd); err != nil { + t.Fatal(err) + } + + var writtenPath string + for range 20 { + time.Sleep(100 * time.Millisecond) + data, err := os.ReadFile(outLog) + if err == nil && len(data) > 0 { + writtenPath = string(data) + break + } + } + + if writtenPath == "" { + t.Fatalf("path file was not written, screen:\n%s", screen(term)) + } + + parts := strings.Split(strings.TrimSpace(writtenPath), ":") + fnmCount := 0 + for _, p := range parts { + if strings.Contains(p, "fnm_multishells") { + fnmCount++ + } + } + + if fnmCount != 1 { + t.Fatalf("expected exactly 1 fnm_multishells entry in PATH, got %d:\n%s", fnmCount, writtenPath) + } +} +