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..2a8ece2 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -694,8 +694,14 @@ 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. 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 "". @@ -720,7 +726,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..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. 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`.