From 2e553038c54925b3756a72ead7a9cdf130fc5854 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 15 Nov 2019 17:29:04 -0800 Subject: [PATCH] Allow `pwsh` to inherit `$env:PSModulePath` and enable `powershell.exe` to start correctly (#11057) --- .../engine/Modules/ModuleIntrinsics.cs | 180 +++++------------- .../engine/NativeCommandProcessor.cs | 13 ++ .../TabCompletion/TabCompletion.Tests.ps1 | 5 +- .../Import-Module.Tests.ps1 | 4 +- .../certificateCommon.psm1 | 17 +- .../engine/Module/ModulePath.Tests.ps1 | 63 ++++-- 6 files changed, 118 insertions(+), 164 deletions(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 4116060625..9a0511a93c 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -12,6 +12,7 @@ using System.Text; using System.Threading; using Microsoft.PowerShell.Commands; +using Microsoft.Win32; using Dbg = System.Management.Automation.Diagnostics; @@ -1162,92 +1163,6 @@ namespace System.Management.Automation return result.ToString(); } - /// - /// Check if the current powershell is likely running in following scenarios: - /// - PSCore started on windows [machine-wide env:PSModulePath will influence] - /// - PSCore started from full ps - /// - PSCore started from inbox nano/iot ps - /// If it's likely one of them, then we need to clear the current process module path. - /// - private static bool NeedToClearProcessModulePath(string currentProcessModulePath, string personalModulePath, string sharedModulePath) - { -#if UNIX - return false; -#else - Dbg.Assert(!string.IsNullOrEmpty(personalModulePath), "caller makes sure personalModulePath not null or empty"); - Dbg.Assert(sharedModulePath != null, "caller makes sure sharedModulePath is not null"); - - const string winSxSModuleDirectory = @"PowerShell\Modules"; - const string winLegacyModuleDirectory = @"WindowsPowerShell\Modules"; - - // The machine-wide and user-wide environment variables are only meaningful for full ps, - // so if the current process module path contains any of them, it's likely that the sxs - // ps was started directly on windows, or from full ps. The same goes for the legacy personal - // and shared module paths. - string hklmModulePath = GetExpandedEnvironmentVariable(Constants.PSModulePathEnvVar, EnvironmentVariableTarget.Machine); - string hkcuModulePath = GetExpandedEnvironmentVariable(Constants.PSModulePathEnvVar, EnvironmentVariableTarget.User); - string legacyPersonalModulePath = personalModulePath.Replace(winSxSModuleDirectory, winLegacyModuleDirectory); - string legacyProgramFilesModulePath = sharedModulePath.Replace(winSxSModuleDirectory, winLegacyModuleDirectory); - - return (!string.IsNullOrEmpty(hklmModulePath) && currentProcessModulePath.IndexOf(hklmModulePath, StringComparison.OrdinalIgnoreCase) != -1) || - (!string.IsNullOrEmpty(hkcuModulePath) && currentProcessModulePath.IndexOf(hkcuModulePath, StringComparison.OrdinalIgnoreCase) != -1) || - currentProcessModulePath.IndexOf(legacyPersonalModulePath, StringComparison.OrdinalIgnoreCase) != -1 || - currentProcessModulePath.IndexOf(legacyProgramFilesModulePath, StringComparison.OrdinalIgnoreCase) != -1; -#endif - } - - /// - /// When sxs ps instance B got started from sxs ps instance A, A's pshome module path might - /// show up in current process module path. It doesn't make sense for B to load modules from - /// A's pshome module path, so remove it in such case. - /// - private static string RemoveSxSPsHomeModulePath(string currentProcessModulePath, string personalModulePath, string sharedModulePath, string psHomeModulePath) - { -#if UNIX - const string powershellExeName = "pwsh"; -#else - const string powershellExeName = "pwsh.exe"; -#endif - const string powershellDepsName = "pwsh.deps.json"; - - StringBuilder modulePathString = new StringBuilder(currentProcessModulePath.Length); - char[] invalidPathChars = Path.GetInvalidPathChars(); - - foreach (var path in currentProcessModulePath.Split(Utils.Separators.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - string trimedPath = path.Trim().TrimEnd(Path.DirectorySeparatorChar); - if (trimedPath.IndexOfAny(invalidPathChars) != -1 || !Path.IsPathRooted(trimedPath)) - { - // Path contains invalid characters or it's not an absolute path. Ignore it. - continue; - } - - if (!trimedPath.Equals(personalModulePath, StringComparison.OrdinalIgnoreCase) && - !trimedPath.Equals(sharedModulePath, StringComparison.OrdinalIgnoreCase) && - !trimedPath.Equals(psHomeModulePath, StringComparison.OrdinalIgnoreCase) && - trimedPath.EndsWith("Modules", StringComparison.OrdinalIgnoreCase)) - { - string parentDir = Path.GetDirectoryName(trimedPath); - string psExePath = Path.Combine(parentDir, powershellExeName); - string psDepsPath = Path.Combine(parentDir, powershellDepsName); - if ((File.Exists(psExePath) && File.Exists(psDepsPath))) - { - // Path is a PSHome module path from a different PowerShell instance. Ignore it. - continue; - } - } - - if (modulePathString.Length > 0) - { - modulePathString.Append(Path.PathSeparator); - } - - modulePathString.Append(trimedPath); - } - - return modulePathString.ToString(); - } - /// /// Checks the various PSModulePath environment string and returns PSModulePath string as appropriate. Note - because these /// strings go through the provider, we need to escape any wildcards before passing them @@ -1259,16 +1174,6 @@ namespace System.Management.Automation string sharedModulePath = GetSharedModulePath(); // aka location string psHomeModulePath = GetPSHomeModulePath(); // $PSHome\Modules location - if (!string.IsNullOrEmpty(currentProcessModulePath) && - NeedToClearProcessModulePath(currentProcessModulePath, personalModulePath, sharedModulePath)) - { - // Clear the current process module path in the following cases - // - start PSCore on windows [machine-wide env:PSModulePath will influence] - // - start PSCore from full ps - // - start PSCore from inbox nano/iot ps - currentProcessModulePath = null; - } - // If the variable isn't set, then set it to the default value if (currentProcessModulePath == null) // EVT.Process does Not exist - really corner case { @@ -1291,39 +1196,30 @@ namespace System.Management.Automation { currentProcessModulePath += hklmMachineModulePath; // += EVT.Machine } + +#if !UNIX + // Add Windows Modules path + currentProcessModulePath = $"{currentProcessModulePath}{Path.PathSeparator}{s_windowsPowerShellPSHomeModulePath}"; +#endif } // EVT.Process exists // Now handle the case where the environment variable is already set. else { - // When SxS PS instance A starts SxS PS instance B, A's PSHome module path might be inherited by B. We need to remove that path from B - currentProcessModulePath = RemoveSxSPsHomeModulePath(currentProcessModulePath, personalModulePath, sharedModulePath, psHomeModulePath); - string personalModulePathToUse = string.IsNullOrEmpty(hkcuUserModulePath) ? personalModulePath : hkcuUserModulePath; string systemModulePathToUse = string.IsNullOrEmpty(hklmMachineModulePath) ? psHomeModulePath : hklmMachineModulePath; + // Maintain order of the paths, but ahead of any existing paths: + // personalModulePath + // sharedModulePath + // systemModulePath currentProcessModulePath = AddToPath(currentProcessModulePath, personalModulePathToUse, 0); - - int insertIndex = -1; -#if !UNIX - string windowsPowerShellModulePath = GetWindowsPowerShellPSHomeModulePath(); - // If the Windows PowerShell Module path is already present, insert the system module path - // ($PSHOME/Modules) before it. - insertIndex = PathContainsSubstring(currentProcessModulePath, windowsPowerShellModulePath); -#endif + int insertIndex = PathContainsSubstring(currentProcessModulePath, personalModulePathToUse) + personalModulePathToUse.Length + 1; + currentProcessModulePath = AddToPath(currentProcessModulePath, sharedModulePath, insertIndex); + insertIndex = PathContainsSubstring(currentProcessModulePath, sharedModulePath) + sharedModulePath.Length + 1; currentProcessModulePath = AddToPath(currentProcessModulePath, systemModulePathToUse, insertIndex); } - // if we reached this point - always add location to EVT.Process - // everything below is the same behaviour as WMF 4 code - - // index of $PSHome\Modules in currentProcessModulePath - int indexOfPSHomeModulePath = PathContainsSubstring(currentProcessModulePath, psHomeModulePath); - - // if $PSHome\Modules not found (psHomePosition == -1) - append location to the end; - // if $PSHome\Modules IS found (psHomePosition >= 0) - insert location before $PSHome\Modules - currentProcessModulePath = AddToPath(currentProcessModulePath, sharedModulePath, indexOfPSHomeModulePath); - return currentProcessModulePath; } @@ -1338,6 +1234,45 @@ namespace System.Management.Automation return currentModulePath; } +#if !UNIX + /// + /// Returns a PSModulePath suiteable for Windows PowerShell by removing this PowerShell's specific + /// paths from current PSModulePath. + /// + /// + /// Returns appropriate PSModulePath for Windows PowerShell. + /// + internal static string GetWindowsPowerShellModulePath() + { + string currentModulePath = GetModulePath(); + + if (currentModulePath == null) + { + return null; + } + + // PowerShell specific paths including if set in powershell.config.json file we want to exclude + var excludeModulePaths = new HashSet { + GetPersonalModulePath(), + GetSharedModulePath(), + GetPSHomeModulePath(), + PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers), + PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) + }; + + var modulePathList = new List(); + foreach (var path in currentModulePath.Split(';')) + { + if (!excludeModulePaths.Contains(path)) + { + modulePathList.Add(path); + } + } + + return string.Join(Path.PathSeparator, modulePathList); + } +#endif + /// /// Checks if $env:PSModulePath is not set and sets it as appropriate. Note - because these /// strings go through the provider, we need to escape any wildcards before passing them @@ -1353,17 +1288,6 @@ namespace System.Management.Automation if (!string.IsNullOrEmpty(newModulePathString)) { -#if !UNIX - // If on Windows, we want to add the System32 Windows PowerShell module directory - // so that Windows modules are discoverable - string windowsPowerShellModulePath = GetWindowsPowerShellPSHomeModulePath(); - if (!newModulePathString.Contains(windowsPowerShellModulePath, StringComparison.OrdinalIgnoreCase)) - { - newModulePathString += Path.PathSeparator + windowsPowerShellModulePath; - } -#endif - - // Set the environment variable... Environment.SetEnvironmentVariable(Constants.PSModulePathEnvVar, newModulePathString); } diff --git a/src/System.Management.Automation/engine/NativeCommandProcessor.cs b/src/System.Management.Automation/engine/NativeCommandProcessor.cs index 7a7ab4a3b8..c41cd9a055 100644 --- a/src/System.Management.Automation/engine/NativeCommandProcessor.cs +++ b/src/System.Management.Automation/engine/NativeCommandProcessor.cs @@ -409,6 +409,19 @@ namespace System.Management.Automation // Get the start info for the process. ProcessStartInfo startInfo = GetProcessStartInfo(redirectOutput, redirectError, redirectInput, soloCommand); +#if !UNIX + string commandPath = this.Path.ToLowerInvariant(); + if (commandPath.EndsWith("powershell.exe") || commandPath.EndsWith("powershell_ise.exe")) + { + // if starting Windows PowerShell, need to remove PowerShell specific segments of PSModulePath + string psmodulepath = ModuleIntrinsics.GetWindowsPowerShellModulePath(); + startInfo.Environment["PSModulePath"] = psmodulepath; + + // must set UseShellExecute to false if we modify the environment block + startInfo.UseShellExecute = false; + } +#endif + if (this.Command.Context.CurrentPipelineStopping) { throw new PipelineStoppedException(); diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index e1ed4e22c5..7285798830 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -17,9 +17,10 @@ Describe "TabCompletion" -Tags CI { } It 'Should complete abbreviated function' { - $res = (TabExpansion2 -inputScript 'pschrl' -cursorColumn 'pschr'.Length).CompletionMatches.CompletionText + function Test-AbbreviatedFunctionExpansion {} + $res = (TabExpansion2 -inputScript 't-afe' -cursorColumn 't-afe'.Length).CompletionMatches.CompletionText $res.Count | Should -BeGreaterOrEqual 1 - $res | Should -BeExactly 'PSConsoleHostReadLine' + $res | Should -BeExactly 'Test-AbbreviatedFunctionExpansion' } It 'Should complete native exe' -Skip:(!$IsWindows) { diff --git a/test/powershell/Modules/Microsoft.PowerShell.Core/Import-Module.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Core/Import-Module.Tests.ps1 index 7f29101044..518c476adf 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Core/Import-Module.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Core/Import-Module.Tests.ps1 @@ -32,7 +32,7 @@ Describe "Import-Module" -Tags "CI" { } It "should be able to load a module with a trailing directory separator: " -TestCases @( - @{ modulePath = (Get-Module -ListAvailable $moduleName).ModuleBase + [System.IO.Path]::DirectorySeparatorChar; expectedName = $moduleName }, + @{ modulePath = (Get-Module -ListAvailable $moduleName)[0].ModuleBase + [System.IO.Path]::DirectorySeparatorChar; expectedName = $moduleName }, @{ modulePath = Join-Path -Path $TestDrive -ChildPath "\Modules\TestModule\"; expectedName = "TestModule" } ) { param( $modulePath, $expectedName ) @@ -41,7 +41,7 @@ Describe "Import-Module" -Tags "CI" { } It "should be able to add a module with using ModuleInfo switch" { - $a = Get-Module -ListAvailable $moduleName + $a = (Get-Module -ListAvailable $moduleName)[0] { Import-Module -ModuleInfo $a } | Should -Not -Throw (Get-Module -Name $moduleName).Name | Should -BeExactly $moduleName } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Security/certificateCommon.psm1 b/test/powershell/Modules/Microsoft.PowerShell.Security/certificateCommon.psm1 index e9a9d95a77..03dd6bb99c 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Security/certificateCommon.psm1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Security/certificateCommon.psm1 @@ -146,22 +146,15 @@ function Install-TestCertificates # PKI module is not available for PowerShell, so we need to use Windows PowerShell to import the cert $fullPowerShell = Join-Path "$env:SystemRoot" "System32\WindowsPowerShell\v1.0\powershell.exe" - try { - $modulePathCopy = $env:PSModulePath - $env:PSModulePath = $null - - $command = @" + $command = @" Import-PfxCertificate $script:certLocation -CertStoreLocation cert:\CurrentUser\My | ForEach-Object PSPath Import-Certificate $script:badCertLocation -CertStoreLocation Cert:\CurrentUser\My | ForEach-Object PSPath "@ - $certPaths = & $fullPowerShell -NoProfile -NonInteractive -Command $command - $certPaths.Count | Should -Be 2 | Out-Null + $certPaths = & $fullPowerShell -NoProfile -NonInteractive -Command $command + $certPaths.Count | Should -Be 2 | Out-Null - $script:importedCert = Get-ChildItem $certPaths[0] - $script:testBadCert = Get-ChildItem $certPaths[1] - } finally { - $env:PSModulePath = $modulePathCopy - } + $script:importedCert = Get-ChildItem $certPaths[0] + $script:testBadCert = Get-ChildItem $certPaths[1] } elseif($IsWindows) { diff --git a/test/powershell/engine/Module/ModulePath.Tests.ps1 b/test/powershell/engine/Module/ModulePath.Tests.ps1 index d038fe7f2c..c124a8ed17 100644 --- a/test/powershell/engine/Module/ModulePath.Tests.ps1 +++ b/test/powershell/engine/Module/ModulePath.Tests.ps1 @@ -3,7 +3,6 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { BeforeAll { - if ($IsWindows) { $powershell = "$PSHOME\pwsh.exe" @@ -14,13 +13,22 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { } $expectedUserPath = Join-Path -Path $HOME -ChildPath "Documents\$ProductName\Modules" $expectedSharedPath = Join-Path -Path $env:ProgramFiles -ChildPath "$ProductName\Modules" + $userConfigPath = "~/Documents/powershell/powershell.config.json" } else { $powershell = "$PSHOME/pwsh" $expectedUserPath = [System.Management.Automation.Platform]::SelectProductNameForDirectory("USER_MODULES") $expectedSharedPath = [System.Management.Automation.Platform]::SelectProductNameForDirectory("SHARED_MODULES") + $userConfigPath = "~/.config/powershell/powershell.config.json" } + + $userConfigExists = $false + if (Test-Path $userConfigPath) { + $userConfigExists = $true + Copy-Item $userConfigPath "$userConfigPath.backup" -Force -ErrorAction Ignore + } + $expectedSystemPath = Join-Path -Path $PSHOME -ChildPath 'Modules' # Skip these tests in cases when there is no 'pwsh' executable (e.g. when framework dependent PS package is used) @@ -41,6 +49,15 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { New-Item -Path $fakePSHomeModuleDir -ItemType Directory > $null } + AfterAll { + if ($userConfigExists) { + Move-Item "$userConfigPath.backup" $userConfigPath -Force -ErrorAction Ignore + } + else { + Remove-Item "$userConfigPath" -Force -ErrorAction Ignore + } + } + BeforeEach { $originalModulePath = $env:PSModulePath } @@ -74,7 +91,7 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { } } - It "ignore pshome module path derived from a different PowerShell instance" -Skip:(!$IsCoreCLR -or $skipNoPwsh) { + It "Works with pshome module path derived from a different PowerShell instance" -Skip:(!$IsCoreCLR -or $skipNoPwsh) { ## Create 'powershell' and 'pwsh.deps.json' in the fake PSHome folder, ## so that the module path calculation logic would believe it's real. @@ -88,21 +105,15 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { $newModulePath = & $powershell -nopro -c '$env:PSModulePath' $paths = $newModulePath -split [System.IO.Path]::PathSeparator - if ($IsWindows) - { - $paths.Count | Should -Be 4 - } - else - { - $paths.Count | Should -Be 3 - } + $paths.Count | Should -Be 4 $paths[0] | Should -Be $expectedUserPath $paths[1] | Should -Be $expectedSharedPath $paths[2] | Should -Be $expectedSystemPath + $paths[3] | Should -Be $fakePSHomeModuleDir if ($IsWindows) { - $paths[3].TrimEnd([System.IO.Path]::DirectorySeparatorChar) | Should -Be $expectedWindowsPowerShellPSHomePath + $expectedWindowsPowerShellPSHomePath | Should -Not -BeIn $paths } } finally { @@ -120,15 +131,7 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { $env:PSModulePath = $fakePSHomeModuleDir, $customeModules -join ([System.IO.Path]::PathSeparator) $newModulePath = & $powershell -nopro -c '$env:PSModulePath' $paths = $newModulePath -split [System.IO.Path]::PathSeparator - - if ($IsWindows) - { - $paths.Count | Should -Be 6 - } - else - { - $paths.Count | Should -Be 5 - } + $paths.Count | Should -Be 5 $paths -contains $fakePSHomeModuleDir | Should -BeTrue $paths -contains $customeModules | Should -BeTrue } @@ -162,4 +165,24 @@ Describe "SxS Module Path Basic Tests" -tags "CI" { $wpshIndex | Should -Not -Be -1 $pwshIndex | Should -BeLessThan $wpshIndex } + + It 'Windows PowerShell does not inherit PowerShell paths' -Skip:(!$IsWindows) { + $out = powershell.exe -noprofile -command '$env:PSModulePath' + $out | Should -Not -Contain $expectedUserPath + $out | Should -Not -Contain $expectedSharedPath + $out | Should -Not -Contain $expectedSystemPath + } + + It 'Windows PowerShell inherits user added paths' -Skip:(!$IsWindows) { + $env:PSModulePath += ";myPath" + $out = powershell.exe -noprofile -command '$env:PSModulePath' + $out | Should -BeLike '*;myPath' + } + + It 'Windows PowerShell does not inherit path defined in powershell.config.json' -Skip:(!$IsWindows) { + $userConfig = '{ "PSModulePath": "myUserPath" }' + Set-Content -Path $userConfigPath -Value $userConfig -Force + $out = pwsh -noprofile -command 'powershell.exe -noprofile -command $env:PSModulePath' + $out | Should -Not -BeLike 'myUserPath;*' + } }