From 23b1dd46aef43fbfe126d62e80ebe27a21b7a81e Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 27 Apr 2020 14:14:57 -0700 Subject: [PATCH] Support passing PSPath to native commands (#12386) --- .../ExperimentalFeature.cs | 3 + .../engine/NativeCommandParameterBinder.cs | 242 ++++++++++++------ .../NativeCommandArguments.Tests.ps1 | 95 +++++++ 3 files changed, 265 insertions(+), 75 deletions(-) diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 32ffb71817..9337f48542 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -120,6 +120,9 @@ namespace System.Management.Automation new ExperimentalFeature( name: "PSCultureInvariantReplaceOperator", description: "Use culture invariant to-string convertor for lval in replace operator"), + new ExperimentalFeature( + name: "PSNativePSPathResolution", + description: "Convert PSPath to filesystem path, if possible, for native commands"), }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs b/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs index d2c5d1de2a..0091596a44 100644 --- a/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs +++ b/src/System.Management.Automation/engine/NativeCommandParameterBinder.cs @@ -82,7 +82,7 @@ namespace System.Management.Automation if (parameter.ParameterNameSpecified) { Diagnostics.Assert(!parameter.ParameterText.Contains(' '), "Parameters cannot have whitespace"); - PossiblyGlobArg(parameter.ParameterText, usedQuotes: false); + PossiblyGlobArg(parameter.ParameterText, StringConstantType.BareWord); if (parameter.SpaceAfterParameter) { @@ -107,23 +107,30 @@ namespace System.Management.Automation // windbg -k com:port=\\devbox\pipe\debug,pipe,resets=0,reconnect // The parser produced an array of strings but marked the parameter so we // can properly reconstruct the correct command line. - bool usedQuotes = false; + StringConstantType stringConstantType = StringConstantType.BareWord; ArrayLiteralAst arrayLiteralAst = null; switch (parameter?.ArgumentAst) { case StringConstantExpressionAst sce: - usedQuotes = sce.StringConstantType != StringConstantType.BareWord; + stringConstantType = sce.StringConstantType; break; case ExpandableStringExpressionAst ese: - usedQuotes = ese.StringConstantType != StringConstantType.BareWord; + stringConstantType = ese.StringConstantType; break; case ArrayLiteralAst ala: arrayLiteralAst = ala; break; } - appendOneNativeArgument(Context, argValue, - arrayLiteralAst, sawVerbatimArgumentMarker, usedQuotes); + // Prior to PSNativePSPathResolution experimental feature, a single quote worked the same as a double quote + // so if the feature is not enabled, we treat any quotes as double quotes. When this feature is no longer + // experimental, this code here needs to be removed. + if (!ExperimentalFeature.IsEnabled("PSNativePSPathResolution") && stringConstantType == StringConstantType.SingleQuoted) + { + stringConstantType = StringConstantType.DoubleQuoted; + } + + AppendOneNativeArgument(Context, argValue, arrayLiteralAst, sawVerbatimArgumentMarker, stringConstantType); } } } @@ -157,14 +164,12 @@ namespace System.Management.Automation /// The object to append. /// If the argument was an array literal, the Ast, otherwise null. /// True if the argument occurs after --%. - /// True if the argument was a quoted string (single or double). - private void appendOneNativeArgument(ExecutionContext context, object obj, ArrayLiteralAst argArrayAst, bool sawVerbatimArgumentMarker, bool usedQuotes) + /// Bare, SingleQuoted, or DoubleQuoted. + private void AppendOneNativeArgument(ExecutionContext context, object obj, ArrayLiteralAst argArrayAst, bool sawVerbatimArgumentMarker, StringConstantType stringConstantType) { IEnumerator list = LanguagePrimitives.GetEnumerator(obj); - Diagnostics.Assert(argArrayAst == null - || obj is object[] && ((object[])obj).Length == argArrayAst.Elements.Count, - "array argument and ArrayLiteralAst differ in number of elements"); + Diagnostics.Assert((argArrayAst == null) || obj is object[] && ((object[])obj).Length == argArrayAst.Elements.Count, "array argument and ArrayLiteralAst differ in number of elements"); int currentElement = -1; string separator = string.Empty; @@ -218,9 +223,18 @@ namespace System.Management.Automation if (NeedQuotes(arg)) { _arguments.Append('"'); + + if (stringConstantType == StringConstantType.DoubleQuoted) + { + _arguments.Append(ResolvePath(arg, Context)); + } + else + { + _arguments.Append(arg); + } + // need to escape all trailing backslashes so the native command receives it correctly // according to http://www.daviddeley.com/autohotkey/parameters/parameters.htm#WINCRULESDOC - _arguments.Append(arg); for (int i = arg.Length - 1; i >= 0 && arg[i] == '\\'; i--) { _arguments.Append('\\'); @@ -230,11 +244,12 @@ namespace System.Management.Automation } else { - PossiblyGlobArg(arg, usedQuotes); + PossiblyGlobArg(arg, stringConstantType); } } } - } while (list != null); + } + while (list != null); } /// @@ -242,94 +257,168 @@ namespace System.Management.Automation /// On Unix, do globbing as appropriate, otherwise just append . /// /// The argument that possibly needs expansion. - /// True if the argument was a quoted string (single or double). - private void PossiblyGlobArg(string arg, bool usedQuotes) + /// Bare, SingleQuoted, or DoubleQuoted. + private void PossiblyGlobArg(string arg, StringConstantType stringConstantType) { var argExpanded = false; #if UNIX // On UNIX systems, we expand arguments containing wildcard expressions against // the file system just like bash, etc. - if (!usedQuotes && WildcardPattern.ContainsWildcardCharacters(arg)) + + if (stringConstantType == StringConstantType.BareWord) { - // See if the current working directory is a filesystem provider location - // We won't do the expansion if it isn't since native commands can only access the file system. - var cwdinfo = Context.EngineSessionState.CurrentLocation; - - // If it's a filesystem location then expand the wildcards - if (cwdinfo.Provider.Name.Equals(FileSystemProvider.ProviderName, StringComparison.OrdinalIgnoreCase)) + if (WildcardPattern.ContainsWildcardCharacters(arg)) { - // On UNIX, paths starting with ~ or absolute paths are not normalized - bool normalizePath = arg.Length == 0 || !(arg[0] == '~' || arg[0] == '/'); + // See if the current working directory is a filesystem provider location + // We won't do the expansion if it isn't since native commands can only access the file system. + var cwdinfo = Context.EngineSessionState.CurrentLocation; - // See if there are any matching paths otherwise just add the pattern as the argument - Collection paths = null; - try + // If it's a filesystem location then expand the wildcards + if (cwdinfo.Provider.Name.Equals(FileSystemProvider.ProviderName, StringComparison.OrdinalIgnoreCase)) { - paths = Context.EngineSessionState.InvokeProvider.ChildItem.Get(arg, false); - } - catch - { - // Fallthrough will append the pattern unchanged. - } + // On UNIX, paths starting with ~ or absolute paths are not normalized + bool normalizePath = arg.Length == 0 || !(arg[0] == '~' || arg[0] == '/'); - // Expand paths, but only from the file system. - if (paths?.Count > 0 && paths.All(p => p.BaseObject is FileSystemInfo)) - { - var sep = string.Empty; - foreach (var path in paths) + // See if there are any matching paths otherwise just add the pattern as the argument + Collection paths = null; + try { - _arguments.Append(sep); - sep = " "; - var expandedPath = (path.BaseObject as FileSystemInfo).FullName; - if (normalizePath) - { - expandedPath = - Context.SessionState.Path.NormalizeRelativePath(expandedPath, cwdinfo.ProviderPath); - } - // If the path contains spaces, then add quotes around it. - if (NeedQuotes(expandedPath)) - { - _arguments.Append("\""); - _arguments.Append(expandedPath); - _arguments.Append("\""); - } - else - { - _arguments.Append(expandedPath); - } + paths = Context.EngineSessionState.InvokeProvider.ChildItem.Get(arg, false); + } + catch + { + // Fallthrough will append the pattern unchanged. + } - argExpanded = true; + // Expand paths, but only from the file system. + if (paths?.Count > 0 && paths.All(p => p.BaseObject is FileSystemInfo)) + { + var sep = string.Empty; + foreach (var path in paths) + { + _arguments.Append(sep); + sep = " "; + var expandedPath = (path.BaseObject as FileSystemInfo).FullName; + if (normalizePath) + { + expandedPath = + Context.SessionState.Path.NormalizeRelativePath(expandedPath, cwdinfo.ProviderPath); + } + // If the path contains spaces, then add quotes around it. + if (NeedQuotes(expandedPath)) + { + _arguments.Append("\""); + _arguments.Append(expandedPath); + _arguments.Append("\""); + } + else + { + _arguments.Append(expandedPath); + } + + argExpanded = true; + } } } } - } - else if (!usedQuotes) - { - // Even if there are no wildcards, we still need to possibly - // expand ~ into the filesystem provider home directory path - ProviderInfo fileSystemProvider = Context.EngineSessionState.GetSingleProvider(FileSystemProvider.ProviderName); - string home = fileSystemProvider.Home; - if (string.Equals(arg, "~")) + else { - _arguments.Append(home); - argExpanded = true; - } - else if (arg.StartsWith("~/", StringComparison.OrdinalIgnoreCase)) - { - var replacementString = home + arg.Substring(1); - _arguments.Append(replacementString); - argExpanded = true; + // Even if there are no wildcards, we still need to possibly + // expand ~ into the filesystem provider home directory path + ProviderInfo fileSystemProvider = Context.EngineSessionState.GetSingleProvider(FileSystemProvider.ProviderName); + string home = fileSystemProvider.Home; + if (string.Equals(arg, "~")) + { + _arguments.Append(home); + argExpanded = true; + } + else if (arg.StartsWith("~/", StringComparison.OrdinalIgnoreCase)) + { + var replacementString = home + arg.Substring(1); + _arguments.Append(replacementString); + argExpanded = true; + } } } #endif // UNIX + if (stringConstantType != StringConstantType.SingleQuoted) + { + arg = ResolvePath(arg, Context); + } + if (!argExpanded) { _arguments.Append(arg); } } + /// + /// Check if string is prefixed by psdrive, if so, expand it if filesystem path. + /// + /// The potential PSPath to resolve. + /// The current ExecutionContext. + /// Resolved PSPath if applicable otherwise the original path + internal static string ResolvePath(string path, ExecutionContext context) + { + if (ExperimentalFeature.IsEnabled("PSNativePSPathResolution")) + { +#if !UNIX + // on Windows, we need to expand ~ to point to user's home path + if (string.Equals(path, "~", StringComparison.Ordinal) || path.StartsWith(TildeDirectorySeparator, StringComparison.Ordinal) || path.StartsWith(TildeAltDirectorySeparator, StringComparison.Ordinal)) + { + try + { + ProviderInfo fileSystemProvider = context.EngineSessionState.GetSingleProvider(FileSystemProvider.ProviderName); + return new StringBuilder(fileSystemProvider.Home) + .Append(path.Substring(1)) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) + .ToString(); + } + catch + { + return path; + } + } + + // check if the driveName is an actual disk drive on Windows, if so, no expansion + if (path.Length >= 2 && path[1] == ':') + { + foreach (var drive in DriveInfo.GetDrives()) + { + if (drive.Name.StartsWith(new string(path[0], 1), StringComparison.OrdinalIgnoreCase)) + { + return path; + } + } + } +#endif + + if (path.Contains(':')) + { + LocationGlobber globber = new LocationGlobber(context.SessionState); + try + { + ProviderInfo providerInfo; + + // replace the argument with resolved path if it's a filesystem path + string pspath = globber.GetProviderPath(path, out providerInfo); + if (string.Equals(providerInfo.Name, FileSystemProvider.ProviderName, StringComparison.OrdinalIgnoreCase)) + { + path = pspath; + } + } + catch + { + // if it's not a provider path, do nothing + } + } + } + + return path; + } + /// /// Check to see if the string contains spaces and therefore must be quoted. /// @@ -384,6 +473,9 @@ namespace System.Management.Automation /// The native command to bind to. /// private NativeCommand _nativeCommand; + private static readonly string TildeDirectorySeparator = $"~{Path.DirectorySeparatorChar}"; + private static readonly string TildeAltDirectorySeparator = $"~{Path.AltDirectorySeparatorChar}"; + #endregion private members } } diff --git a/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 b/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 index 693697d84d..c5b5a5f66d 100644 --- a/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 +++ b/test/powershell/Language/Scripting/NativeExecution/NativeCommandArguments.Tests.ps1 @@ -64,3 +64,98 @@ Describe "Native Command Arguments" -tags "CI" { } } } + +Describe 'PSPath to native commands' { + BeforeAll { + $featureEnabled = $EnabledExperimentalFeatures.Contains('PSNativePSPathResolution') + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + + $PSDefaultParameterValues["it:skip"] = (-not $featureEnabled) + + if ($IsWindows) { + $cmd = "cmd" + $cmdArg1 = "/c" + $cmdArg2 = "type" + $dir = "cmd" + $dirArg1 = "/c" + $dirArg2 = "dir" + } + else { + $cmd = "cat" + $dir = "ls" + } + + Set-Content -Path testdrive:/test.txt -Value 'Hello' + Set-Content -Path "testdrive:/test file.txt" -Value 'Hello' + Set-Content -Path "env:/test var" -Value 'Hello' + $filePath = Join-Path -Path ~ -ChildPath (New-Guid) + Set-Content -Path $filePath -Value 'Home' + $complexDriveName = 'My test! ;+drive' + New-PSDrive -Name $complexDriveName -Root $testdrive -PSProvider FileSystem + } + + AfterAll { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + + Remove-Item -Path "env:/test var" + Remove-Item -Path $filePath + Remove-PSDrive -Name $complexDriveName + } + + It 'PSPath with ~/path works' { + $out = & $cmd $cmdArg1 $cmdArg2 $filePath + $LASTEXITCODE | Should -Be 0 + $out | Should -BeExactly 'Home' + } + + It 'PSPath with ~ works' { + $out = & $dir $dirArg1 $dirArg2 ~ + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + } + + It 'PSPath that is file system path works with native commands: ' -TestCases @( + @{ path = "testdrive:/test.txt" } + @{ path = "testdrive:/test file.txt" } + ){ + param($path) + + $out = & $cmd $cmdArg1 $cmdArg2 "$path" + $LASTEXITCODE | Should -Be 0 + $out | Should -BeExactly 'Hello' + } + + It 'PSPath passed with single quotes should be treated as literal' { + $out = & $cmd $cmdArg1 $cmdArg2 'testdrive:/test.txt' + $LASTEXITCODE | Should -Not -Be 0 + $out | Should -BeNullOrEmpty + } + + It 'PSPath that is not a file system path fails with native commands: ' -TestCases @( + @{ path = "env:/PSModulePath" } + @{ path = "env:/test var" } + ){ + param($path) + + $out = & $cmd $cmdArg1 $cmdArg2 "$path" + $LASTEXITCODE | Should -Not -Be 0 + $out | Should -BeNullOrEmpty + } + + It 'Relative PSPath works' { + New-Item -Path $testdrive -Name TestFolder -ItemType Directory -ErrorAction Stop + $pwd = Get-Location + Set-Content -Path (Join-Path -Path $testdrive -ChildPath 'TestFolder' -AdditionalChildPath 'test.txt') -Value 'hello' + Set-Location -Path (Join-Path -Path $testdrive -ChildPath 'TestFolder') + Set-Location -Path $pwd + $out = & $cmd $cmdArg1 $cmdArg2 "TestDrive:test.txt" + $LASTEXITCODE | Should -Be 0 + $out | Should -BeExactly 'Hello' + } + + It 'Complex PSDrive name works' { + $out = & $cmd $cmdArg1 $cmdArg2 "${complexDriveName}:/test.txt" + $LASTEXITCODE | Should -Be 0 + $out | Should -BeExactly 'Hello' + } +}