From ac762c1cce10b962394b9e7589ce8a2e3f8cebb1 Mon Sep 17 00:00:00 2001 From: "James Truher [MSFT]" Date: Tue, 20 Jul 2021 16:10:04 -0700 Subject: [PATCH] Add a Windows mode for native commands that allows some commands to use legacy argument passing (#15408) --- .../engine/CommandBase.cs | 10 +- .../engine/InitialSessionState.cs | 9 +- .../engine/NativeCommandParameterBinder.cs | 18 +- .../NativeCommandParameterBinderController.cs | 6 +- .../engine/NativeCommandProcessor.cs | 153 +++++++++++++---- test/powershell/Host/ConsoleHost.Tests.ps1 | 2 +- .../NativeCommandArguments.Tests.ps1 | 162 +++++++++++++++++- 7 files changed, 306 insertions(+), 54 deletions(-) diff --git a/src/System.Management.Automation/engine/CommandBase.cs b/src/System.Management.Automation/engine/CommandBase.cs index 016b22ff5969..048eafd56c8c 100644 --- a/src/System.Management.Automation/engine/CommandBase.cs +++ b/src/System.Management.Automation/engine/CommandBase.cs @@ -281,8 +281,14 @@ public enum NativeArgumentPassingStyle /// Use legacy argument parsing via ProcessStartInfo.Arguments. Legacy = 0, - /// Use new style argument parsing via ProcessStartInfo.ArgumentList. - Standard = 1 + /// Use new style argument passing via ProcessStartInfo.ArgumentList. + Standard = 1, + + /// + /// 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. + /// + Windows = 2 } #endregion NativeArgumentPassingStyle diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index ae25c8d0edfb..99aa639d09c8 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -1320,13 +1320,18 @@ private void AddVariables(IEnumerable variables) // 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)))); diff --git a/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs b/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs index 35c956e28024..511449bc7e59 100644 --- a/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs +++ b/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs @@ -20,6 +20,8 @@ namespace System.Management.Automation /// internal class NativeCommandParameterBinder : ParameterBinderBase { + private readonly VariablePath s_nativeArgumentPassingVarPath = new VariablePath(SpecialVariables.NativeArgumentPassing); + #region ctor /// @@ -187,7 +189,7 @@ internal void AddToArgumentList(CommandParameterInternal parameter, string argum /// /// Gets a value indicating whether to use an ArgumentList or string for arguments when invoking a native executable. /// - internal bool UseArgumentList + internal NativeArgumentPassingStyle ArgumentPassingStyle { get { @@ -197,17 +199,17 @@ internal bool UseArgumentList { // This will default to the new behavior if it is set to anything other than Legacy var preference = LanguagePrimitives.ConvertTo( - 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 @@ private void AppendOneNativeArgument(ExecutionContext context, CommandParameterI } 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 @@ private void AppendOneNativeArgument(ExecutionContext context, CommandParameterI } } } - else if (UseArgumentList && currentObj != null) + else if (ArgumentPassingStyle == NativeArgumentPassingStyle.Standard && currentObj != null) { // add empty strings to arglist, but not nulls AddToArgumentList(parameter, arg); diff --git a/src/System.Management.Automation/engine/NativeCommandParameterBinderController.cs b/src/System.Management.Automation/engine/NativeCommandParameterBinderController.cs index 933c71f55035..9a1dab71beb2 100644 --- a/src/System.Management.Automation/engine/NativeCommandParameterBinderController.cs +++ b/src/System.Management.Automation/engine/NativeCommandParameterBinderController.cs @@ -50,13 +50,13 @@ internal string[] ArgumentList } /// - /// Gets a value indicating whether to use the new API for StartInfo. + /// Gets the value indicating what type of native argument binding to use. /// - internal bool UseArgumentList + internal NativeArgumentPassingStyle ArgumentPassingStyle { get { - return ((NativeCommandParameterBinder)DefaultParameterBinder).UseArgumentList; + return ((NativeCommandParameterBinder)DefaultParameterBinder).ArgumentPassingStyle; } } diff --git a/src/System.Management.Automation/engine/NativeCommandProcessor.cs b/src/System.Management.Automation/engine/NativeCommandProcessor.cs index 32f2ee1f7ec9..3407532acdb4 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -135,6 +135,26 @@ internal ProcessOutputObject(object data, MinishellStream stream) /// 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 s_legacyFileExtensions = new HashSet(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 s_legacyCommands = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "cmd", + "cscript", + "wscript", + }; + #region ctor/native command properties /// @@ -474,6 +494,7 @@ private void InitNativeProcess() // 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 @@ private void InitNativeProcess() 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 @@ private void InitNativeProcess() 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,56 +1145,81 @@ private void ProcessOutputRecord(ProcessOutputObject outputValue) } /// - /// Gets the start info for process. + /// Get whether we should treat this executable with special handling and use the legacy passing style. /// - /// - /// - /// - /// - /// - private ProcessStartInfo GetProcessStartInfo(bool redirectOutput, bool redirectError, bool redirectInput, bool soloCommand) + /// + private bool UseSpecialArgumentPassing(string filePath) => + NativeParameterBinderController.ArgumentPassingStyle switch + { + NativeArgumentPassingStyle.Legacy => true, + NativeArgumentPassingStyle.Windows => ShouldUseLegacyPassingStyle(filePath), + _ => false + }; + + /// + /// Gets the ProcessStartInfo for process. + /// + /// A boolean that indicates that, when true, output from the process is redirected to a stream, and otherwise is sent to stdout. + /// A boolean that indicates that, when true, error output from the process is redirected to a stream, and otherwise is sent to stderr. + /// A boolean that indicates that, when true, input to the process is taken from a stream, and otherwise is taken from stdin. + /// A boolean that indicates, when true, that the command to be executed is not part of a pipeline, and otherwise indicates that is is. + /// A ProcessStartInfo object which is the base of the native invocation. + private ProcessStartInfo GetProcessStartInfo( + bool redirectOutput, + bool redirectError, + bool redirectInput, + bool soloCommand) { - ProcessStartInfo startInfo = new ProcessStartInfo(); - startInfo.FileName = this.Path; + var startInfo = new ProcessStartInfo + { + FileName = this.Path + }; - if (IsExecutable(this.Path)) + if (!IsExecutable(this.Path)) { - startInfo.UseShellExecute = false; - if (redirectInput) + if (Platform.IsNanoServer || Platform.IsIoT) { - startInfo.RedirectStandardInput = true; + // 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); } - if (redirectOutput) + // We only want to ShellExecute something that is standalone... + if (!soloCommand) { - startInfo.RedirectStandardOutput = true; - startInfo.StandardOutputEncoding = Console.OutputEncoding; + throw InterpreterError.NewInterpreterException( + this.Path, + typeof(RuntimeException), + this.Command.InvocationExtent, + "CantActivateDocumentInPipeline", + ParserStrings.CantActivateDocumentInPipeline, + this.Path); } - if (redirectError) - { - startInfo.RedirectStandardError = true; - startInfo.StandardErrorEncoding = Console.OutputEncoding; - } + startInfo.UseShellExecute = true; } else { - if (Platform.IsNanoServer || Platform.IsIoT) + startInfo.UseShellExecute = false; + startInfo.RedirectStandardInput = redirectInput; + + if (redirectOutput) { - // 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); + startInfo.RedirectStandardOutput = true; + startInfo.StandardOutputEncoding = Console.OutputEncoding; } - // We only want to ShellExecute something that is standalone... - if (!soloCommand) + if (redirectError) { - throw InterpreterError.NewInterpreterException(this.Path, typeof(RuntimeException), - this.Command.InvocationExtent, "CantActivateDocumentInPipeline", ParserStrings.CantActivateDocumentInPipeline, this.Path); + startInfo.RedirectStandardError = true; + startInfo.StandardErrorEncoding = Console.OutputEncoding; } - - startInfo.UseShellExecute = true; } // For minishell value of -outoutFormat parameter depends on value of redirectOutput. @@ -1165,7 +1227,7 @@ private ProcessStartInfo GetProcessStartInfo(bool redirectOutput, bool redirectE 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 @@ private ProcessStartInfo GetProcessStartInfo(bool redirectOutput, bool redirectE // 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 @@ private ProcessStartInfo GetProcessStartInfo(bool redirectOutput, bool redirectE context.EngineSessionState.GetNamespaceCurrentLocation( context.ProviderNames.FileSystem).ProviderPath; startInfo.WorkingDirectory = WildcardPattern.Unescape(rawPath); + return startInfo; } + /// + /// 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. + /// + /// The file to use when checking how to pass arguments. + /// A boolean indicating what passing style should be used. + 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."); diff --git a/test/powershell/Host/ConsoleHost.Tests.ps1 b/test/powershell/Host/ConsoleHost.Tests.ps1 index a601f44e4224..28561da23cea 100644 --- a/test/powershell/Host/ConsoleHost.Tests.ps1 +++ b/test/powershell/Host/ConsoleHost.Tests.ps1 @@ -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 } diff --git a/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 b/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 index b279f04c9e81..0c7b7f9d512f 100644 --- a/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 +++ b/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 @@ -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: " + "Argument 1 is: " + "Argument 2 is: " + "Argument 3 is: " + ) + Script = @' + + + + +'@ + } + @{ + Command = "cscript.exe" + Filename = "test.vbs" + ExpectedResults = @( + "Argument 0 is: " + "Argument 1 is: " + "Argument 2 is: " + "Argument 3 is: " + ) + 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: " + "Argument 1 is: " + "Argument 2 is: " + "Argument 3 is: " + ) + 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: " + "Argument 2 is: " + "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: " + "Argument 2 is: " + "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 '' 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()