diff --git a/internal/proc/batch_windows.go b/internal/proc/batch_windows.go new file mode 100644 index 00000000..9f0f347e --- /dev/null +++ b/internal/proc/batch_windows.go @@ -0,0 +1,114 @@ +package proc + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unicode" +) + +// batch has a .cmd or .bat (npm.cmd, a CLI's npm shim) run by cmd.exe with +// a command line cmd.exe reads back as the arguments given. Windows runs a +// batch file as `cmd.exe /c `, and Go builds that line for a +// program's own parsing: cmd.exe, seeing more than two quotes, drops the +// first and the last, so `"C:\Program Files\nodejs\npm.cmd" … --prefix +// "C:\Program Files\nodejs" …` ran C:\Program; and an argument with & or | +// in it, which Go leaves unquoted, was split into a second command there. +// +// Each argument is quoted the way Rust's standard library quotes one for a +// batch file: in quotes when it has anything but letters, digits and +// #$*+-./:?@\_ in it or a \ at its end, a " doubled, a % broken up so +// cmd.exe expands no variable, and a line break refused, as it would end +// the command. A doubled " is read back as one by programs built with +// Microsoft's C runtime since 2008, node among them; Go's own parser reads +// it the older way. +func batch(cmd *exec.Cmd) { + if cmd.Err != nil || cmd.Path == "" { + return + } + switch strings.ToLower(filepath.Ext(cmd.Path)) { + case ".cmd", ".bat": + default: + return + } + if strings.ContainsAny(cmd.Path, "\"\r\n") { + cmd.Err = errBatchArg + return + } + var b strings.Builder + b.WriteString(`/d /e:ON /v:OFF /s /c ""`) + b.WriteString(cmd.Path) + b.WriteByte('"') + for _, a := range cmd.Args[1:] { + if strings.ContainsAny(a, "\r\n\x00") { + cmd.Err = errBatchArg + return + } + b.WriteByte(' ') + quoteBatchArg(&b, a) + } + b.WriteByte('"') + shell := comSpec() + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CmdLine = `"` + shell + `" ` + b.String() + cmd.Args = append([]string{shell, "/d", "/e:ON", "/v:OFF", "/s", "/c", cmd.Path}, cmd.Args[1:]...) + cmd.Path = shell +} + +var errBatchArg = errors.New("cmd.exe can't be handed a batch file with a quote in its path, or a line break or NUL in an argument") + +func quoteBatchArg(b *strings.Builder, a string) { + // a trailing \ is quoted too, or a script's "%~1" would have it + // escape the quote after it + quote := a == "" || strings.HasSuffix(a, `\`) || strings.IndexFunc(a, func(r rune) bool { + if r < 0x80 { + return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune(`#$*+-./:?@\_`, r)) + } + return unicode.IsControl(r) + }) >= 0 + if quote { + b.WriteByte('"') + } + backslashes := 0 + for _, r := range a { + switch r { + case '\\': + backslashes++ + b.WriteRune(r) + continue + case '"': + // the backslashes before it are doubled and the quote is + // doubled, which keeps cmd.exe's idea of what is quoted + b.WriteString(strings.Repeat(`\`, backslashes)) + b.WriteByte('"') + case '%': + // %cd:~,% is always empty, and it stands between this % and + // the name after it + b.WriteString("%%cd:~,") + } + backslashes = 0 + b.WriteRune(r) + } + if quote { + b.WriteString(strings.Repeat(`\`, backslashes)) + b.WriteByte('"') + } +} + +// comSpec is cmd.exe: %ComSpec% when it names one, else the one in +// System32. +func comSpec() string { + if c := os.Getenv("ComSpec"); filepath.IsAbs(c) && strings.EqualFold(filepath.Base(c), "cmd.exe") { + return c + } + root := os.Getenv("SystemRoot") + if root == "" { + root = `C:\Windows` + } + return filepath.Join(root, "System32", "cmd.exe") +} diff --git a/internal/proc/proc.go b/internal/proc/proc.go index 5d4a4ac9..ec6ec274 100644 --- a/internal/proc/proc.go +++ b/internal/proc/proc.go @@ -23,6 +23,7 @@ import ( // Command is exec.Command, with no window of its own on Windows. func Command(name string, args ...string) *exec.Cmd { cmd := exec.Command(name, args...) + batch(cmd) hide(cmd) return cmd } @@ -36,6 +37,7 @@ func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd // Output waited for it however long it took (#123) — its pipes are // closed a moment after instead cmd.WaitDelay = waitDelay + batch(cmd) hide(cmd) return cmd } diff --git a/internal/proc/proc_other.go b/internal/proc/proc_other.go index 760f5de2..702dd396 100644 --- a/internal/proc/proc_other.go +++ b/internal/proc/proc_other.go @@ -6,3 +6,6 @@ import "os/exec" // hide has nothing to do: only Windows gives a child a window of its own. func hide(*exec.Cmd) {} + +// batch has nothing to do: only Windows runs a script through cmd.exe. +func batch(*exec.Cmd) {} diff --git a/internal/proc/proc_windows_test.go b/internal/proc/proc_windows_test.go index 698f5bb7..1b077bc4 100644 --- a/internal/proc/proc_windows_test.go +++ b/internal/proc/proc_windows_test.go @@ -2,6 +2,13 @@ package proc import ( "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" "testing" ) @@ -25,3 +32,55 @@ func TestHideWithoutConsole(t *testing.T) { t.Fatalf("with a console the child should share it: %+v", cmd.SysProcAttr) } } + +// A .cmd in a folder with a space, given an argument with a space, hands +// the program it runs the arguments as given: npm.cmd under nvm-windows' +// C:\Program Files\nodejs, installing into that same folder, had cmd.exe +// run "C:\Program" instead (wztlink1013 on Discord). The script passes %* +// on, as npm.cmd does to node, to this test binary, which prints what it +// was given. A " in an argument isn't among them: it comes back doubled to +// Go's parser, which reads "" the way C runtimes did before 2008. +func TestBatchFileInFolderWithSpace(t *testing.T) { + if os.Getenv("MAGPIE_PROC_ECHO") == "1" { + return + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(t.TempDir(), "Program Files", "nodejs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + script := "@echo off\r\n\"" + self + "\" -test.run=TestEchoArgs -- %*\r\n" + for _, name := range []string{"npm.cmd", "tool.bat"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + } + args := []string{"install", "-g", "--prefix", dir, "@anthropic-ai/claude-code@latest", + "x&y", "a b|c", "%PATH%", `C:\a dir\`, `C:\b\`, "", "^(<>)!,;=", "中文 路径"} + want, _ := json.Marshal(args) + t.Setenv("MAGPIE_PROC_ECHO", "1") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + for _, name := range []string{filepath.Join(dir, "npm.cmd"), filepath.Join(dir, "tool.bat"), "npm"} { + for _, cmd := range []*exec.Cmd{Command(name, args...), CommandContext(context.Background(), name, args...)} { + out, err := cmd.CombinedOutput() + got, _, _ := strings.Cut(string(out), "\n") + if err != nil || got != string(want) { + t.Errorf("%s: got %q, %v; want %s", name, out, err, want) + } + } + } +} + +// TestEchoArgs is the program TestBatchFileInFolderWithSpace's scripts run. +func TestEchoArgs(t *testing.T) { + if os.Getenv("MAGPIE_PROC_ECHO") != "1" { + t.Skip("run by TestBatchFileInFolderWithSpace") + } + i := slices.Index(os.Args, "--") + b, _ := json.Marshal(append([]string{}, os.Args[i+1:]...)) + fmt.Fprintf(os.Stdout, "%s\n", b) + os.Exit(0) +}