Search code examples
powershellescapingwinscpquotingpowershell-7.3

PowerShell 7.3.0 breaking command invocation


I use WinSCP within a Powershell script. It suddenly stopped working. After a while I could figure out that the problem appeared from a more recent version of PowerShell:

Reduced code:

& winscp `
    /log `
    /command `
        'echo Connecting...' `
        "open sftp://kjhgk:[email protected]/ -hostkey=`"`"ssh-ed25519 includes spaces`"`"" 

Error message using v7.2.7

Host "example.com" does not exist.

Errror message using v7.3.0

Too many parameters for command 'open'.

As you can see with v7.3.0 WinSCP receives different input depending on the version of PS. I found out that the difference has something to do with the spaces in the hostkey. If they are omitted v7.3.0 outputs the same error.

What change to PowerShell caused this, and how can I fix it? (How can I debug such issues? I played a bit around with escaping, but the strings look the same no matter the version, no obvious breaking change that could be responsible)


Solution

  • Version 7.3.0 of PowerShell (Core) introduced a breaking change with respect to how arguments with embedded " characters (and empty-string arguments)[1] are passed to external programs, such as winscp:[2]

    While this change is mostly beneficial, because it fixes behavior that was fundamentally broken since v1 (this answer discusses the old, broken behavior), it also invariably breaks existing workarounds that build on the broken behavior, except those for calls to batch files and the WSH CLIs (wscript.exe and cscript.exe) and their associated script files (with file-name extensions such as .vbs and .js).

    To make existing workarounds continue to work, set the $PSNativeCommandArgumentPassing preference variable (temporarily) to 'Legacy':

    # Note: Enclosing the call in & { ... } makes it execute in a *child scope*
    #       limiting the change to $PSNativeCommandArgumentPassing to that scope.
    & {
      $PSNativeCommandArgumentPassing = 'Legacy'
      & winscp `
        /log `
        /command `
            'echo Connecting...' `
            "open sftp://kjhgk:[email protected]/ -hostkey=`"`"ssh-ed25519 includes spaces`"`"" 
    }
    

    Unfortunately, because winscp.exe only accepts
    "open sftp://kjhgk:[email protected]/ -hostkey=""ssh-ed25519 includes spaces""" on its process command line (i.e., embedded " escaped as ""), and not also the most widely used form
    "open sftp://kjhgk:[email protected]/ -hostkey=\"ssh-ed25519 includes spaces\"" (embedded " escaped as \"), which the fixed behavior now employs, for winscp.exe, specifically, a workaround will continue to be required.

    If you don't want to rely on having to modify $PSNativeCommandArgumentPassing for the workaround, here are workarounds that function in both v7.2- and v7.3+ :

    • Use --%, the stop-parsing token, which, however, comes with pitfalls and severe limitations, notably the inability to (directly) use PowerShell variables or subexpressions in the arguments that follow it - see this answer for details; however, you can bypass these limitations if you use --% as part of an array that you construct and assign to a variable first and then pass via splatting:

      # Note: Must be single-line; note the --% and the 
      #       unescaped use of "" in the argument that follows it.
      #       Only "..." quoting must be used after --% 
      #       and the only variables that can be used are cmd-style 
      #       *environment variables* such as %OS%.
      winscp /log /command 'echo Connecting...' --% "open sftp://kjhgk:[email protected]/ -hostkey=""ssh-ed25519 includes spaces""" 
      
      # Superior alternative, using splatting:
      $argList = '/log', '/command', 'echo Connecting...', 
                 '--%', "open sftp://kjhgk:[email protected]/ -hostkey=""ssh-ed25519 includes spaces"""
      winscp @argList
      
    • Alternatively, call via cmd /c:

      # Note: Pass-through command must be single-line,
      #       Only "..." quoting supported, 
      #       and the embedded command must obey cmd.exe's syntax rules.
      cmd /c @"
        winscp /log /command "echo Connecting..." "open sftp://kjhgk:[email protected]/ -hostkey=""ssh-ed25519 includes spaces"""
      "@
      
      • Note: You don't strictly need to use a here-string (@"<newline>...<newline>"@ or @'<newline>...<newline>'@), but it helps readability and simplifies using embedded quoting.

    Both workarounds allow you to pass arguments directly as quoted, but unfortunately also require formulating the entire (pass-through) command on a single line - except if --% is combined with splatting.


    Background information:

    The v7.3+ default $PSNativeCommandArgumentPassing value on Windows, 'Windows':

    • regrettably retains the old, broken behavior for calls to batch files and the WSH CLIs (wscript.exe and cscript.exe) and their associated script files (with file-name extensions such as .vbs and .js).

      • While, for these programs only, this allows existing workarounds to continue to function, future code that only needs to run in v7.3+ will continue to be burdened by the need for these obscure workarounds, which build on broken behavior.

        • The alternative, which was not implemented, would have been to build accommodations for these programs as well as some program-agnostic accommodations into PowerShell, so that in the vast majority of case there won't even be a need for workarounds in the future: see GitHub issue #15143.
      • There are also troublesome signs that this list of exceptions will be appended to, piecemeal, which all but guarantees confusion for a given PowerShell version as to which programs require workarounds and which don't.

    • commendably, for all other programs PowerShell encodes the arguments - when it of necessity rebuilds the command line behind the scenes - as follows with respect to ":

      • It encodes the arguments for programs that follow the C++ command-line parsing rules (as used by C / C++ / .NET applications) / the parsing rules of the CommandLineToArgv WinAPI function, which are the most widely observed convention for parsing a process' command line.

      • In a nutshell, this means that embedded " characters embedded in an argument, to be seen as a verbatim part of it by the target program, are escaped as \", with \ itself requiring escaping only (as \\) if it precedes a " but is meant to be interpreted verbatim.

      • Note that if you set $PSNativeCommandArgumentPassing value to 'Standard' (which is the default on Unix-like platforms, where this mode fixes all problems and makes v7.3+ code never require workarounds), this behavior applies to all external programs, i.e. the above exceptions no longer apply).

    For a summary of the impact of the breaking v7.3 change, see this comment on GitHub.

    If you have / need to write cross-edition, cross-version PowerShell code: The Native module (Install-Module Native; authored by me), has an ie function (short for: Invoke Executable), which is a polyfill that provides workaround-free cross-edition (v3+), cross-platform, and cross-version behavior in the vast majority of cases - simply prepend ie to your external-program calls.
    Caveat: In the specific case at hand it will not work, because it isn't aware that winscp.exe requires ""-escaping.


    [1] See this answer for details and workarounds.

    [2] Reverting that change in a later version and making the new behavior opt-in was briefly considered, but decided against - see GitHub issue #18694