1

I'm writing a service installer script in Powershell where the service requires a complicated quoted command line.
So I tried to simplify it and broke each option down to individual variables, however when creating the final command line string the strings don't escape their quotes. Thus the command line doesn't work.

I'd like to keep all the options separate so that other admins can configure and install the service without needing to worry about escaping quotes.

I'm thinking I need to perform a search and replace or use a shell specific safe/escape string command to operate on the individual strings first.

I don't know how the command line of a service is parsed, so not sure which shell escape method to use.

I've done a search on quotes in strings but they never seem to deal with nesting of strings with quotes inside strings with quotes.

This is my install script and I do have control over the applicationservice, so if you know of a better method to get arguments into a service that would also be appreciated.

$installpath = (get-location)
$name="landingZone"
$displayName="LandingZone Starter"
$description="Sony CI automated download client"

$sessionuser="Engineering"
$processname="explorer"
$logname="Landing Zone"
$programpath="C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe"
$programarguments='"C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe -jar C:\Program Files (x86)\Sony CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar"'
$WorkDirectory="C:\Program Files (x86)\Sony CI\Sony-Ci-LZ-1.4.22" 
$Visible=1
$TerminateTimeout=1000

# Arg! help me!

$binpath = $installpath.toString() + "\applicationservice.exe ""SessionUser=$sessionuser"" ""ProcessName=$processname"" ""LogName=$logname"" ""ProgramPath=$programpath"" ""ProgramArguments=$programarguments"" ""WorkDirectory=$workdirectory"" Visible=$visible TerminateTimeout=$terminatetimeout"


New-Service -Name $name -BinaryPathName $binPath -DisplayName $displayname -Description $description -StartupType Manual

Thanks

silicontrip
  • 906
  • 7
  • 23
  • What should be the format you need to create? `"SessionUser"="Engineering"` ? or `"SessionUser='Engineering'"` or ?? – Theo Nov 26 '20 at 13:19
  • They are being parsed on the command line (I assume cmd) and just need to be separated by a space. They are read in the OnStart method using this code; `string[] clArgs = Environment.GetCommandLineArgs();` So the final argument passed on the command line should not have any quotes; `SessionUser=Engineering` – silicontrip Nov 27 '20 at 00:13

3 Answers3

1

Note:

  • This answers addresses the question as asked, with respect to the quoting and escaping required to construct a command line via a single string.

  • For a robust programmatic alternative that constructs the command line via a hashtable and a loop, see Mark's own answer.


Potential problem: If $installpath.toString() returns a path with spaces, you'll have to use embedded quoting for the executable path as well.

Definite problem:

The following argument itself has embedded ":

$programarguments='"C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe -jar C:\Program Files (x86)\Sony CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar"'

However, this embedded quoting isn't placed correctly: the executable path and the -jar argument individually need enclosing in "...", because both have embedded spaces:

$programarguments='"C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe" -jar "C:\Program Files (x86)\Sony CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar"'

In order to embed this value inside another double-quoted string for command-line use, you must escape its embedded " as either "" or \" (see below for when to use which), which you can do with -replace '"', '""' or -replace '"', '\"'.

The following uses an expandable here-string (@"<newline>...<newline>"@) to simplify the embedded quoting and spreads the command across several lines for readability (the resulting newlines are removed afterwards with -replace '\r?\n'):

$binpath = $installpath.toString() + @"
\applicationservice.exe 
  SessionUser="$sessionuser" 
  ProcessName="$processname"
  LogName="$logname"
  ProgramPath="$programpath"
  ProgramArguments="$($programarguments -replace '"', '""')"
  WorkDirectory="$workdirectory"
  Visible=$visible
  TerminateTimeout=$terminatetimeout
"@ -replace '\r?\n'

Note:

  • The above only uses embedded double-quoting for the value part of the <property>=<value> pairs (e.g., foo="bar none" rather than "foo=bar none"), which, unfortunately, is not an uncommon requirement on Windows (notably with msiexec), and it also seems to be necessary here, judging from your own answer,

  • " embedded inside the $programarguments value are escaped as "" rather than as \":

    • Either form of escaping typically works, but "" has the advantage that you needn't worry about values ending in \, which with \"-escaping would additionally require you to escape that trailing \ as \\.

    • The caveat is that while most CLIs on Windows recognize both "" and \" as an escaped ", some recognize only \, such as Ruby, Perl, and notably also applications that use the CommandLineToArgv WinAPI function.

See also:


As an aside:

  • The backtick ` is PowerShell's general-purpose escape character.

  • Inside "..." strings (only), you can alternatively escape an embedded " char. as "".

For instance, both " `"hi`" "` and " ""hi"" " return verbatim  "hi" .

The exception is that when PowerShell is called from the outside, via its CLI, only \" is recognized as an escaped " in Windows PowerShell, so as to be consistent with other CLIs, whereas PowerShell [Core] v6+ also accepts "".

mklement0
  • 382,024
  • 64
  • 607
  • 775
1

Thanks to everyone who took time to comment and answer.

This is the single line PS script string which ended up working (look 6 double quotes in a row);

"$($inspath)\applicationservice.exe SessionUser=Engineering ProcessName=explorer 
LogName=""Landing Zone"" ProgramPath=""C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe"" 
ProgramArguments=""""""C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe"""" -jar """"c:\users\Engineering\Desktop\Sony CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar"""""" 
WorkDirectory=""c:\users\Engineering\Desktop\Sony CI\Sony-Ci-LZ-1.4.22"" Visible=1 
TerminateTimeout=1000"

Which ends up like this command line, as shown in the PathName property of the WMI win32_service.

C:\Users\mheath\Documents\20201106-MakeMeAService\ServiceScripts\applicationservice.exe
SessionUser=Engineering ProcessName=explorer LogName="Landing Zone" ProgramPath="C:\Program Files
(x86)\Common Files\Oracle\Java\javapath\java.exe" ProgramArguments="""C:\Program Files (x86)\Common
Files\Oracle\Java\javapath\java.exe"" -jar ""c:\users\Engineering\Desktop\Sony
CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar""" WorkDirectory="c:\users\Engineering\Desktop\Sony
CI\Sony-Ci-LZ-1.4.22" Visible=1 TerminateTimeout=1000

And finally the command line as read by the service, using Environment.GetCommandLineArgs(); in the OnStart() method (comma separated)

C:\Users\mheath\Documents\20201106-MakeMeAService\ServiceScripts\applicationservice.exe,SessionUser=mheath,ProcessName=explorer,LogName=Landing Zone test,ProgramPath=C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe,ProgramArguments="C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe" -jar "c:\users\Engineering\Desktop\Sony CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar",WorkDirectory=c:\users\Engineering\Desktop\Sony CI\Sony-Ci-LZ-1.4.22,Visible=1,TerminateTimeout=1000

I made some significant changes to the script, putting the config in a HashTable and using the -f string formatter.

$installpath = (get-location)
$name="testlz"
$displayName="Testlz"
$description="Testlz"

$config = @{
    SessionUser='mheath';
    ProcessName='explorer';
    LogName='Landing Zone test';
    ProgramPath= 'C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe';
    ProgramArguments='"C:\Program Files (x86)\Common Files\Oracle\Java\javapath\java.exe" -jar "c:\users\Engineering\Desktop\Sony CI\Sony-Ci-LZ-1.4.22\Sony-Ci-LZ-1.4.22.jar"';
    WorkDirectory='c:\users\Engineering\Desktop\Sony CI\Sony-Ci-LZ-1.4.22';
    Visible=1;
    TerminateTimeout="1000";
}

$escconfig = @{}

$escconfig.Add('ServicePath', $installpath.toString() + '\applicationservice.exe')

foreach ($it in $config.Keys)
{
    $escconfig[$it] = '"{0}"' -f ($config[$it] -replace '"','""')
}

$binpath = '{0} SessionUser={1} ProcessName={2} LogName={3} ProgramPath={4} ProgramArguments={5} WorkDirectory={6} Visible={7} TerminateTimeout={8}' `
    -f $escconfig['ServicePath'], $escconfig['SessionUser'], $escconfig['ProcessName'], $escconfig['logname'], `
    $escconfig['ProgramPath'], $escconfig['ProgramArguments'], $escconfig['WorkDirectory'], $escconfig['Visible'], $escconfig['TerminateTimeout']

New-Service -Name $name -BinaryPathName $binPath -DisplayName $displayname -Description $description -StartupType Manual

This hopefully protects any other paths that contain spaces.

PS. Any discrepancies you see from one text code block to the next, are just because I copied and pasted it as I was testing.

mklement0
  • 382,024
  • 64
  • 607
  • 775
silicontrip
  • 906
  • 7
  • 23
  • It does need the extra quotes. As this string is then parsed by cmd so the individual arguments are grouped together and received correctly by my service. I tried it with only a single quote replacement and removed the quotes around {0}. This fails because CMD sees the escaped quote characters and continues to treat the spaces as argument delimiters. On top of this my service makes a call to CreateProcess which performs another argument parse on it and needs the spaces protected, so that command receives the arguments correctly. Hope this makes sense. Talk about Inception! – silicontrip Nov 28 '20 at 03:43
  • 1
    Ah, yes - the triple quotes in the resulting string come from `"..."` having embedded, escaped `""...""` strings in the `ProgramArguments` property value (via `$programarguments`), which my answer actually also results in. As an inconsequential aside in this case: I don't think `cmd.exe` is involved when a service command line is executed - the target executable is directly invoked. – mklement0 Nov 28 '20 at 04:03
  • the `lpCommandLine` argument of `CreateProcess()` must be doing some smart parsing, as it's an LPSTR when createprocess is called, but arrives as a primative array to the executing program. So it must performs some kind of quote and space escaping rules. – silicontrip Nov 28 '20 at 07:55
  • Argument-passing on Windows is never _array_-based, sadly; instead, a single command-line string encoding all arguments is passed, and it is up to the program being invoked to parse it. The only thing [`CreateProcess()`](https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessa) does is to separate out the executable name - the first whitespace-separated unquoted or double-quoted token - if the executable name or path is passed as part of `lpCommandLine` rather than separately via `lpApplicationName`. The remaining string is passed through. – mklement0 Nov 28 '20 at 13:18
  • P.S.: The non-involvement of `cmd.exe` is implied by the linked docs stating that in order to execute a batch file you must use `cmd.exe` as `lpApplicationName` and `/c ` as `lpCommandLine`. – mklement0 Nov 28 '20 at 15:25
  • Something is converting it from a string argument to a string[] array. If my service did receive just a single string I wouldn't have to make sure the quotes are correct, because I could parse it how I like but I receive an array already space delimitered. – silicontrip Nov 29 '20 at 23:09
  • 1
    For C/C++/C# applications, it is the _underlying runtime_ that does the parsing for you (which you can bypass, if you need custom parsing); see https://learn.microsoft.com/en-us/cpp/cpp/main-function-command-line-args – mklement0 Nov 29 '20 at 23:16
-1

You should escape your quotes with a backtick:

$test = "`"test`""

Write-Host $test

Or you can use a here string like this

$test = @'
"test"
'@

Write-Host $test

Both write "test" to the console

Bassie
  • 9,529
  • 8
  • 68
  • 159
  • Or triple double quotes `Write-Host """test"""` – Doug Maurer Nov 26 '20 at 03:12
  • 1
    This isn't a plain how to escape a string question. I receive properly quoted strings as individuals but then I have to nest those strings into another string with quotes. $a='"A"' $b='"b"' $c ="""$a"" ... ""$b""" – silicontrip Nov 26 '20 at 04:16
  • my edit is still pending, so, if you do this `$test2 = "``"$test``""` (I cannot set correcly this on the comment : only one escape character on each side) with the above `$test` declaration, `$test2` will contain `""test""`, I think that is what you want to obtain – CFou Nov 26 '20 at 16:43
  • @Doug, that's not really triple-quoting, that is escaping embedded `"` chars as `""`, which is what Mark employs in his question and which is merely an alternative to `\`"`-escaping - which is why this answer doesn't help. – mklement0 Nov 26 '20 at 17:19
  • @CFou, the question already shows proper string interpolation in principle, and `""` and `\`"` can be used interchangeably for escaping `"` inside `"..."`. On a meta note: To include a verbatim `\`` in a comment (as I've done here), `\ `-escape it. – mklement0 Nov 26 '20 at 17:23