From f8bd9e7a0e42e4a4d1eaf55d29bfd6bc57123e38 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Mon, 19 Oct 2026 12:42:17 -0700 Subject: [PATCH] Pass Cmd() and Bash() commands through without mangling their quotes Cmd() on Windows handed the command to cmd.exe as one argument, so it was escaped for argv parsing: every " became \". cmd.exe doesn't parse argv; it runs everything after /C as typed, so programs got literal \" and quoted arguments broke, along with any | & > inside them. Cmd() now gives cmd.exe the command line as-is: /S /C "". Bash() escaped the command's quotes before Command.Run escaped the argument again, so bash saw \" (a literal quote) instead of quotes around an argument. It now passes the command as typed. Adds tests for quoted arguments with spaces and pipes, and bumps the version to 3.2.1. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++++ Tests/CShell.Tests/Command.Tests.cs | 24 ++++++++++++++++++++++++ src/CShell.cs | 25 ++++++++++++------------- src/CShell.csproj | 4 ++-- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e18a618..892870d 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,10 @@ chmod +x example.csx ``` ## CHANGELOG +### v3.2.1 +* **Cmd()** on Windows passes the command to cmd.exe as typed. Every `"` used to arrive as `\"`, so quoted arguments, and any `|`, `&` or `>` inside them, broke (`Cmd("az webapp list --query \"[?name=='x'] | [0]\"")`) +* **Bash()** no longer escapes the command's quotes a second time, so `Bash("printf '%s' \"a b\"")` passes `a b` as one argument + ### v3.1.0 * **Cli** now models nested commands, the way `dotnet build` and `dotnet nuget push` do * `.Command(name, help, c => ...)` gives a verb a command line of its own, and commands nest to any depth diff --git a/Tests/CShell.Tests/Command.Tests.cs b/Tests/CShell.Tests/Command.Tests.cs index c937e11..91e1279 100644 --- a/Tests/CShell.Tests/Command.Tests.cs +++ b/Tests/CShell.Tests/Command.Tests.cs @@ -220,6 +220,30 @@ public async Task Test_Bash() Assert.AreEqual("TestA.txt", result.Trim(), "AsString"); } + [TestMethod] + public async Task Test_Cmd_QuotedArguments() + { + // Quotes, the spaces inside them and a | inside them all reach the program as typed. cmd.exe used to get + // every " as \", which also left the | outside any quotes. + CShell shell = new CShell(); + shell.cd(testFolder); + var result = await shell.Cmd("echo x \"[?name=='S'].value | [0]\" y").AsString(); + + // cmd.exe's echo prints the quotes; bash's drops them. + Assert.AreEqual(IsWindows ? "x \"[?name=='S'].value | [0]\" y" : "x [?name=='S'].value | [0] y", result.Trim()); + } + + [TestMethod] + public async Task Test_Bash_QuotedArguments() + { + // "a b" is one argument. Bash used to get \"a b\": two arguments, with literal quotes. + CShell shell = new CShell(); + shell.cd(testFolder); + var result = await shell.Bash("printf '%s\\n' \"a b\" c").AsString(); + + Assert.AreEqual("a b\nc", result.Trim().Replace("\r\n", "\n")); + } + [TestMethod] public async Task Test_Start() { diff --git a/src/CShell.cs b/src/CShell.cs index 9b61756..3cdc03c 100644 --- a/src/CShell.cs +++ b/src/CShell.cs @@ -346,15 +346,16 @@ public Command Cmd(string cmd) Console.WriteLine(cmd); } - List args = new List(); - if (!Echo) + // cmd.exe doesn't split its command line the way other programs do: it runs everything after /C as + // typed. Passed as an argument, the command was escaped for argv parsing, so every " reached cmd.exe + // as \" and quoted arguments (and any |, & or > inside them) broke. Hand cmd.exe the command line + // as-is instead; /S strips just the outer pair of quotes and leaves the command's own alone. + var arguments = $"{(Echo ? string.Empty : "/Q ")}/S /C \"{cmd}\""; + return Command.Run("cmd.exe", Array.Empty(), opt => { - args.Add("/Q"); - } - args.Add("/C"); - args.Add(cmd); - - return Command.Run("cmd.exe", args, SetCommandOptions); + SetCommandOptions(opt); + opt.StartInfo(info => info.Arguments = arguments); + }); } else { @@ -373,11 +374,9 @@ public Command Bash(string cmd) { Console.WriteLine(cmd); } - var args = new List(); - - var escapedArgs = cmd.Replace("\"", "\\\""); - args.Add("-c"); - args.Add(escapedArgs); + // Command.Run escapes each argument, so the command goes as typed. (Escaping its quotes here as well left + // bash seeing \" -- a literal quote -- instead of quotes around an argument.) + var args = new List { "-c", cmd }; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return Command.Run("bash.exe", args, SetCommandOptions); diff --git a/src/CShell.csproj b/src/CShell.csproj index d55e61e..fb16256 100644 --- a/src/CShell.csproj +++ b/src/CShell.csproj @@ -16,8 +16,8 @@ git scripting dotnet csharp CShell - 3.2.0 - 3.2.0 + 3.2.1 + 3.2.1 true snupkg