From 2efd52baab6a84151714a0d52af05b6f594d5205 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:30:12 -0500 Subject: [PATCH 1/2] Quote arguments holding cmd.exe special characters A .bat or .cmd program runs through cmd.exe, which acts on & | < > ^ outside quotes and splits batch arguments at , ; so an Arguments list element like 'a&ver', with no space to trigger quoting, ran ver instead of reaching the program. ConvertTo-PkArgumentString, and with it list Arguments, now quotes any element holding one of & | < > ^ ( ) , ; - harmless to programs that use the C runtime's rules, which drop the quotes. The README notes that cmd.exe still expands %NAME% inside quotes. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 18 ++++++++++++++++++ ParallelKit.ps1 | 13 ++++++++++--- README.md | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 85b14ad..670ca98 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -2683,6 +2683,24 @@ if ($compiled) { Test-Assert ((ConvertTo-PkArgumentString $null) -eq '') "and takes `$null itself" Test-Assert ((ConvertTo-PkArgumentString 'two words') -eq '"two words"' -and (ConvertTo-PkArgumentString @(1, 'x y')) -eq '1 "x y"') "it still takes one string, and non-strings" + # cmd.exe's special characters. A .bat/.cmd program runs through cmd.exe, + # which acts on & | < > ^ outside quotes and splits batch arguments at , ; + # so an element holding one is quoted even without a space. 'a&ver' used to + # reach a batch file as 'a' and run ver. + $special = @('a&b', 'a|b', 'ab', 'a^b', '(x)', 'a,b', 'a;b') + Test-Assert ((ConvertTo-PkArgumentString $special) -eq '"a&b" "a|b" "ab" "a^b" "(x)" "a,b" "a;b"') "an element with a cmd.exe special character is quoted" + Test-Assert ((Get-BatchArgvText $special) -eq '[a&b][a|b][ab][a^b][(x)][a,b][a;b]') "and an ordinary program still gets it exactly" + Test-Assert ((ConvertTo-PkArgumentString @('a=b', '100%', 'a!b')) -eq 'a=b 100% a!b') "= % and ! are left alone" + $batFile = Join-Path $env:TEMP ("pk-args-" + [guid]::NewGuid().ToString("N") + ".cmd") + # set "NAME=value" keeps & | ^ literal, so this prints what cmd handed over. + [System.IO.File]::WriteAllText($batFile, "@echo off`r`nset `"PKARG1=%~1`"`r`nset `"PKARG2=%~2`"`r`nset PKARG`r`n") + foreach ($value in @('a&ver', 'a|b', 'a^b', 'a,b', 'a;b', '(x)')) { + $batText = $null + try { $batText = (Invoke-PkProcessWithTimeout -FilePath $batFile -Arguments @($value) -TimeoutMs 10000).Stdout.Trim() } catch { $batText = [string]$_ } + Test-Assert ($batText -ceq ('PKARG1=' + $value)) ("a .cmd program gets '" + $value + "' as one argument, and nothing else runs (" + $batText + ")") + } + try { [System.IO.File]::Delete($batFile) } catch { } # temp file only; best effort + # A string is still exactly as written. Test-Assert ((Get-BatchArgvText '"C:\My Files\a.png" out.jxl') -eq $spacedText) "a string is still passed exactly as written" # The documented change: list elements are values, not pieces of a command line. diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index a1a5fcf..7413f61 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -694,8 +694,11 @@ function ConvertTo-PkArgumentString { # An argument with a space, tab, newline or quote is wrapped in quotes, its # quotes become \", and backslashes are doubled where they come before a quote. # These are the C runtime's rules, which most Windows programs use; cmd.exe /c - # parses its own way. The kit's -Arguments and Arguments values do this - # themselves when given a list. + # parses its own way. An argument with one of & | < > ^ ( ) , ; is quoted + # too, so a .bat or .cmd program, which runs through cmd.exe, gets it as one + # argument instead of cmd acting on it. cmd still expands %NAME% inside + # quotes. The kit's -Arguments and Arguments values do this themselves when + # given a list. # .PARAMETER Arguments # The arguments, in order. A $null one is left out; an empty string is passed # as "". @@ -720,7 +723,11 @@ function ConvertTo-PkArgumentString { foreach ($item in $Arguments) { if ($null -eq $item) { continue } $arg = [string]$item - if ($arg.Length -gt 0 -and $arg.IndexOfAny([char[]]@(' ', "`t", "`n", "`v", '"')) -lt 0) { + # The C runtime only needs quotes around whitespace and quotes. The rest + # are cmd.exe's: it runs a .bat/.cmd program's command line, acting on + # & | < > ^ ( ) outside quotes and splitting batch arguments at , ; + # Quoting them is harmless to other programs. + if ($arg.Length -gt 0 -and $arg.IndexOfAny([char[]]@(' ', "`t", "`n", "`v", '"', '&', '|', '<', '>', '^', '(', ')', ',', ';')) -lt 0) { $parts.Add($arg) continue } diff --git a/README.md b/README.md index a213188..1f9fae5 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ $batch = Invoke-PkRun { Invoke-PkProcessBatch @batchParams } Long parameter lists in these examples use splatting (`@batchParams`) instead of trailing backticks: it's easier to read, and a stray space after a backtick can't break it. -Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is either one string, passed to the program exactly as written, or a list, where each element is one argument: `Arguments = @($in, $out)` works with spaces in the paths. A list is quoted the way `ConvertTo-PkArgumentString @(...)` quotes it, so the program gets exactly that list back, spaces, quotes and trailing backslashes included, and `$null` elements are left out. Quotes inside an element are passed on as quote characters, so don't pre-quote list elements. It follows the rules most Windows programs use; `cmd.exe /c` parses its own way, so give it one string. Programs run hidden, with their output captured. +Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is either one string, passed to the program exactly as written, or a list, where each element is one argument: `Arguments = @($in, $out)` works with spaces in the paths. A list is quoted the way `ConvertTo-PkArgumentString @(...)` quotes it, so the program gets exactly that list back, spaces, quotes and trailing backslashes included, and `$null` elements are left out. Quotes inside an element are passed on as quote characters, so don't pre-quote list elements. It follows the rules most Windows programs use; `cmd.exe /c` parses its own way, so give it one string. A `.bat` or `.cmd` program runs through `cmd.exe` too. A list element holding `&`, `|`, `<`, `>`, `^`, `(`, `)`, `,` or `;` is quoted, so such a program gets it as one argument, but `cmd.exe` still expands `%NAME%` in an argument, quoted or not, when a variable by that name exists. Programs run hidden, with their output captured. Programs run in your current location (`Get-Location`) unless a command says otherwise, and a relative `WorkingDirectory`, or a `FilePath` with a folder in it like `.\tools\cjxl.exe`, is relative to that location too. Drives PowerShell knows, like `~` or one made with `New-PSDrive`, work in both. A bare name like `cjxl.exe` is found the way Windows finds programs, mostly on `PATH`. A `.bat` or `.cmd` program can't run in a network-share (`\\server\share`) folder: `cmd.exe` falls back to `C:\Windows` there, so give such commands a local `WorkingDirectory`. From d403eed98952da24aa914565b405425367029a14 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:35:45 -0500 Subject: [PATCH 2/2] Say what quoting can't cover for .bat and .cmd programs cmd.exe also splits batch arguments at =, which stays unquoted because some programs read NAME=value from the command line themselves, and it counts an escaped \" as a quote, so an argument containing a quote can end up partly unquoted. The help and README now say so, next to %NAME% expansion. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.ps1 | 9 ++++++--- README.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index 7413f61..2a8ece2 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -696,9 +696,12 @@ function ConvertTo-PkArgumentString { # These are the C runtime's rules, which most Windows programs use; cmd.exe /c # parses its own way. An argument with one of & | < > ^ ( ) , ; is quoted # too, so a .bat or .cmd program, which runs through cmd.exe, gets it as one - # argument instead of cmd acting on it. cmd still expands %NAME% inside - # quotes. The kit's -Arguments and Arguments values do this themselves when - # given a list. + # argument instead of cmd acting on it. Not covered for those programs: cmd + # expands %NAME% inside quotes, splits batch arguments at = (left unquoted, + # since some programs read NAME=value themselves), and counts a \" as a + # quote, so an argument containing a quote can end up partly unquoted. The + # kit's -Arguments and Arguments values do this themselves when given a + # list. # .PARAMETER Arguments # The arguments, in order. A $null one is left out; an empty string is passed # as "". diff --git a/README.md b/README.md index 1f9fae5..f1750ec 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ $batch = Invoke-PkRun { Invoke-PkProcessBatch @batchParams } Long parameter lists in these examples use splatting (`@batchParams`) instead of trailing backticks: it's easier to read, and a stray space after a backtick can't break it. -Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is either one string, passed to the program exactly as written, or a list, where each element is one argument: `Arguments = @($in, $out)` works with spaces in the paths. A list is quoted the way `ConvertTo-PkArgumentString @(...)` quotes it, so the program gets exactly that list back, spaces, quotes and trailing backslashes included, and `$null` elements are left out. Quotes inside an element are passed on as quote characters, so don't pre-quote list elements. It follows the rules most Windows programs use; `cmd.exe /c` parses its own way, so give it one string. A `.bat` or `.cmd` program runs through `cmd.exe` too. A list element holding `&`, `|`, `<`, `>`, `^`, `(`, `)`, `,` or `;` is quoted, so such a program gets it as one argument, but `cmd.exe` still expands `%NAME%` in an argument, quoted or not, when a variable by that name exists. Programs run hidden, with their output captured. +Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is either one string, passed to the program exactly as written, or a list, where each element is one argument: `Arguments = @($in, $out)` works with spaces in the paths. A list is quoted the way `ConvertTo-PkArgumentString @(...)` quotes it, so the program gets exactly that list back, spaces, quotes and trailing backslashes included, and `$null` elements are left out. Quotes inside an element are passed on as quote characters, so don't pre-quote list elements. It follows the rules most Windows programs use; `cmd.exe /c` parses its own way, so give it one string. A `.bat` or `.cmd` program runs through `cmd.exe` too. A list element holding `&`, `|`, `<`, `>`, `^`, `(`, `)`, `,` or `;` is quoted, so such a program gets it as one argument. What quoting can't cover there: `cmd.exe` expands `%NAME%` in an argument, quoted or not, when a variable by that name exists; it splits batch arguments at `=`, which isn't quoted because some programs read `NAME=value` from the command line themselves; and an element containing a `"` can leave the characters after it unquoted as far as `cmd.exe` is concerned. Programs run hidden, with their output captured. Programs run in your current location (`Get-Location`) unless a command says otherwise, and a relative `WorkingDirectory`, or a `FilePath` with a folder in it like `.\tools\cjxl.exe`, is relative to that location too. Drives PowerShell knows, like `~` or one made with `New-PSDrive`, work in both. A bare name like `cjxl.exe` is found the way Windows finds programs, mostly on `PATH`. A `.bat` or `.cmd` program can't run in a network-share (`\\server\share`) folder: `cmd.exe` falls back to `C:\Windows` there, so give such commands a local `WorkingDirectory`.