-
Notifications
You must be signed in to change notification settings - Fork 2
/
Native.psm1
1552 lines (1258 loc) · 68.1 KB
/
Native.psm1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
###
# IMPORTANT: KEEP THIS MODULE PSv3-COMPATIBLE.
# Notably:
# * do not use .ForEach() / .Where()
# * do not use ::new()
# * do not use Get-ItemPropertyValue
# * do not use New-TemporaryFile
###
Set-StrictMode -Version 1
# For older WinPS versions: Set OS/edition flags (which in PSCore are automatically defined).
# Note: Unlike in the Pester test files, $script: isn't strictly needed here, but silences
# the PSSA warning re assigning to automatic variables.
if (-not (Test-Path Variable:IsWindows)) { $script:IsWindows = $true }
if (-not (Test-Path Variable:IsCoreCLR)) { $script:IsCoreCLR = $false }
# Test if a workaround for PowerShell's broken argument passing to external
# programs as described in
# https://github.com/PowerShell/PowerShell/issues/1995#issuecomment-562334606
# is still required.
# NOTE:
# We activate the PowerShell Core 7.2.0-preview.5+ experimental PSNativeCommandArgumentPassing feature, which is
# aimed at fixing #1995, and MAY be good enough to obviate our workarounds, but NOT YET AS OF PowerShell Core 7.2.0-preview.5,
# because it LACKS VITAL ACCOMMODATIONS FOR CLIS ON WINDOWS - see https://github.com/PowerShell/PowerShell/issues/15143
# We test
$PSNativeCommandArgumentPassing = 'Standard'
$script:needQuotingWorkaround = if ($IsWindows) {
# The `choice` command is a trick to print an argument as-is; choice.exe expects \"-escaping.
(choice.exe /d Y /t 0 /m 'Nat "King" Cole') -notmatch '"' -or
$( # The \" test passed, but we must also test for batch-file ("") and msiexec-style accommodations (partial quoting of `foo="bar none"` arguments), in case a fix in PowerShell itself fails to incorporate these.
$tmpBatchFile = [IO.Path]::GetTempFileName(); [IO.File]::Delete($tmpBatchFile); $tmpBatchFile += '.cmd'
'@echo [%*]' | Set-Content -LiteralPath $tmpBatchFile
(& $tmpBatchFile 'Andre "The Hawk" Dawson' 'foo=bar none') -ne '["Andre ""The Hawk"" Dawson" foo="bar none"]'
[IO.File]::Delete($tmpBatchFile)
)
}
else {
# NOTE: On Unix, even the currently-lacking experimental PSNativeCommandArgumentPassing feature is a suffixient fix
# that obviates our workaround.
(printf %s '"ab"') -ne '"ab"'
}
if ($script:needQuotingWorkaround) {
# We've determined that we (still) need our workarounds - on Windows even with PSNativeCommandArgumentPassing as of PowerShell Core 7.2.0-preview.5.
# Therefore, we deactivate the feature now, as our workarounds rely on the old, broken behavior.
$PSNativeCommandArgumentPassing = 'Legacy'
}
#region -- EXPORTED members (must be referenced in the *.psd1 file too)
# -- Define the ALIASES to EXPORT (must be exported in the *.psd1 file too).
Set-Alias ins Invoke-NativeShell
Set-Alias dbea Debug-ExecutableArguments
# SEE THE BOTTOM OF THIS #region FOR AN Export-ModuleMember CALL REQUIRED
# FOR PSv3/4 COMPATIBILITY.
# Note: 'ie' and 'iee' are *directly* used as the *function* names,
# deliberately forgoing verbose names, for the reasons explained
# in the comment-based help for 'ie'.
# --
function Invoke-NativeShell {
<#
.SYNOPSIS
Executes a native shell command line. Aliased to: ins
.DESCRIPTION
Executes a command line or ad-hoc script using the platform-native shell,
optionally with pass-through arguments.
If no argument and no pipeline input is given, an interactive shell is entered.
Otherwise, pass a *single string* comprising one more commands for the native
shell to execute; e.g.:
ins 'whoami && echo hi'
The native shell's exit code will be reflected in $LASTEXITCODE; use only
$LASTEXITCODE to infer success vs. failure, not $?, which always ends up
$true for technical reasons.
Unfortunately, this means that you cannot meaningfully combine this command
with && and ||, the pipeline-chain operators.
However, if you want to automatically abort a script (throw a script-terminating
error) in case of a nonzero exit code, you can use -e (-ErrorOnFailure).
For command lines with tricky quoting, use here-strings; e.g., on Unix:
ins @'
printf '%s\n' "3\" of rain."
'@
Use an interpolating (here-)string to incorporate PowerShell values; use `$
for $ characters to pass through to the native shell; e.g., on Unix:
ins @"
printf 'PS version: %s\n' "$($PSVersionTable.PSVersion)"
"@
Pipeline input is supported in two fundamental modes:
* The pipeline input is the *command line* to execute (`<commands> | ins`):
* In this case, no -CommandLine argument must be passed, or, if pass-through
arguments are specified, it must be '-' to explicitly signal that the
command line is coming from the pipeline (stdin)
(`<commands> | ins - passThruArg1 ...`).
Alternatively, use parameeter -Args explicitly with an array of values to
unambiguously identify them as pass-through arguments
(`<commands> | ins -Args passThruArg1, ...`).
* The pipeline input is *data* to pass *to* the command line to execute
(`<data> | ins <commands>`):
.PARAMETER CommandLine
The command line or ad-hoc script to pass to the native shell for execution.
Ad-hoc script means that the string you pass can act as a batch file / shell
script that is capable of receiving any pass-through arguments passed via
-ArgumentList (-Args) the usual way (e.g., %1 as the first argument on Windows,
and $1 on Unix).
You may omit this parameter and pass the command line via the pipeline instead.
If you use the pipeline this way on and you additionally want to specify
pass-through arguments positionally, you can pass '-' as the -CommandLine
argument to signal that the code is specified via the pipeline; alternatively,
use the -ArgumentList (-Args) parameter explicitly, in which case you must
specify the arguments as an *array*.
IMPORTANT:
On Windows, the command line isn't executed directly by cmd.exe,
but via a temporary *batch file*. This means that batch-file syntax rather
than command-prompt syntax is in effect, which notably means that you need %%
rather than just % before `for` loop variables (e.g. %%i) and that you may
escape verbatim % characters as %%
.PARAMETER ArgumentList
Any addtional arguments to pass through to the ad-hoc script passed to
-CommandLine or supplied via the pipeline.
IMPORTANT:
* These arguments bind to the standard batch-file / shell-script
parameters starting with %1 / $1. See the NOTES section for more information.
* If you pass the pass-through arguments individually, positionally,
precede them with an extra '--' argument to avoid name conflicts with
this function's own parameters (which includes the supported common
parameters). This is also necessary if you want to pass -- through to the
native shell.
* If the command line is supplied via the pipeline, you must either pass '-'
as -CommandLine or use -ArgumentList / -Args explicitly and specify the
pass-through arguments *as an array*.
* For technical reasons you must *quote* arguments that contain commas,
look like `-foo:bar` or `-foo.bar`, e.g. `'foo,bar'` instead of `foo,bar`.
.PARAMETER UseSh
Supported on Unix-like platforms only (ignored on Windows); aliased to -sh:
Uses /bin/sh rather than /bin/bash for execution.
Note that /bin/sh, which is the official system shell on Unix-like platforms,
can be expected to support POSIX-compliant features only, which notably
precludes certain Bash features, such as [[ ... ]] conditionals, process
substitutions, <(...), and Bash-specific variables.
Conversely, if your command line does work with -UseSh, it can be assumed
to work in any of the major POSIX-compatible shells: bash, dash, ksh, and zsh.
.PARAMETER ErrorOnFailure
Triggers a script-terminating error if the native shell indicates overall
failure of the command line's execution via a nonzero exit code; aliased to
-e.
The error record generated shows an *approximation* of the original command
line in its TargetObject property; that is, it shows a concatenation of the
verbatim expanded arguments without consistently reflecting necessary quoting
or escaping.
IMPORTANT: This switch acts independently of PowerShell's error handling,
which as of v7.1 does not act on nonzero exit codes reported by external
executables.
There is a pending RFC to change that:
https://github.com/PowerShell/PowerShell-RFC/pull/88
Once it gets implemented, this commmand will
be subject to this new integration in the absence of -ErrorOnFailure (-e).
.PARAMETER InputObject
This is an auxiliary parameter required for technical reasons only.
Do not use it directly.
.EXAMPLE
ins 'ver & date /t & echo %CD%'
Windows example: Calls cmd.exe with the given command line, which ouputs
cmd.exe version information and prints the current date and working directory.
.EXAMPLE
'ver & date /t & echo %CD%' | ins
Windows example: Equivalent command using pipeline input to pass the command
line.
.EXAMPLE
'foo', 'bar' | ins 'findstr bar & ver'
Windows example: Passes data to the command line via the pipeline (stdin).
.EXAMPLE
$msg = 'hi'; ins "echo $msg"
Uses string interpolation to incorporate a PowerShell variable value into
the native command line.
.EXAMPLE
ins -e 'whoami -nosuchoption'
Uses -e (-ErrorOnFailure) to request throwing an error if the native shell
reports a nonzero exit code, as is the case here.
.EXAMPLE
ins 'ls / | cat -n'
Unix example: Calls Bash with the given command line, which lists the files
and directories in the root directory and numbers the output lines.
.EXAMPLE
ins 'ls "$1" | cat -n; echo "$2"' $HOME 'Hi there.'
Unix example: uses a pass-through argument to pass a PowerShell variable value
to the Bash command line.
.EXAMPLE
'ls "$1" | cat -n; echo "$2"' | ins -UseSh - $HOME 'Hi there.'
Unix example: Equivalent of the previous example with the command line passed
via the pipeline, except that /bin/sh is used for execution.
Note that since the command line is provided via the pipeline and there are
pass-through arguments present, '-' must be passed as the -CommandLine argument.
.EXAMPLE
'one', 'two', 'three' | ins 'grep three | cat -n'
Unix example: Sends data through the pipeline to pass to the native command
line as stdin input.
.EXAMPLE
ins @'
printf '%s\n' "6'1\" tall"
'@
Unix example: Uses a (verbatim) here-string to pass a command line with
complex quoting to Bash.
.NOTES
* By definition, calls to this function are *platform-specific*.
To perform platform-agnostic calls to a single external executable, use the
`ie` function that comes with this module.
* On Unix-like platforms, /bin/bash is used by default, due to its ubiquity.
If you want to use the official system default shell, /bin/sh, instead, use
-UseSh. Without -UseSh, /bin/sh is also used as a fallback in the unlikely
event that /bin/bash is not present.
* When /bin/bash and /bin/sh accept a command line as a CLI argument, it is via
the -c option, with subsequent positional arguments getting passed
*to the command line* being invoked; curiously, however, the first such
argument sets the *invocation name* that the command line sees as special
parameter $0; it is only the *second* argument that becomes $1, the first
true script parameter (argument).
Since this is somewhat counterintuitive and since setting $0 in this scenaro
is rarely, if ever, needed, this function leaves $0 at its default (the path
of the executing shell) and passes any pass-through arguments (specified via
-ArgumentList / -Args or positionally) starting with parameter $1.
* On Windows, <systemRoot>\System32\cmd.exe is used, where <systemroot> is
the Windows directory path as stored in the 'SystemRoot' registry value at
'HKEY_LOCAL_MACHINE:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'; the
following options, which explicitly request cmd.exe's default behavior, are
used to ensure a predictable execution environment: /d /e:on /v:off
* That $? ends up $true even if the native shell reported a nonzero exit code
(reflected in $LASTEXITCODE) cannot be avoided as of v7.1; however there are
plans to eventually make $? settable from user code; see
https://github.com/PowerShell/PowerShell/issues/10917#issuecomment-550550490
#>
[CmdletBinding(PositionalBinding = $false)]
param(
[Parameter(Position = 1)]
[string] $CommandLine
,
[Parameter(Position = 2, ValueFromRemainingArguments = $true)]
[Alias('Args')]
[string[]] $ArgumentList
,
[Alias('sh')]
[switch] $UseSh
,
[Alias('e')]
[switch] $ErrorOnFailure
,
[Parameter(ValueFromPipeline = $true)] # Dummy parameter to ensure that pipeline input is accepted, even though we use $input to process it.
$InputObject
)
# Note: If -UseSh is passed on Windows, we *ignore* it rather than throwing an error.
# This makes it easier to create cross-platform commands in that only the command-line
# string must be provided via a variable (with platform-appropriate content).
$nativeShellExePath = if ($IsWindows) {
# For increased robustness, rely on the SystemRoot definition (typically, C:\Windows)
# from the registry rather than $env:ComSpec, given that the latter could
# have been (more easily) modified, even in-process.
'{0}\System32\cmd.exe' -f $(try {
(Get-Item -ErrorAction Stop -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').GetValue('SystemRoot', 'C:\Windows')
}
catch {
'C:\Windows'
})
}
else {
# Note: By default, due to its ubiquity, we use /bin/bash, unless -UseSh was passed or /bin/bash doesn't exist (unlikely).
if ($UseSh -or -not (Test-Path -PathType Leaf '/bin/bash')) {
# The de-facto standard location for the default system shell on Unix-like platforms.
'/bin/sh'
}
else {
'/bin/bash'
}
}
# We invoke the native shell via 'ie' by default, or, if erroring out on
# nonzero exit codes is requsted, via 'iee'
$invokingFunction = ('ie', 'iee')[$ErrorOnFailure.IsPresent]
$havePipelineInput = $MyInvocation.ExpectingInput
$pipelineInputIsCommandLine = $havePipelineInput -and (-not $CommandLine -or $CommandLine -eq '-')
if (-not $havePipelineInput -and -not $CommandLine) {
# If neither a command line nor pipeline input is given, enter an interactive
# session of the target shell.
Write-Verbose "Entering interactive $nativeShellExePath session..."
& $invokingFunction $nativeShellExePath
}
else {
# A command line / ad-hoc script must be passed to the native shell.
# NOTE: For reasons that differ by platform, we translate a code-via-stdin (pipeline) (`... | ins`)
# invocation to a code-by-argument / code-by-temporary-batch-file invocation.
if ($pipelineInputIsCommandLine) {
$pipelineInputIsCommandLine = $havePipelineInput = $false
$CommandLine = @($Input) -join "`n" # Collect all pipeline input and join the (stringified) objects with newlines.
# RATIONALE:
# Windows:
# * cmd.exe doesn't properly support piping *commands* to it:
# The "logo" is always printed, and lines are executed one after, with
# the prompt string printed after each, and by default each command is
# also echoed before execution (only this aspect can be controlled, with /q)
# * Similarly, passing multi-line strings to cmd /c isn't supported:
# Only the *first* line is processed, the rest are *ignored*.
# Since we're using a temporary *batch file* for invocation anyway,
# we can offer the same code-via-pipeline experience as with bash/sh:
# Essentially, a multi-line ad-hoc batch file may be passed.
#
# Unix:
# * While bash/sh are perfectable cable of receiving ad-hoc scripts via
# stdin, we still translate the invocation into a `-c <string>`-based
# one, so as to support commands that ask for *interactive input*.
# If we used the pipeline/stdin, the *code itself*, by virtue of
# being received via stdin, would be used to automatically respond to
# interactive prompts.
}
# As a courtesy, we make sure that if arguments are passed, the target
# commmand (ad-hoc script) actually contains references to those arguments,
# and warn otherwise, as that likely means that the user accidentally
# used `ins foo bar` when they meant to use `ins 'foo bar'`
if ($ArgumentList) {
# Note: These regexes aren't fool-proof (may yield false positives that result in no warning when there should be one), but should work well enough.
# We could in theory limit the $\d / %\d matching to the number of arguments passed (e.g., not allow $3 if only 2 args. are passed), but that doesn't seem worth it.
$regex = if ($IsWindows) { '%\*|%\d|%~[^\d]*\d' } else { '\$\{?\*\}?|\$\{?@\}?|\$\{?\d\}?' }
if ($CommandLine -notmatch $regex) {
Write-Warning "You are passing arguments to the -CommandLine argument, but the latter doesn't reference them. Did you mean to make them part of the -CommandLine string? E.g. ``ins 'echo hi'`` rather than ``ins echo hi``"
}
}
if ($IsWindows) {
# On Windows, we use a temporary batch file to avoid re-quoting problems
# that would arise if the command line were passed to cmd /c as an *argument*.
# This invariably means that batch-file rather than command-line syntax is
# expected, which, however, is arguably preferable anyway.
Write-Verbose "Passing commands via a temporary batch file to $nativeShellExePath..."
$tmpBatchFile = [IO.Path]::GetTempFileName(); [IO.File]::Delete($tmpBatchFile); $tmpBatchFile += '.cmd'
# Write the command line to the temp. batch file.
Set-Content -Encoding Oem -LiteralPath $tmpBatchFile -Value "@echo off`n$CommandLine"
# To be safe, use an empty array rather than $null for array splatting below.
if ($null -eq $ArgumentList) { $ArgumentList = @() }
# IMPORTANT: We must only use `$input | ...` if actual pipeline input
# is present. If no input is present, PowerShell *still
# redirects* the target executable's stdin and simply makes it
# *empty*. This causes command lines with *interactive* prompts
# to malfunction.
# Also: We cannot use an intermediate script block invoked
# with & here in order to avoid duplicating the actual
# command: the pipeline input is then NOT passed through
# (you'd have to use $input inside the script block too, which amounts to a catch-22).
if ($havePipelineInput) {
# Note: For predictability, we use explicit switches in order to get what should be the default
# behavior of cmd.exe on a pristine system:
# /d == no auto-run, /e:on == enable command extensions; /v:off == disable delayed variable expansion
# IMPORTANT: Changes to this call must be replicated in the `else` branch below.
$input | & $invokingFunction $nativeShellExePath /d /e:on /v:off /c "$tmpBatchFile $ArgumentList & exit" # !! The '& exit' is for robust exit-code reporting - see https://stackoverflow.com/a/67009271/45375
}
else {
# IMPORTANT: Changes to this call must be replicated in the `if` branch above.
& $invokingFunction $nativeShellExePath /d /e:on /v:off /c "$tmpBatchFile $ArgumentList & exit" # !! See explanation for the `& exit ` part above.
}
Remove-Item -ErrorAction Ignore -LiteralPath $tmpBatchFile
}
else {
# Unix
Write-Verbose "Passing commands as an argument to $nativeShellExePath..."
$passThruArgs = if ($ArgumentList.Count) {
# POSIX-like shells interpret the first post `-c <code>` operand as $0,
# which is both unexpected and rarely useful.
# We abstract this behavior away by emulating the default $0 value (the
# name/path of the shell being invoked) and by passing the
# the pass-through arguments starting with $1.
, $nativeShellExePath + $ArgumentList
}
else {
@()
}
# IMPORTANT: We must only use `$input | ...` if actual pipeline input
# is present. If no input is present, PowerShell *still
# redirects* the target executable's stdin and simply makes it
# *empty*. This causes command lines with *interactive* prompts
# to malfunction.
# Also: We cannot use an intermediate script block invoked
# with & here in order to avoid duplicating the actual
# command: the pipeline input is then NOT passed through.
# (you'd have to use $input inside the script block too).
if ($havePipelineInput) {
# IMPORTANT: Changes to this call must be replicated in the `else` branch.
$input | & $invokingFunction $nativeShellExePath -c $CommandLine $passThruArgs
}
else {
& $invokingFunction $nativeShellExePath -c $CommandLine $passThruArgs
}
}
}
# Witout -e, $? *always* ends up as $true for the caller, irrespective of
# the $LASTEXITCODE value, unfortunately, which cannot be helpd as of v7.1.
# This means you cannot use this function meaningfully with && and ||.
# There is no workaround as of PowerShell Core 7.1.0-preview.5, but there
# are plans to make $? settable by user code: see
# https://github.com/PowerShell/PowerShell/issues/10917#issuecomment-550550490
}
function ie {
<#
.SYNOPSIS
Invokes an external executable with robust argument passing.
.DESCRIPTION
Invokes an external executable with arguments passed through properly, even if
they contain embedded double quotes or they're the empty string, to compensate
for PowerShell's broken argument passing up to at least v7.1
'ie' stands for 'Invoke (External) Executable'
Use this function by simply prefixing a call to an external executable with
'ie' as the command (if invocation via call operator '&' would
normally be necessary, use 'ie' *instead* of it). E.g., on Unix:
ie printf '"%s" ' print these arguments quoted
IMPORTANT:
* To check if the executable signaled failure, see if $LASTEXITCODE is nonzero.
Do not use $?, which always ends up as $true.
Unfortunately, this means that you cannot meaningfully use this function with
&& and ||, the pipeline-chain operators.
However, if you want to automatically abort a script (throw a
script-terminating error) if failure is signaled, you can call the related
'iee' wrapper function.
* Use of --%, the stop-parsing symbol, with this function is NOT supported,
but it is never necessary on Unix platforms, and should generally not be
necessary on Windows, because this function automatically handles special
quoting needs of batch files and, in PowerShell v5.1 and above, of
high-profile CLIs such as msiexec.exe, msdeploy.exe, and cmdkey.exe -
see the NOTES section. In the rare event that you do need --%, use it with
direct invocation, as usual, or invoke via ins (Invoke-NativeShell).
* -- as an argument is invariably removed by PowerShell on invocation, for
technical reasons. If you need to pass -- through to the executable, use
the form `ie -- ...`, i.e. use an extra -- before all arguments.
* For technical reasons you must *quote* arguments that contain commas,
look like `-foo:bar` or `-foo.bar`, e.g. `'foo,bar'` instead of `foo,bar`.
Since the invocation solely relies on PowerShell's own argument-mode
syntax and since, as in direct invocation, no other shell is involved,
this function is suitable for use in *cross-platform* code, unlike the
platform-specific calls to Invoke-NativeShell / ins.
This function is intentionally implemented as a *simple* function, and
therefore doesn't support any common parameters (just like direct invocation
doesn't).
.EXAMPLE
ie echoArgs.exe '' 'a&b' '3" of snow' 'Nat "King" Cole' 'c:\temp 1\' 'a \" b' 'a"b'
Calls the echoArgs.exe executable on Windows, which echoes the individual
arguments it receives in diagnostic form as follows, showing that the arguments
were passed as intended:
Arg 0 is <>
Arg 1 is <a&b>
Arg 2 is <3" of snow>
Arg 3 is <Nat "King" Cole>
Arg 4 is <c:\temp 1\>
Arg 5 is <a \" b>
Arg 6 is <a"b>
Command line:
"C:\ProgramData\chocolatey\lib\echoargs\tools\EchoArgs.exe" "" a&b "3\" of snow" "Nat \"King\" Cole" "c:\temp 1\\" "a \\\" b" a\"b
Note: echoArgs.exe is installable via Chocolatey using the following commmand
from an elevated session:
choco install echoargs -y
However, the dbea (Debug-NativeExecutable) command that comes with the same
module as this function provides the same functionality, and the equivalent
invocation would be:
dbea -ie -- '' 'a&b' '3" of snow' 'Nat "King" Cole' 'c:\temp 1\' 'a \" b' 'a"b'
.NOTES
Background information on PowerShell's broken argument handling:
https://github.com/PowerShell/PowerShell/issues/1995#issuecomment-562334606
That $? ends up $true even if the executable reported a nonzero exit code
(reflected in $LASTEXITCODE) cannot be avoided as of v7.1; however there are
plans to eventually make $? settable from user code; see
https://github.com/PowerShell/PowerShell/issues/10917#issuecomment-550550490
That you must *quote* arguments that contain commas, look like `-foo:bar` or
`-foo.bar`:
* is unavoidable in the case of values with commas: PowerShell-native commands
- which this module's commands are - receive such arguments differently
than external executables, namely as *arrays*, and passing such arrays on to
external executables invariably passes the array's elements as *individual
arguments*:
* `a,b` and `a, b` both become array `'a', 'b'`, which, when passed to an
external exeuctables passes two separate arguments `a` and `b`.
* in the case of `-foo:bar` and `-foo.bar` it is arguably PowerShell bugs that
cause such arguments to be broken into *two* - see these issues:
https://github.com/PowerShell/PowerShell/issues/6360
https://github.com/PowerShell/PowerShell/issues/6291
These transformations happen before this module's commands receive their
arguments, without their knowledge, so they cannot be compensated for.
External executable in this context means any executable that PowerShell must
invoke via a child process, which encompasses not just binary executables,
but also batch files, WSH scripts, and other shells' or scripting languages'
scripts.
The only reason for this function's existence is that, up to at least
PowerShell 7.1, arguments passed to external programs are not passed
correctly if they are either the empty string or have embedded double quotes.
Should the underlying problem ever be fixed in PowerShell itself, this
function will no longer apply its workarounds and will effectively act like
'&', the call operator. See the NOTES section for a link to more information.
This function is intentionally designed to be a minimalist stopgap that
should be unobtrusive and simple to use. It is therefore implemented as
a *simple* function and does *not* support common parameters (just like
you can't use common parameters with direct invocation).
The specifics of accommodating batch-file calls are as follows:
* Embedded double quotes, if any, are escaped as "" in all arguments.
* Any argument that contains *no spaces* but contains either double quotes
or cmd.exe metacharacters such as "&" is enclosed in double quotes
(whereas PowerShell by default only encloses arguments *with spaces* in
double quotes); e.g., a verbatim argument seen by PowerShell as `a&b` is
placed as `"a&b"` on the command line passed to a batch file; specifically,
the following characters trigger this behavior: & | < > ^ , ; "
* CAVEAT: An argument that starts with a space-less word followed by `=`,
e.g. `a=b`, is always passed with that word and the `=` *unquoted*, as part
of the accommodations for msiexec-style CLIs (see below). While this doesn't
affect argument pass-through with `%*`, intra-batch-file parsing of arguments
results in such a token being broken into *two* arguments, `a` and `b`.
NOTE: Calling cmd.exe directly with a command line passed as a single argument
- e.g. `ie cmd /c 'dir "C:\Program Files"'` - is supported,
but you don't actually need this function for that, because PowerShell's
lack of escaping of embedded double quotes is in this case canceled out
by cmd.exe not expecting such escaping. Additionally, as a courtesy, this
function transforms a multi-argument command line into a single-argument
one for increased robustness.
The specifics of accommodating WSH calls are as follows:
WSH (Windows Script Host, whose CLIs are cscript.exe and wscript.exe) is also
implicitly called when executing files with one of the following extensions,
which are present in $env:PATHEXT, comprising both VBScript and JScript files
and their variations, as well as WSH wrapper files:
Extensions: .vbs .vbe .js .jse .wsf .wsh
""-escaping of embedded double quotes is used when a WSH script is implicitly
or explicitly called, which, however, only *mitigates* the fundamental problem
that WSH supports neither \" or ""-escaping: With ""-escaping, while the
embedded " are still mistakenly *stripped*, at least the argument boundaries
are preserved.
The specifics of accommodating high-profile CLIs such as msiexec.exe /
msdeploy.exe and cmdkey.exe are as follows:
On Windows, any invocation that contains at least one argument of the following
forms triggers the behavior described below; `<word>` can be composed of
letters, digits, and underscores:
* <word>=<value>
* /<word>:<value>
* -<word>:<value>
More formally, an argument that matches regex '^([/-]\w+[=:]|\w+=)(.+)$'
triggers the behavior.
If such an argument is present:
* If the <value> part has spaces, only *it* is enclosed in double quotes,
not the argument *as a whole* (which is what PowerShell - justifiably - does
by default); e.g., a verbatim argument seen by PowerShell as `foo=bar none`
is placed as `foo="bar none"` on the process' command line (rather than as
`"foo=bar none"`). The aforementioned high-profile CLIs require this very
specific form of quoting, unfortunately.
* Additionally, embedded double quotes, if any, are escaped as "" for the
following executables: msiexec.exe, msdeploy.exe, and cmdkey.exe
#>
# IMPORTANT:
# We deliberately declare NO parameters, because any parameter could interfere
# with the pass-through arguments and then - unexpectedly and cumbersomely - require -- before these arguments.
# The problem is that even a single-character prefix of a declared parameter name would be bound to it.
# E.g., a -WhatIf parameter would be bound by -w
# param()
# Split into executable name/path and arguments.
# Note: We can't assume that $argsForExe will be a flat array - see below.
$exe, $argsForExe = $args
if (-not $exe) {
Throw (
(New-Object System.Management.Automation.ErrorRecord (
[System.Management.Automation.ParameterBindingException] "Missing mandatory parameter: Please specify the external executable to invoke.",
'MissingMandatoryParameter',
'InvalidArgument',
$null
))
)
}
# Resolve the targeted executable to its full path.
# Note:
# * Even if we wanted to support calls to PowerShell-native commands too,
# we cannot, given that this simple function is based on the array of positional arguments, $args.
# While @args has built-in magic for passing even *named* arguments through,
# we need to split $args into executable name and remaining arguments here, and the magic doesn't work with custom arrays.
# * We explicitly look for (aliases of) external executables only, bypassing other
# command forms that would normally have higher precedence, namely
# namely functions and cmdlets.
# !! Using -CommandType implies -All, so we must limit the results to the *first* command found.
# !! Due to this function being defined in a *module*, only aliases defined in the *global* scope are recognized.
$app = Get-Command -ErrorAction Ignore -CommandType Alias, Application $exe | Select-Object -First 1
if ($app -and $app.CommandType -eq 'Alias') { $app = $app.ResolvedCommand }
if (-not $app -or $app.CommandType -ne 'Application') {
# No command $exe that is (an alias of) an external executable (.CommandType 'Application') found.
$msg = if (-not $app) {
"No external executable '$exe' found."
}
else {
"Alias '$exe' resolves to '$($app.Name)', which is not an external executable."
}
Throw (
(New-Object System.Management.Automation.ErrorRecord (
[ArgumentException] $msg,
'ApplicationNotFoundException',
'InvalidArgument',
$exe
))
)
}
# Use the full path for invocation, to avoid having to re-resolve the executable as specified to the underlying full path.
# Note: Regrettably, Get-Command also reports *documents* as commands of type 'Application' - see https://github.com/PowerShell/PowerShell/issues/12625
# While we could do our own subsequent analysis to see if a true executable was specified, that doesn't seem worth the trouble.
# It's usually pointless to invoke a document directly *with additional arguments*, which are usually ignored.
$exe = $app.Path
# Flatten the array of arguments, because we also want to support invocations such as `ie $someArray foo bar`,
# i.e. a mix of array-splatting and indiv. args.
# NOTE:
# * We coerce to [string[]] up front, which applies PowerShell's usual culture-invariant string formatting for values such as `1.2`
# * Since we're by definition calling *external programs*, everything is passed as a string anyway; note that [Array]::FindIndex requires a strongly typed index, for instance.
$orgExeArgs = $argsForExe # Also save the array of original arguments with their original types, in case the workaround is no longer needed or a script block-based PowerShell CLI invocation is being performed.
[string[]] $argsForExe = foreach ($potentialArrayArg in $argsForExe) { foreach ($arg in $potentialArrayArg) { $arg } }
# Determine the base name and filename extension of the target executable, as we need to vary
# the quoting behavior based on it:
$null = $app.Path -match '[/\\]?(?<exe>[^/\\]+?)(?<ext>\.[^.]+)?$'
$exeBaseName, $ext = $Matches['exe'], $Matches['ext']
# Infer various executable characteristics:
$isBatchFile = $IsWindows -and $ext -in '.cmd', '.bat'
$isCmdExe = $IsWindows -and -not $isBatchFile -and ($exeBaseName -eq 'cmd' -and $ext -eq '.exe') # cmd.exe, the legacy Windows shell
$isWsh = $IsWindows -and -not ($isBatchFile -or $isCmdExe) -and ($ext -in '.vbs', '.vbe', '.js', '.jse', '.wsh', '.wsf' -or ($exeBaseName -in 'cscript', 'wscript' -and $ext -eq '.exe')) # WSH is being called, either explicitly or
# See if a PowerShell CLI is being invoked, so we can detect whether a *script block* is among the arguments,
# which causes PowerShell to transform the invocation into a Base64-encoded one using the -encodedCommand CLI parameter.
$isPsCli = $exeBaseName -in 'powershell', 'pwsh' -and $ext -in $null, '.exe'
# See if the exe is one of the high-profile CLIs that require *partial* double-quoting of arguments such as `FOO="bar none"`.
# Also, these exes require ""-escaping (except cmdkey.exe, which doesn't support embedded " at all).
$isMsiExecLikeExe = $exeBaseName -in 'msiexec', 'msdeploy', 'cmdkey' -and $ext -eq '.exe'
# The regex that determines for direct cmd.exe and batch-file calls, both of which are transformed into a `cmd /c "<command-line>"` call,
# which arguments require double-quoting, which obviously includes arguments with *spaces*, but also space-*less* arguments that contain
# cmd.exe metacharacters.
# NOTE: "=" - even though it also serves as an argument separator, alongside space, "," and ";" - is NOT included, so as to give precedence to the msiexec-style partial double-quoting requirements.
# !! CHANGES TO THIS REGEX MUST BE REPLICATED IN THE .NOTES SECTION of the comment-based help above.
$reMustDQuoteForCmd = '[&|<>^,; "]'
# The regex that matches all argument-interior embedded double quotes (to be applied after having enclosed an argument in outer double quotes).
$reInteriorDQuotes = '(?<=.)"(?!$)'
# The regex that identifies msiexec-style arguments such as `FOO="bar none"` or `/foo:"bar none"` / `-foo:"bar none"` that require *partial* double-quoting, namely
# of the *value* part (to the right of ":" / "=", if necessary.
# The regex splits into property/parameter name and value via capture groups, but itself doesn't try to determine if actual double-quoting of the value part is necessary.
# !! CHANGES TO THIS REGEX MUST BE REPLICATED IN THE .NOTES SECTION of the comment-based help above.
$rePartialDQuotingCandidate = '^([/-]\w+[=:]|\w+=)(.+)$'
$useStopParsingSymbol = $false # may be set below.
# == Construct the array of escaped arguments, if necessary.
[array] $escapedArgs =
if ($null -eq $argsForExe -and -not $isBatchFile) {
# To be safe: If there are no arguments to pass, use an *empty array* for splatting so as
# to be sure that *no* arguments are passed. We don't want to rely on passing $null
# getting the same no-arguments treatment in all PS versions.
# Exception: For robustness, *batch files* must becalled as cmd /c "<batch-file> ... & exit /b" - see below.
@()
}
elseif (-not $script:needQuotingWorkaround -or ($isPsCli -and ($orgExeArgs | ForEach-Object GetType) -contains [scriptblock])) { # Note: We cannot use .ForEach('GetType'), because we must remain PSv3-compatible.
# Use the arguments as-is if (a) the quoting workaround is no longer needed or (b) the engine itself will aply Base64-encoding behind the scenes
# using the -encodedCommand CLI parameter.
# Note: As of PowerShell Core 7.1.0-preview.5, the engine unexpectedly applies Base64-encoding in the presence of a [scriptblock] argument alone,
# irrespective of what executable is being invoked: see https://github.com/PowerShell/PowerShell/issues/4973
# In effect we're masking the bug by exhibiting more sensible behavior if the executable is NOT a PowerShell CLI (stringified script block, which may still not be the intent),
# in the hopes that the bug will get fixed and that direct execution will then exhibit the same behavior.
$orgExeArgs
}
elseif ($isCmdExe) {
# -- Special handling for cmd.exe
# Find the index of either the /c or the /k switch, whichever comes first:
# Note: The [string[]] cast ensures a strongly typed array, which is necessary for method overload resolution to find a suitable generic overload,
# Since we've type-constrained $argsForExe to [string[]] above, this isn't strictly necessary here, however.
$cmdLineArgOptionNdx = [Array]::FindIndex([string[]] $argsForExe, [Predicate[string]] { $args[0] -in '/c', '/k' })
if ($cmdLineArgOptionNdx -eq -1) {
# By definition, there's no command to pass to cmd.exe for execution, as cmd.exe ignores any unrecognized tokens unless
# they follow /c or /k - irrespective of whether they look like options (e.g. `/uga`) or operands (e.g., `foo`).
# We're either dealing with a valid invocation that uses only options to control the interactive sessions (e.g. `cmd /v`)
# or a broken invocation.
# Either way, there's nothing to escape, so pass all arguments through without escaping.
$argsForExe
}
else {
# The assumption is that anything preceding /c or /k is only other switches, which also need
# no escaping (anything else would be a broken cmd.exe invocation anyway.)
$argsForExe[0..$cmdLineArgOptionNdx]
# The remaining arguments by definition make up the command line to pass to cmd.exe.
# Note that the only way to *robustly* pass such a command line is *as a single argument* enclosed in `"..."` overall.
# Passing a single argument requires NO escaping, because PowerShell's lack of escaping of embedded double-quotes is
# in this case canceled out by cmd.exe not expecting such escaping(!)
# In short:
# * There is no need to use `ie` for passing a *single-argument* command-line to cmd /c or cmd /k.
# * However, at least we don't want to break if `ie` is used, and as a courtesy we make passing *multi-argument* command lines more robust.
if ($cmdLineArgOptionNdx -le $argsForExe.Count - 2) {
if ($cmdLineArgOptionNdx -eq $argsForExe.Count - 2) {
# *single-argument* command line, pass it through - rely on PowerShell's broken argument-passing.
$argsForExe[-1]
}
else {
# *multi*-argument command line.
# As a courtesy, we transform these multiple arguments into a *single*-argument command line, by
# space-joining them, which requires double-quoting them individually on demand, as for batch files.
# PowerShell's broken argument-passing will blindly enclose this in `"..."` overall - which is what cmd.exe expects.
# CAVEAT: Embedded " in individual arguments are ""-escaped to be batch file-friendly,
# but this which can break CLIs that only understand \" - such as WinPS (but fortunately no longer PS Core).
# First, check if the command (first post-/c-or/k argument) is an executable path needs double-quoting.
$indirectExe = $argsForExe[$cmdLineArgOptionNdx + 1]
if ($indirectExe -match $reMustDQuoteForCmd) {
if ([Environment]::OSVersion.Version.Major -lt 10) {
# !! Before W10 (definitely on W7, up to 8.1?), cmd.exe breaks with `cmd /c "<double-quoted-batch-filepath> ..." invocations,
# !! so we use the short, 8.3 version of the batch file path, which doesn't require quoting.
if ($inDirectExeFullPath = (Get-Command -Erroraction Ignore $indirectExe).Path) {
$indirectExe = (New-Object -ComObject Scripting.FileSystemObject).GetFile($inDirectExeFullPath).ShortPath
}
}
else {
# Executable path requires double-quoting.
$indirectExe = '"' + $indirectExe + '"'
}
}
# Note: We cannot use .ForEach('GetType'), because we must remain PSv3-compatible.
, $indirectExe + ($argsForExe[($cmdLineArgOptionNdx + 2)..($argsForExe.Count - 1)] | ForEach-Object { ($_, "`"$_`"")[$_ -match $reMustDQuoteForCmd] -replace $reInteriorDQuotes, '""' }) -join ' '
}
}
}
}
elseif ($isBatchFile) {
# -- SPECIAL HANDLING FOR BATCH FILES IN ORDER TO *SUPPORT RELIABLE EXIT-CODE REPORTING*:
# See our SO question and answer at https://stackoverflow.com/a/67009271/45375
# We call via `cmd /c "<batch-file> ... & exit "`, using the same escaping as for direct `cmd /c` / `cmd /k` calls above.
# # Hypothetical caveat: Since we must use ""-escaping when calling batch files: In the case of batch files acting as CLI entry points (such as `az.cmd` for Azure),
# # this escaping could still break if the ultimate target executable only supports \"
# # A least officially provided CLI entry points hopefully account for that (i.e., they hopefully only chose batch-file entry points if the code ultimately processing the arguments recognizes "" as an escaped ")
# First, check if the batch file path itself needs double-quoting.
if ($exe -match $reMustDQuoteForCmd) {
if ([Environment]::OSVersion.Version.Major -lt 10) {
# !! Before W10 (definitely on W7, up to 8.1?), cmd.exe breaks with `cmd /c "<double-quoted-batch-filepath> ..." invocations,
# !! so we use the short, 8.3 version of the batch file path, which doesn't require quoting.
$exe = (New-Object -ComObject Scripting.FileSystemObject).GetFile($exe).ShortPath
}
else {
# Batch-file path requires double-quoting.
$exe = '"' + $exe + '"'
}
}
# Note: We cannot use .ForEach('GetType'), because we must remain PSv3-compatible.
'/c', (((, $exe + ($argsForExe | ForEach-Object {
if ($_ -match $rePartialDQuotingCandidate) {
# support for partial double-quoting of msiexec-style arguments such as `foo="bar none"` - see above.
$prefix, $arg = $Matches[1], $Matches[2]
}
else {
$prefix, $arg = '', $_
}
$prefix + (($arg, "`"$arg`"")[$arg -match $reMustDQuoteForCmd] -replace $reInteriorDQuotes, '""')
})) + ' & exit') -join ' ')
$exe = "$env:SystemRoot\System32\cmd.exe"
}
else {
# Escape all arguments properly to pass them through as seen verbatim by PowerShell.
# NOTE: FOR SIMPLICITY, WE ALWAYS USE --% FOR CLIs OTHER THAN CMD.EXE AND BATCH FILES - EVEN ON UNIX.
# This saves us from having to special-case calls on *Windows PowerShell* where the following edge cases
# cannot be handled with *direct* calls (without --%):
# * Unbalanced embedded " preceded by a space-less token at the start of the argument; e.g.:
# `3" of snow`
# * A value enclosed *fully* in embedded ""; e.g.:
# `"foo bar"`
# * Additionally, in WinPS v3 and v4, partially quoted arguments such as `foo="bar none"` would invariably
# be double-quoted *as a whole* without --%
$useStopParsingSymbol = $true
# Decide whether to escape embedded double quotes as \" or as "", based on the target executable (Windows only)
$useDoubledDQuotes = ($IsWindows -and ($isMsiExecLikeExe -or $isWsh)) -or $env:__ie_doubledquotes # test override via env. var.
$escapedDQuote = ('\"', '""')[$useDoubledDQuotes]
foreach ($arg in $argsForExe) {
if ('' -eq $arg) { '""'; continue } # Empty arguments must be passed as `'""'`(!), otherwise they are omitted.
# Note: $null values - which we want to ignore - are seemingly automatically eliminated during splatting, which we use below.
# Determine argument characteristics.
$hasDQuotes = $arg.Contains('"')
$hasSpaces = $arg.Contains(' ')
# Determine if *explicit* double-quoting must be used:
# * On Unix:
# * Never: Because ""-escaping is never used.
# * On Windows:
# * If ""-escaping is used and a space-less argument contains "
$mustManuallyDQuote = $useDoubledDQuotes -and $hasDQuotes -and -not $hasSpaces
# Determine if the argument must *end up* with double-quoting on the process command line,
# which applies either if manual double-quoting is required or the argument contains spaces.
$mustEndUpDQuoted = $hasSpaces -or $mustManuallyDQuote
# Windows only:
# See if *partial double-quoting for msiexec-style CLIs* must be applied (e.g., `FOO="bar none"` or `/foo:"bar none"` / `-foo:"bar none"`)
# Note:
# * ":" only triggers this quoting if the argument starts with "/" or "-", so that we don't accidentally turn `c:\program files` into `c:\"program files"`.
# * We do NOT restrict this quoting to only when $isMsiExecLikeExe is $true, so as to also cover potential other CLIs that need this quoting.
# However, given that we use ""-escaping *only* when $isMsiExecLikeExe is $true, i.e. if specific executables are detected, any other CLIs
# must understand \"-escaping if embedded " are present for the call to succeed.
# * The partial double-quoting should be benign if it isn't actually needed, because conventional CLIs parse both `FOO="bar none"` and `"FOO=bar none"` as verbatim `FOO=bar none`.
$mustPartiallyDQuote = $IsWindows -and $mustEndUpDQuoted -and $arg -match $rePartialDQuotingCandidate
if ($mustPartiallyDQuote) {
# Split into - by definition pass-as-unquoted - prefix and the needs-double-quoting suffix.
$prefix, $arg = $Matches[1], $Matches[2]
$mustManuallyDQuote = $true
}
else {
$prefix = ''
}
# Escape any embedded " first and
# *double \ instances before them*, because a verbatim `\"` sequence would otherwise be interpreted as an *escaped* "
# Note: We must must do this even when using ""-escaping on Windows, because Windows CLIs that accept ""-escaping *also* accept \"-escaping - even in a single argument.
$arg = $arg -replace '\\+(?=")', '$&$&' -replace '"', $escapedDQuote
# If double-quoting must be used, trailing '\'s must be doubled.
# so that `c:\program files` translates to `"c:\program files\\"` - without the doubling the trailing \" would be interpreted as an *escaped* "
if ($mustEndUpDQuoted) { $arg = $arg -replace '\\+$', '$&$&' }
# Apply explicit double-quoting, if necessary, and prepend the prefix, if partial quoting was applied.
$arg = $prefix + $(if ($mustManuallyDQuote) { '"{0}"' -f $arg } else { $arg })
# Enclose the whole verbatim argument in "...", if necessary, for use with --%
if ($useStopParsingSymbol -and -not $mustPartiallyDQuote -and $mustEndUpDQuoted) {
$arg = '"' + $arg + '"'
}
$arg # output the escaped argument.
}
}
if ($DebugPreference -eq 'Continue') {
Write-Debug "Using --%: $useStopParsingSymbol; verbatim arguments:"
# Print the verbatim arguments about to be passed.
, $exe + $escapedArgs | ForEach-Object { "«$_»" } | Write-Debug
$host.Ui.WriteLine()
}
# If --% is to be used, prepend it to the array of escaped arguments.
if ($useStopParsingSymbol) {
$escapedArgs = , '--%' + $escapedArgs
}
# Finally, invoke the executable with the properly escaped arguments, if any, possibly with pipeline input.
# Note: We must use @escapedArgs rather than $escapedArgs, otherwise PowerShell won't apply
# Base64 encoding in the presence of a script-block argument when its CLI is called.
# Use of @ is also necessary we use --% to pass the escaped arguments.
if ($MyInvocation.ExpectingInput) {
# IMPORTANT: We must only use `$input | ...` *if actual pipeline input
# is present*. If no input is present, PowerShell *still
# redirects* the target executable's stdin and simply makes it
# *empty*. This causes command lines with *interactive* prompts
# to malfunction.
# Also: We cannot use an intermediate script block invoked
# with & here in order to avoid duplicating the actual
# command: the pipeline input is then NOT passed through
# (you'd have to use $input inside the script block too, which amounts to a catch-22).
$input | & $exe @escapedArgs
}
else {
& $exe @escapedArgs
}
# NOTE: