Convert a batch-file command with complex arguments to PowerShell

I have the following in .bat that is (this works):

"%winscp%" /ini=nul ^
           /log=C:\TEMP\winscplog.txt ^
           /command "open scp://goofy:[email protected]/ -hostkey=""ssh-rsa 2048 d4:1c:1a:4c:c3:60:d5:05:12:02:d2:d8:d6:ae:6c:5d""" ^
           "put ""%outfile%"" /home/public/somedir/somesubdir/%basename%" ^
           "exit"

I have tried to duplicate that into a powershell script like this:

& $winscp "/ini=nul" `
           "/log=C:\TEMP\winscplog.txt" `
           "/command" 'open sftp://goofy:[email protected]/ -hostkey="ssh-rsa 2048 d4:1c:1a:4c:c3:60:d5:05:12:02:d2:d8:d6:ae:6c:5d"' `
           "put `"" + $outfile + "`" /home/public/somedir/somesubdir/" + $basename `
           "exit"

When I run the .bat script the file will upload.

When I run the .ps1 script I get Host key does not match configured key ssh-rsa

I suspect that I have not formatted the command properly in powershell and the hostkey is getting mangled by the time winscp sees it.

I checked the log and all that is shown is the hostkey from the host. It does not show the key I am using. I confirmed that by changing my host and noting that it did not show up in the log. I compared the log between .bat and .ps1 and the difference is ps1 terminates with the error noted above.

winscp is a sftp utility.


Solution 1:

Note:

  • JamesQMurphy's helpful answer is the best solution in this case.

  • This answer generally discusses translating command lines written for cmd.exe to PowerShell.


Translating cmd.exe (batch-file) command lines to PowerShell is tricky - so tricky, that in PSv3 pseudo-parameter --%, the stop-parsing token, was introduced:

Its purpose is to allow you to pass everything that comes after --% as-is through to the target program, so as the control the exact quoting - except that cmd.exe-style %...%-style environment-variable references are still expanded by PowerShell[1].

However, --% comes with many severe limitations - discussed below - and its usefulness is limited to Windows, so it should be considered a last resort;

An alternative is to use the PSv3+ Native module (install with Install-Module Native from the PowerShell Gallery in PSv5+), which offers two commands that internally compensate for all of PowerShell's argument-passing and cmd.exe's argument-parsing quirks:

  • Function ie, which you prepend to any call to an external program, including cmd.exe, allows you to use only PowerShell's syntax_, without having to worry about argument-passing problems; e.g.:

    # Without `ie`, this command would malfunction.
    'a"b' | ie findstr 'a"b'
    
  • Function ins (Invoke-NativeShell) function allows you to reuse command lines written for cmd.exe as-is, passed as a single string; unlike --%, this also allows you to embed PowerShell variables and expressions in the command line, via a PowerShell expandable string ("..."):

    # Simple, verbatim cmd.exe command line.
    ins 'ver & whoami'
    
    # Multi-line, via a here-string.
    ins @'
      dir /x ^
        c:\
     '@
    
    # With up-front string interpolation to embed a PowerShell var.
    $var='c:\windows'; ins "dir /x `"$var`""
    

Limitations and pitfalls of --%

  • --% must follow the name/path of the external utility to invoke (it can't be the first token on the command line), so that the utility executable (path) itself, if passed by [environment] variable, must be defined and referenced using PowerShell syntax.

  • --% supports only one command, which an unquoted |, || or && on the same line, if present, implicitly ends; that allows you to pipe / chain such a command to / with other commands.

    • However, using ; in order to unconditionally place another command on the same line is not supported; the ; is passed through verbatim.

    • --% reads (at most) to the end of the line so spreading a command across multiple lines with line-continuation chars. is NOT supported.[2]

  • Other than %...% environment-variable references, you cannot embed any other dynamic elements in the command; that is, you cannot use regular PowerShell variable references or expressions.

    • Escaping % characters as %% (the way you can do inside batch files) is not supported; %<name>% tokens are invariably expanded, if <name> refers to a defined environment variable (if not, the token is passed through as-is).
  • Other than %...% environment-variable references, you cannot embed any other dynamic elements in the command; that is, you cannot embed regular PowerShell variable references or expressions.

  • You cannot use stream redirections (e.g., >file.txt), because they are passed verbatim, as arguments to the target command.

    • For stdout output you can work around that by appending | Set-Content file.txt instead, but there is no direct PowerShell workaround for stderr output.
    • However, if you invoke your command via cmd, you can let cmd handle the (stderr) redirection (e.g., cmd --% /c nosuch 2>file.txt)

Applied to your case, this means:

  • %winscp% must be translated to its PowerShell equivalent, $env:winscp, and the latter must be prefixed with &, PowerShell's call operator, which is required when invoking external commands that are specified by variable or quoted string.
  • & $env:winscp must be followed by --% to ensure that all remaining arguments are passed through unmodified (except for expansion of %...% variable references).
  • The list of arguments from the original command can be pasted as-is after --%, but must be on a single line.

Therefore, the simplest approach in your case - albeit at the expense of having to use a single line - is:

# Invoke the command line with --%
# All arguments after --% are used as-is from the original command.
& $env:winscp --% /ini=nul /log=C:\TEMP\winscplog.txt /command "open scp://goofy:[email protected]/ -hostkey=""ssh-rsa 2048 d4:1c:1a:4c:c3:60:d5:05:12:02:d2:d8:d6:ae:6c:5d""" "put ""%outfile%"" /home/public/somedir/somesubdir/%basename%" "exit"

[1] Note that, despite the cmd.exe-like syntax, --% also works on Unix-like platforms in PowerShell Core (macOS, Linux), but is of very limited use there: unlike with native shells such as bash there, --% only works with double-quoted strings ("..."); e.g., bash --% -c "hello world" works, but bash --% -c 'hello world' doesn't - and the usual shell expansions, notably globbing, aren't supported - see this GitHub issue.

[2] Even `, PowerShell's own line-continuation character, is treated as a pass-through literal. cmd.exe isn't even involved when you use --% (unless you explicitly use cmd --% /c ...), so its line-continuation character, ^, cannot be used either.

Solution 2:

Martin Prikryl (the author of WinSCP) also provides a .Net assembly, which might be a better choice if you want to switch to PowerShell.

Example from the documentation updated with the parameters from your commandline:

try {
    # Load WinSCP .NET assembly
    Add-Type -Path 'WinSCPnet.dll'

    # Setup session options
    $sessionOptions = New-Object WinSCP.SessionOptions -Property @{
        Protocol = [WinSCP.Protocol]::Sftp
        HostName = '10.61.10.225'
        UserName = 'goofy'
        Password = 'changeme'
        SshHostKeyFingerprint = 'ssh-rsa 2048 d4:1c:1a:4c:c3:60:d5:05:12:02:d2:d8:d6:ae:6c:5d'
    }

    $session = New-Object WinSCP.Session

    try {
        # Connect
        $session.Open($sessionOptions)

        # Upload files
        $transferOptions = New-Object WinSCP.TransferOptions
        $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary

        $transferResult = $session.PutFiles($outfile, "/home/public/somedir/somesubdir/$basename", $false, $transferOptions)

        # Throw on any error
        $transferResult.Check()

        # Print results
        foreach ($transfer in $transferResult.Transfers) {
            Write-Host ("Upload of {0} succeeded" -f $transfer.FileName)
        }
    } finally {
        # Disconnect, clean up
        $session.Dispose()
    }

    exit 0
} catch [Exception] {
    Write-Host ("Error: {0}" -f $_.Exception.Message)
    exit 1
}

Solution 3:

Whenever I have to invoke an executable from PowerShell, I always pass the parameters as a list, like below. I also use single quote marks, unless I'm actually substituting variables, in which case I have to use the double quotation marks.

 & SomeUtility.exe @('param1','param2',"with$variable")

It gets a little tricky when there are spaces in the parameters, since you may have to provide quotation marks so that the utility can properly parse the command.

Your example is even more tricky, since WinScp wants the argument of the /command parameter enclosed in quotation marks, and any strings inside enclosed in double quotation marks. All of that needs to be preserved, because of WinScp. I believe the following would work. I've broken up the parameters into multiple lines for readability. I'm also assuming that you've successfully populated your $winscp, $outfile, and $basename variables.

$params = @(
  '/ini=nul',
  '/log=C:\TEMP\winscplog.txt',
  '/command',
  '"open scp://goofy:[email protected]/ -hostkey=""ssh-rsa 2048 d4:1c:1a:4c:c3:60:d5:05:12:02:d2:d8:d6:ae:6c:5d"""',
  ('"put ""' + $outfile + '"" /home/public/somedir/somesubdir/' + $basename + '"'),
  '"exit"'
)

& $winscp $params

Note the parentheses around the fifth parameter; this is due to the string concatenation operations there. (Without the parentheses, each operand would have been added to the list separately -- you can confirm this by looking at $params.Count.) Also keep in mind that you will need quotation marks around your log file path if you ever have to change it to something with spaces.