Add a Windows mode for native commands that allows some commands to use legacy argument passing (#15408)

This commit is contained in:
James Truher [MSFT]
2021-07-20 23:10:04 +00:00
committed by GitHub
parent 5943bc9905
commit ac762c1cce
7 changed files with 308 additions and 56 deletions
@@ -281,8 +281,14 @@ namespace System.Management.Automation
/// <summary>Use legacy argument parsing via ProcessStartInfo.Arguments.</summary>
Legacy = 0,
/// <summary>Use new style argument parsing via ProcessStartInfo.ArgumentList.</summary>
Standard = 1
/// <summary>Use new style argument passing via ProcessStartInfo.ArgumentList.</summary>
Standard = 1,
/// <summary>
/// Use specific to Windows passing style which is Legacy for selected files on Windows, but
/// Standard for everything else. This is the default behavior for Windows.
/// </summary>
Windows = 2
}
#endregion NativeArgumentPassingStyle
@@ -1320,13 +1320,18 @@ namespace System.Management.Automation.Runspaces
// If the PSNativeCommandArgumentPassing feature is enabled, create the variable which controls the behavior
// Since the BuiltInVariables list is static, and this should be done dynamically
// we need to do this here.
// we need to do this here. Also, since the defaults are different based on platform we need a
// bit more logic.
if (ExperimentalFeature.IsEnabled("PSNativeCommandArgumentPassing"))
{
NativeArgumentPassingStyle style = NativeArgumentPassingStyle.Standard;
if (Platform.IsWindows) {
style = NativeArgumentPassingStyle.Windows;
}
Variables.Add(
new SessionStateVariableEntry(
SpecialVariables.NativeArgumentPassing,
NativeArgumentPassingStyle.Standard,
style,
RunspaceInit.NativeCommandArgumentPassingDescription,
ScopedItemOptions.None,
new ArgumentTypeConverterAttribute(typeof(NativeArgumentPassingStyle))));
@@ -20,6 +20,8 @@ namespace System.Management.Automation
/// </summary>
internal class NativeCommandParameterBinder : ParameterBinderBase
{
private readonly VariablePath s_nativeArgumentPassingVarPath = new VariablePath(SpecialVariables.NativeArgumentPassing);
#region ctor
/// <summary>
@@ -187,7 +189,7 @@ namespace System.Management.Automation
/// <summary>
/// Gets a value indicating whether to use an ArgumentList or string for arguments when invoking a native executable.
/// </summary>
internal bool UseArgumentList
internal NativeArgumentPassingStyle ArgumentPassingStyle
{
get
{
@@ -197,17 +199,17 @@ namespace System.Management.Automation
{
// This will default to the new behavior if it is set to anything other than Legacy
var preference = LanguagePrimitives.ConvertTo<NativeArgumentPassingStyle>(
Context.GetVariableValue(new VariablePath(SpecialVariables.NativeArgumentPassing), NativeArgumentPassingStyle.Standard));
return preference != NativeArgumentPassingStyle.Legacy;
Context.GetVariableValue(s_nativeArgumentPassingVarPath, NativeArgumentPassingStyle.Standard));
return preference;
}
catch
{
// The value is not convertable send back true
return true;
// The value is not convertable send back Legacy
return NativeArgumentPassingStyle.Legacy;
}
}
return false;
return NativeArgumentPassingStyle.Legacy;
}
}
@@ -314,7 +316,7 @@ namespace System.Management.Automation
}
else
{
if (argArrayAst != null && UseArgumentList)
if (argArrayAst != null && ArgumentPassingStyle == NativeArgumentPassingStyle.Standard)
{
// We have a literal array, so take the extent, break it on spaces and add them to the argument list.
foreach (string element in argArrayAst.Extent.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries))
@@ -331,7 +333,7 @@ namespace System.Management.Automation
}
}
}
else if (UseArgumentList && currentObj != null)
else if (ArgumentPassingStyle == NativeArgumentPassingStyle.Standard && currentObj != null)
{
// add empty strings to arglist, but not nulls
AddToArgumentList(parameter, arg);
@@ -50,13 +50,13 @@ namespace System.Management.Automation
}
/// <summary>
/// Gets a value indicating whether to use the new API for StartInfo.
/// Gets the value indicating what type of native argument binding to use.
/// </summary>
internal bool UseArgumentList
internal NativeArgumentPassingStyle ArgumentPassingStyle
{
get
{
return ((NativeCommandParameterBinder)DefaultParameterBinder).UseArgumentList;
return ((NativeCommandParameterBinder)DefaultParameterBinder).ArgumentPassingStyle;
}
}
@@ -135,6 +135,26 @@ namespace System.Management.Automation
/// </summary>
internal class NativeCommandProcessor : CommandProcessorBase
{
// This is the list of files which will trigger Legacy behavior if
// PSNativeCommandArgumentPassing is set to "Windows".
private static readonly IReadOnlySet<string> s_legacyFileExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".js",
".wsf",
".cmd",
".bat",
".vbs",
};
// The following native commands have non-standard behavior with regard to argument passing,
// so we use Legacy argument parsing for them when PSNativeCommandArgumentPassing is set to Windows.
private static readonly IReadOnlySet<string> s_legacyCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"cmd",
"cscript",
"wscript",
};
#region ctor/native command properties
/// <summary>
@@ -474,6 +494,7 @@ namespace System.Management.Automation
// on Windows desktops, see if there is a file association for this command. If so then we'll use that.
string executable = FindExecutable(startInfo.FileName);
bool notDone = true;
// check to see what mode we should be in for argument passing
if (!string.IsNullOrEmpty(executable))
{
isWindowsApplication = IsWindowsApplication(executable);
@@ -485,7 +506,16 @@ namespace System.Management.Automation
string oldArguments = startInfo.Arguments;
string oldFileName = startInfo.FileName;
startInfo.Arguments = "\"" + startInfo.FileName + "\" " + startInfo.Arguments;
// Check to see whether this executable should be using Legacy mode argument parsing
bool useSpecialArgumentPassing = UseSpecialArgumentPassing(oldFileName);
if (useSpecialArgumentPassing)
{
startInfo.Arguments = "\"" + oldFileName + "\" " + startInfo.Arguments;
}
else
{
startInfo.ArgumentList.Insert(0, oldFileName);
}
startInfo.FileName = executable;
try
{
@@ -495,7 +525,14 @@ namespace System.Management.Automation
catch (Win32Exception)
{
// Restore the old filename and arguments to try shell execute last...
startInfo.Arguments = oldArguments;
if (useSpecialArgumentPassing)
{
startInfo.Arguments = oldArguments;
}
else
{
startInfo.ArgumentList.RemoveAt(0);
}
startInfo.FileName = oldFileName;
}
}
@@ -1108,25 +1145,69 @@ namespace System.Management.Automation
}
/// <summary>
/// Gets the start info for process.
/// Get whether we should treat this executable with special handling and use the legacy passing style.
/// </summary>
/// <param name="redirectOutput"></param>
/// <param name="redirectError"></param>
/// <param name="redirectInput"></param>
/// <param name="soloCommand"></param>
/// <returns></returns>
private ProcessStartInfo GetProcessStartInfo(bool redirectOutput, bool redirectError, bool redirectInput, bool soloCommand)
{
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = this.Path;
/// <param name="filePath"></param>
private bool UseSpecialArgumentPassing(string filePath) =>
NativeParameterBinderController.ArgumentPassingStyle switch
{
NativeArgumentPassingStyle.Legacy => true,
NativeArgumentPassingStyle.Windows => ShouldUseLegacyPassingStyle(filePath),
_ => false
};
if (IsExecutable(this.Path))
/// <summary>
/// Gets the ProcessStartInfo for process.
/// </summary>
/// <param name="redirectOutput">A boolean that indicates that, when true, output from the process is redirected to a stream, and otherwise is sent to stdout.</param>
/// <param name="redirectError">A boolean that indicates that, when true, error output from the process is redirected to a stream, and otherwise is sent to stderr.</param>
/// <param name="redirectInput">A boolean that indicates that, when true, input to the process is taken from a stream, and otherwise is taken from stdin.</param>
/// <param name="soloCommand">A boolean that indicates, when true, that the command to be executed is not part of a pipeline, and otherwise indicates that is is.</param>
/// <returns>A ProcessStartInfo object which is the base of the native invocation.</returns>
private ProcessStartInfo GetProcessStartInfo(
bool redirectOutput,
bool redirectError,
bool redirectInput,
bool soloCommand)
{
var startInfo = new ProcessStartInfo
{
FileName = this.Path
};
if (!IsExecutable(this.Path))
{
if (Platform.IsNanoServer || Platform.IsIoT)
{
// Shell doesn't exist on headless SKUs, so documents cannot be associated with an application.
// Therefore, we cannot run document in this case.
throw InterpreterError.NewInterpreterException(
this.Path,
typeof(RuntimeException),
this.Command.InvocationExtent,
"CantActivateDocumentInPowerShellCore",
ParserStrings.CantActivateDocumentInPowerShellCore,
this.Path);
}
// We only want to ShellExecute something that is standalone...
if (!soloCommand)
{
throw InterpreterError.NewInterpreterException(
this.Path,
typeof(RuntimeException),
this.Command.InvocationExtent,
"CantActivateDocumentInPipeline",
ParserStrings.CantActivateDocumentInPipeline,
this.Path);
}
startInfo.UseShellExecute = true;
}
else
{
startInfo.UseShellExecute = false;
if (redirectInput)
{
startInfo.RedirectStandardInput = true;
}
startInfo.RedirectStandardInput = redirectInput;
if (redirectOutput)
{
@@ -1140,32 +1221,13 @@ namespace System.Management.Automation
startInfo.StandardErrorEncoding = Console.OutputEncoding;
}
}
else
{
if (Platform.IsNanoServer || Platform.IsIoT)
{
// Shell doesn't exist on headless SKUs, so documents cannot be associated with an application.
// Therefore, we cannot run document in this case.
throw InterpreterError.NewInterpreterException(this.Path, typeof(RuntimeException),
this.Command.InvocationExtent, "CantActivateDocumentInPowerShellCore", ParserStrings.CantActivateDocumentInPowerShellCore, this.Path);
}
// We only want to ShellExecute something that is standalone...
if (!soloCommand)
{
throw InterpreterError.NewInterpreterException(this.Path, typeof(RuntimeException),
this.Command.InvocationExtent, "CantActivateDocumentInPipeline", ParserStrings.CantActivateDocumentInPipeline, this.Path);
}
startInfo.UseShellExecute = true;
}
// For minishell value of -outoutFormat parameter depends on value of redirectOutput.
// So we delay the parameter binding. Do parameter binding for minishell now.
if (_isMiniShell)
{
MinishellParameterBinderController mpc = (MinishellParameterBinderController)NativeParameterBinderController;
mpc.BindParameters(arguments, redirectOutput, this.Command.Context.EngineHostInterface.Name);
mpc.BindParameters(arguments, startInfo.RedirectStandardOutput, this.Command.Context.EngineHostInterface.Name);
startInfo.CreateNoWindow = mpc.NonInteractive;
}
@@ -1174,7 +1236,8 @@ namespace System.Management.Automation
// We provide the user a way to select the new behavior via a new preference variable
using (ParameterBinderBase.bindingTracer.TraceScope("BIND NAMED native application line args [{0}]", this.Path))
{
if (!NativeParameterBinderController.UseArgumentList)
// We need to check if we're using legacy argument passing or it's a special case.
if (UseSpecialArgumentPassing(startInfo.FileName))
{
using (ParameterBinderBase.bindingTracer.TraceScope("BIND argument [{0}]", NativeParameterBinderController.Arguments))
{
@@ -1203,9 +1266,27 @@ namespace System.Management.Automation
context.EngineSessionState.GetNamespaceCurrentLocation(
context.ProviderNames.FileSystem).ProviderPath;
startInfo.WorkingDirectory = WildcardPattern.Unescape(rawPath);
return startInfo;
}
/// <summary>
/// Determine if we have a special file which will change the way native argument passing
/// is done on Windows. We use legacy behavior for cmd.exe, .bat, .cmd files.
/// </summary>
/// <param name="filePath">The file to use when checking how to pass arguments.</param>
/// <returns>A boolean indicating what passing style should be used.</returns>
private static bool ShouldUseLegacyPassingStyle(string filePath)
{
if (string.IsNullOrEmpty(filePath))
{
return false;
}
return s_legacyFileExtensions.Contains(IO.Path.GetExtension(filePath))
|| s_legacyCommands.Contains(IO.Path.GetFileNameWithoutExtension(filePath));
}
private static bool IsDownstreamOutDefault(Pipe downstreamPipe)
{
Diagnostics.Assert(downstreamPipe != null, "Caller makes sure the passed-in parameter is not null.");
+1 -1
View File
@@ -240,7 +240,7 @@ Describe "ConsoleHost unit tests" -tags "Feature" {
$LASTEXITCODE | Should -Be 64
}
It "Empty space command should succeed" {
It "Empty space command should succeed on non-Windows" -skip:$IsWindows {
& $powershell -noprofile -c '' | Should -BeNullOrEmpty
$LASTEXITCODE | Should -Be 0
}
@@ -2,7 +2,165 @@
# Licensed under the MIT License.
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")]
param()
Describe "Will error correctly if an attempt to set variable to improper value" {
Describe "Behavior is specific for each platform" -tags "CI" {
BeforeAll {
$skipTests = $EnabledExperimentalFeatures -notcontains 'PSNativeCommandArgumentPassing'
}
It "PSNativeCommandArgumentPassing is set to 'Windows' on Windows systems" -skip:(-not $IsWindows) {
$PSNativeCommandArgumentPassing | Should -Be "Windows"
}
It "PSNativeCommandArgumentPassing is set to 'Standard' on non-Windows systems" -skip:($IsWindows) {
$PSNativeCommandArgumentPassing | Should -Be "Standard"
}
It "Has proper behavior on Windows" -skip:(-not $IsWindows) {
"@echo off`nSET V1=1" > "$TESTDRIVE\script 1.cmd"
"@echo off`nSET V2=a`necho %V1%" > "$TESTDRIVE\script 2.cmd"
"@echo off`necho %V1%`necho %V2%" > "$TESTDRIVE\script 3.cmd"
$result = cmd /c """${TESTDRIVE}\script 1.cmd"" && ""${TESTDRIVE}\script 2.cmd"" && ""${TESTDRIVE}\script 3.cmd"""
$result.Count | Should -Be 3
$result[0] | Should -Be 1
$result[1] | Should -Be 1
$result[2] | Should Be "a"
}
}
Describe "tests for multiple languages and extensions" -tags "CI" {
AfterAll {
if (-not $IsWindows -or
$EnabledExperimentalFeatures -notcontains 'PSNativeCommandArgumentPassing') {
return
}
$PSNativeCommandArgumentPassing = $passingStyle
}
BeforeAll {
$testCases = @(
@{
Command = "cscript.exe"
Filename = "test.wsf"
ExpectedResults = @(
"Argument 0 is: <ab cd>"
"Argument 1 is: <ab cd>"
"Argument 2 is: <ab cd>"
"Argument 3 is: <a'b c'd>"
)
Script = @'
<?xml version="1.0" ?>
<job id="test">
<script language="VBScript">
<![CDATA[
for i = 0 to wScript.arguments.count-1
wscript.echo "Argument " & i & " is: <" & wScript.arguments(i) & ">"
next
]]>
</script>
</job>
'@
}
@{
Command = "cscript.exe"
Filename = "test.vbs"
ExpectedResults = @(
"Argument 0 is: <ab cd>"
"Argument 1 is: <ab cd>"
"Argument 2 is: <ab cd>"
"Argument 3 is: <a'b c'd>"
)
Script = @'
for i = 0 to wScript.arguments.count - 1
wscript.echo "Argument " & i & " is: <" & (wScript.arguments(i)) & ">"
next
'@
}
@{
Command = "cscript"
Filename = "test.js"
ExpectedResults = @(
"Argument 0 is: <ab cd>"
"Argument 1 is: <ab cd>"
"Argument 2 is: <ab cd>"
"Argument 3 is: <a'b c'd>"
)
Script = @'
for(i = 0; i < WScript.Arguments.Count(); i++) {
WScript.echo("Argument " + i + " is: <" + WScript.Arguments(i) + ">");
}
'@
}
@{
Command = ""
Filename = "test.bat"
ExpectedResults = @(
"Argument 1 is: <a""b c""d>"
"Argument 2 is: <a""b c""d>"
"Argument 3 is: <""ab cd"">"
"Argument 4 is: <""a'b c'd"">"
)
Script = @'
@echo off
echo Argument 1 is: ^<%1^>
echo Argument 2 is: ^<%2^>
echo Argument 3 is: ^<%3^>
echo Argument 4 is: ^<%4^>
'@
}
@{
Command = ""
Filename = "test.cmd"
ExpectedResults = @(
"Argument 1 is: <a""b c""d>"
"Argument 2 is: <a""b c""d>"
"Argument 3 is: <""ab cd"">"
"Argument 4 is: <""a'b c'd"">"
)
Script = @'
@echo off
echo Argument 1 is: ^<%1^>
echo Argument 2 is: ^<%2^>
echo Argument 3 is: ^<%3^>
echo Argument 4 is: ^<%4^>
'@
}
)
# determine whether we should skip the tests we just defined
# doing it in this order ensures that the test output will show each skipped test
$skipTests = -not $IsWindows -or $EnabledExperimentalFeatures -notcontains 'PSNativeCommandArgumentPassing'
if ($skipTests) {
return
}
# save the passing style
$passingStyle = $PSNativeCommandArgumentPassing
# explicitely set the passing style to Windows
$PSNativeCommandArgumentPassing = "Windows"
}
It "Invoking '<Filename>' is compatible with PowerShell 5" -TestCases $testCases -Skip:$($skipTests) {
param ( $Command, $Arguments, $Filename, $Script, $ExpectedResults )
cscript //h:cscript //nologo //s
$a = 'a"b c"d'
$scriptPath = Join-Path $TESTDRIVE $Filename
$Script | out-file -encoding ASCII $scriptPath
if ($Command) {
$results = & $Command $scriptPath $a 'a"b c"d' a"b c"d "a'b c'd" 2> "${TESTDRIVE}/error.txt"
}
else {
$results = & $scriptPath $a 'a"b c"d' a"b c"d "a'b c'd" 2> "${TESTDRIVE}/error.txt"
}
$errorContent = Get-Content "${TESTDRIVE}/error.txt" -ErrorAction Ignore
$errorContent | Should -BeNullOrEmpty
$results.Count | Should -Be 4
$results[0] | Should -Be $ExpectedResults[0]
$results[1] | Should -Be $ExpectedResults[1]
$results[2] | Should -Be $ExpectedResults[2]
$results[3] | Should -Be $ExpectedResults[3]
}
}
Describe "Will error correctly if an attempt to set variable to improper value" -tags "CI" {
It "will error when setting variable incorrectly" {
if ($EnabledExperimentalFeatures -contains 'PSNativeCommandArgumentPassing') {
{ $global:PSNativeCommandArgumentPassing = "zzz" } | Should -Throw -ExceptionType System.Management.Automation.ArgumentTransformationMetadataException
@@ -107,7 +265,7 @@ foreach ( $argumentListValue in "Standard","Legacy" ) {
}
}
}
Describe 'PSPath to native commands' {
Describe 'PSPath to native commands' -tags "CI" {
BeforeAll {
$featureEnabled = $EnabledExperimentalFeatures.Contains('PSNativePSPathResolution')
$originalDefaultParameterValues = $PSDefaultParameterValues.Clone()