From 327c3864f3d3b269b72530cf216f6d7dbaae8646 Mon Sep 17 00:00:00 2001 From: Mitchell Paulus Date: Wed, 4 Feb 2026 11:49:23 -0600 Subject: [PATCH] Attempt to fix cmd quoting with quotes inside on first arg --- CHANGELOG.md | 2 ++ mshell/Pathbin_windows.go | 47 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0aeb9f..fef59f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Windows CMD.EXE /C quoting now handles quoted commands with extra arguments (e.g., npm.cmd paths with spaces). + ### Changed ## 0.9.0 - 2026-01-27 diff --git a/mshell/Pathbin_windows.go b/mshell/Pathbin_windows.go index 5c8798f..8fb684b 100644 --- a/mshell/Pathbin_windows.go +++ b/mshell/Pathbin_windows.go @@ -245,6 +245,51 @@ func EscapeForCmd(allArgs []string) string { return sb.String() } +func BuildCmdExeLine(allArgs []string) string { + if len(allArgs) == 0 { + return "" + } + if len(allArgs) == 1 { + return allArgs[0] + } + + // From `help cmd` + // If /C or /K is specified, then the remainder of the command line after + // the switch is processed as a command line, where the following logic is + // used to process quote (") characters: + + // 1. If all of the following conditions are met, then quote characters + // on the command line are preserved: + + // - no /S switch + // - exactly two quote characters + // - no special characters between the two quote characters, + // where special is one of: &<>()@^| + // - there are one or more whitespace characters between the + // two quote characters + // - the string between the two quote characters is the name + // of an executable file. + + // 2. Otherwise, old behavior is to see if the first character is + // a quote character and if so, strip the leading character and + // remove the last quote character on the command line, preserving + // any text after the last quote character. + + if strings.EqualFold(allArgs[1], "/C") || strings.EqualFold(allArgs[1], "/K") { + cmdStr := EscapeForCmd(allArgs[2:]) + if len(allArgs) > 3 && strings.HasPrefix(cmdStr, "\"") { + // CMD.EXE needs an extra quote pair when the command starts with quotes and has extra args. + cmdStr = "\"" + cmdStr + "\"" + } + if cmdStr == "" { + return allArgs[0] + " " + allArgs[1] + } + return allArgs[0] + " " + allArgs[1] + " " + cmdStr + } + + return EscapeForCmd(allArgs) +} + func (pbm *PathBinManager) SetupCommand(allArgs []string) (*exec.Cmd) { // Get basename of the command if len(allArgs) == 0 { @@ -254,7 +299,7 @@ func (pbm *PathBinManager) SetupCommand(allArgs []string) (*exec.Cmd) { execName := filepath.Base(allArgs[0]) if strings.ToUpper(execName) == "CMD.EXE" { // If the command is CMD.EXE, we need to escape the arguments properly - cmdStr := EscapeForCmd(allArgs) + cmdStr := BuildCmdExeLine(allArgs) cmd := exec.Command("CMD.EXE") cmd.SysProcAttr = &syscall.SysProcAttr{