261015.1 · c8c2d0828a94a00879ceb6f95112a42e3ea322cf
Fix Windows shell detection with explicit --with-shell
Close #4936
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
reading declarations
Changes to src/util/. All files
@@ -44,19 +44,21 @@ func NewExecutor(withShell string) *Executor { shellType := shellTypeUnknown basename := filepath.Base(shell)- if len(args) > 0 {- args = args[1:]- } else if strings.HasPrefix(basename, "cmd") {+ defaultArgs := []string{"-c"}+ if strings.HasPrefix(basename, "cmd") { shellType = shellTypeCmd- args = []string{"/s/c"}+ defaultArgs = []string{"/s/c"} } else if strings.HasPrefix(basename, "pwsh") { shellType = shellTypePwsh- args = []string{"-NoProfile", "-Command"}+ defaultArgs = []string{"-NoProfile", "-Command"} } else if strings.HasPrefix(basename, "powershell") { shellType = shellTypePowerShell- args = []string{"-NoProfile", "-Command"}+ defaultArgs = []string{"-NoProfile", "-Command"}+ }+ if len(args) > 0 {+ args = args[1:] } else {- args = []string{"-c"}+ args = defaultArgs } return &Executor{shell: shell, shellType: shellType, args: args} }
@@ -0,0 +1,203 @@+//go:build windows++package util++import (+ "bytes"+ "encoding/json"+ "os"+ "os/exec"+ "path/filepath"+ "reflect"+ "strings"+ "testing"+ "time"++ "golang.org/x/sys/windows"+)++func TestNewWindowsExecutor(t *testing.T) {+ for _, test := range []struct {+ name string+ envShell string+ withShell string+ shell string+ shellType shellType+ args []string+ }{+ {"default", "", "", "cmd", shellTypeCmd, []string{"/s/c"}},+ {"cmd_env", "cmd.exe", "", "cmd.exe", shellTypeCmd, []string{"/s/c"}},+ {"cmd_path_env", `C:\Windows\System32\cmd.exe`, "", `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/s/c"}},+ {"powershell_env", "powershell.exe", "", "powershell.exe", shellTypePowerShell, []string{"-NoProfile", "-Command"}},+ {"pwsh_env", "pwsh.exe", "", "pwsh.exe", shellTypePwsh, []string{"-NoProfile", "-Command"}},+ {"custom_env", "custom-shell", "", "custom-shell", shellTypeUnknown, []string{"-c"}},+ {"cmd_explicit", "powershell.exe", "cmd.exe /d /c", "cmd.exe", shellTypeCmd, []string{"/d", "/c"}},+ {"cmd_path_explicit", "", `C:\Windows\System32\cmd.exe /d /s /c`, `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/d", "/s", "/c"}},+ {"powershell_explicit", "cmd.exe", "powershell.exe -NoLogo -NonInteractive -Command", "powershell.exe", shellTypePowerShell, []string{"-NoLogo", "-NonInteractive", "-Command"}},+ {"pwsh_explicit", "cmd.exe", "pwsh.exe -NoLogo -Command", "pwsh.exe", shellTypePwsh, []string{"-NoLogo", "-Command"}},+ {"custom_explicit", "cmd.exe", "custom-shell --custom -e", "custom-shell", shellTypeUnknown, []string{"--custom", "-e"}},+ {"cmd_no_flags", "", "cmd.exe", "cmd.exe", shellTypeCmd, []string{}},+ {"powershell_no_flags", "", "powershell.exe", "powershell.exe", shellTypePowerShell, []string{}},+ {"pwsh_no_flags", "", "pwsh.exe", "pwsh.exe", shellTypePwsh, []string{}},+ {"custom_no_flags", "", "custom-shell", "custom-shell", shellTypeUnknown, []string{}},+ } {+ t.Run(test.name, func(t *testing.T) {+ t.Setenv("SHELL", test.envShell)+ x := NewExecutor(test.withShell)+ if x.shell != test.shell || x.shellType != test.shellType || !reflect.DeepEqual(x.args, test.args) {+ t.Errorf("got shell=%q type=%v args=%q; want shell=%q type=%v args=%q",+ x.shell, x.shellType, x.args, test.shell, test.shellType, test.args)+ }+ })+ }+}++func TestWindowsExecutorQuoteEntry(t *testing.T) {+ entry := `hello 한글 O'Brien & "quoted"`+ for _, test := range []struct {+ withShell string+ want string+ }{+ {"cmd.exe /d /c", `^"hello 한글 O'Brien ^& \^"quoted\^"^"`},+ {"powershell.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},+ {"pwsh.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},+ {"custom-shell -c", `'hello 한글 O'\''Brien & "quoted"'`},+ } {+ t.Run(test.withShell, func(t *testing.T) {+ if got := NewExecutor(test.withShell).QuoteEntry(entry); got != test.want {+ t.Errorf("got %q; want %q", got, test.want)+ }+ })+ }+}++func TestWindowsExecutorProcessGroup(t *testing.T) {+ for _, test := range []struct {+ withShell string+ flags uint32+ args []string+ }{+ {"cmd.exe /d /c", 0, nil},+ {"powershell.exe -NoLogo -Command", 0, []string{"-NoLogo", "-Command"}},+ {"pwsh.exe -NoLogo -Command", windows.CREATE_NEW_PROCESS_GROUP, []string{"-NonInteractive", "-NoLogo", "-Command"}},+ {"custom-shell -e", windows.CREATE_NEW_PROCESS_GROUP, []string{"-e"}},+ } {+ t.Run(test.withShell, func(t *testing.T) {+ x := NewExecutor(test.withShell)+ originalArgs := append([]string{}, x.args...)+ for _, setpgid := range []bool{false, true} {+ cmd := x.ExecCommand("echo hello", setpgid)+ var wantFlags uint32+ if setpgid {+ wantFlags = test.flags+ }+ if cmd.SysProcAttr.CreationFlags != wantFlags {+ t.Errorf("setpgid=%v: got flags=%v; want %v", setpgid, cmd.SysProcAttr.CreationFlags, wantFlags)+ }+ if strings.HasPrefix(test.withShell, "cmd") {+ if cmd.SysProcAttr.CmdLine != `/d /c "echo hello"` {+ t.Errorf("got CMD command line %q", cmd.SysProcAttr.CmdLine)+ }+ } else {+ wantArgs := test.args+ if !setpgid && strings.HasPrefix(test.withShell, "pwsh") {+ wantArgs = wantArgs[1:]+ }+ want := append(append([]string{cmd.Args[0]}, wantArgs...), "echo hello")+ if !reflect.DeepEqual(cmd.Args, want) {+ t.Errorf("setpgid=%v: got args=%q; want %q", setpgid, cmd.Args, want)+ }+ }+ }+ if !reflect.DeepEqual(x.args, originalArgs) {+ t.Errorf("ExecCommand changed executor arguments: %q", x.args)+ }+ })+ }+}++func TestWindowsExecutorQuotedCommand(t *testing.T) {+ // Run the test binary as a helper so this regression needs no external tools.+ // Both its path and its argument exercise Windows shell quoting.+ executable, err := os.Executable()+ if err != nil {+ t.Fatal(err)+ }+ data, err := os.ReadFile(executable)+ if err != nil {+ t.Fatal(err)+ }+ dir := filepath.Join(t.TempDir(), "quoted path 한글 & O'Brien")+ if err := os.Mkdir(dir, 0700); err != nil {+ t.Fatal(err)+ }+ helper := filepath.Join(dir, "shell-probe.exe")+ if err := os.WriteFile(helper, data, 0700); err != nil {+ t.Fatal(err)+ }+ for _, test := range []struct {+ shell string+ withShell string+ prefix string+ }{+ {"cmd.exe", "", ""},+ {"cmd.exe", "cmd.exe /d /c", ""},+ {"powershell.exe", "", "& "},+ {"powershell.exe", "powershell.exe -NoProfile -NonInteractive -Command", "& "},+ {"pwsh.exe", "", "& "},+ {"pwsh.exe", "pwsh.exe -NoProfile -Command", "& "},+ } {+ name := "default"+ if test.withShell != "" {+ name = "explicit"+ }+ t.Run(test.shell+"/"+name, func(t *testing.T) {+ if _, err := exec.LookPath(test.shell); err != nil {+ t.Skipf("%s is unavailable: %v", test.shell, err)+ }+ t.Setenv("SHELL", test.shell)+ x := NewExecutor(test.withShell)+ entry := `hello 한글 O'Brien &|<>()^%!`+ if test.prefix == "" {+ entry += ` "quoted" C:\trailing\`+ }+ command := test.prefix + `"` + helper + `" ` + x.QuoteEntry("-test.run=TestWindowsExecutorHelper") + " -- " + x.QuoteEntry(entry)+ cmd := x.ExecCommand(command, true)+ cmd.Env = append(os.Environ(), "FZF_WINDOWS_EXECUTOR_HELPER=1")+ var output bytes.Buffer+ cmd.Stdout = &output+ cmd.Stderr = &output+ cmd.WaitDelay = 10 * time.Second+ if err := cmd.Start(); err != nil {+ t.Fatal(err)+ }+ timer := time.AfterFunc(10*time.Second, func() { _ = cmd.Process.Kill() })+ defer timer.Stop()+ if err := cmd.Wait(); err != nil {+ t.Fatalf("command failed: %v\n%s", err, output.String())+ }+ var got []string+ if err := json.Unmarshal(output.Bytes(), &got); err != nil {+ t.Fatalf("invalid helper output: %v\n%s", err, output.String())+ }+ if !reflect.DeepEqual(got, []string{entry}) {+ t.Errorf("got arguments %q; want %q", got, []string{entry})+ }+ })+ }+}++func TestWindowsExecutorHelper(t *testing.T) {+ if os.Getenv("FZF_WINDOWS_EXECUTOR_HELPER") != "1" {+ return+ }+ for index, arg := range os.Args {+ if arg == "--" {+ if err := json.NewEncoder(os.Stdout).Encode(os.Args[index+1:]); err != nil {+ os.Exit(1)+ }+ os.Exit(0)+ }+ }+ os.Exit(1)+}