diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 99d6104ce..a86ab21d5 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -17,12 +17,28 @@ public class InternalHooks internal static string MARPrefix; + // PSContentPath testing hooks + internal static string LastUserContentPathSource; + internal static string LastUserContentPath; + public static void SetTestHook(string property, object value) { var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); fieldInfo?.SetValue(null, value); } + public static object GetTestHook(string property) + { + var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); + return fieldInfo?.GetValue(null); + } + + public static void ClearPSContentPathHooks() + { + LastUserContentPathSource = null; + LastUserContentPath = null; + } + public static string GetUserString() { return Microsoft.PowerShell.PSResourceGet.Cmdlets.UserAgentInfo.UserAgentString(); diff --git a/src/code/Utils.cs b/src/code/Utils.cs index a79f2c2b9..3e05f1089 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1101,14 +1101,14 @@ public static List GetPathsFromEnvVarAndScope( { GetStandardPlatformPaths( psCmdlet, - out string myDocumentsPath, + out string psUserContentPath, out string programFilesPath); List resourcePaths = new List(); if (scope is null || scope.Value is ScopeType.CurrentUser) { - resourcePaths.Add(Path.Combine(myDocumentsPath, "Modules")); - resourcePaths.Add(Path.Combine(myDocumentsPath, "Scripts")); + resourcePaths.Add(Path.Combine(psUserContentPath, "Modules")); + resourcePaths.Add(Path.Combine(psUserContentPath, "Scripts")); } if (scope.Value is ScopeType.AllUsers) @@ -1207,6 +1207,34 @@ private static string GetHomeOrCreateTempHome() return s_tempHome; } + /// + /// Gets the user content directory path using PowerShell's $PSUserContentPath variable. + /// Falls back to the legacy path if the variable is not available. + /// + private static string GetUserContentPath(PSCmdlet psCmdlet, string legacyPath) + { + object userContentPathValue = psCmdlet.SessionState.PSVariable.GetValue("PSUserContentPath"); + if (userContentPathValue is PSObject userContentPathObject) + { + userContentPathValue = userContentPathObject.BaseObject; + } + + string userContentPath = userContentPathValue as string; + if (!string.IsNullOrWhiteSpace(userContentPath)) + { + psCmdlet.WriteVerbose($"User content path from $PSUserContentPath variable: {userContentPath}"); + InternalHooks.LastUserContentPathSource = "$PSUserContentPath"; + InternalHooks.LastUserContentPath = userContentPath; + return userContentPath; + } + + // Fallback to legacy location + psCmdlet.WriteVerbose($"Using legacy location: {legacyPath}"); + InternalHooks.LastUserContentPathSource = "Legacy"; + InternalHooks.LastUserContentPath = legacyPath; + return legacyPath; + } + private static void GetStandardPlatformPaths( PSCmdlet psCmdlet, out string localUserDir, @@ -1214,21 +1242,29 @@ private static void GetStandardPlatformPaths( { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - string powerShellType = ((psCmdlet.GetVariableValue("PSEdition") as string) == "Core") ? "PowerShell" : "WindowsPowerShell"; - localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + string powerShellType = GetIsWindowsPowerShell(psCmdlet) ? "WindowsPowerShell" : "PowerShell"; + string legacyPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + powerShellType + ); + + localUserDir = GetUserContentPath(psCmdlet, legacyPath); allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); } else { // paths are the same for both Linux and macOS - localUserDir = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); - // Create the default data directory if it doesn't exist. + string legacyPath = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); + + localUserDir = GetUserContentPath(psCmdlet, legacyPath); + + // Create the default data directory if it doesn't exist if (!Directory.Exists(localUserDir)) { Directory.CreateDirectory(localUserDir); } - allUsersDir = System.IO.Path.Combine("/usr", "local", "share", "powershell"); + allUsersDir = Path.Combine("/", "usr", "local", "share", "powershell"); } } diff --git a/test/PSContentPath.Tests.ps1 b/test/PSContentPath.Tests.ps1 new file mode 100644 index 000000000..3b4397bb4 --- /dev/null +++ b/test/PSContentPath.Tests.ps1 @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +Param() + +$ProgressPreference = "SilentlyContinue" +$modPath = "$psscriptroot/PSGetTestUtils.psm1" +Import-Module $modPath -Force + +Describe 'PSUserContentPath/PSContentPath - End-to-End Install Location' -Tags 'CI' { + BeforeAll { + $script:originalPSModulePath = $env:PSModulePath + $psUserContentPathVariable = Get-Variable -Name PSUserContentPath -ErrorAction SilentlyContinue + $script:psUserContentPathAvailable = $null -ne $psUserContentPathVariable ` + -and $psUserContentPathVariable.Value -is [string] ` + -and -not [string]::IsNullOrWhiteSpace($psUserContentPathVariable.Value) + $script:sessionContentPath = if ($script:psUserContentPathAvailable) { + $psUserContentPathVariable.Value + } else { + $null + } + $script:legacyContentPath = if (Get-IsWindows) { + Split-Path -Path (Get-CurrentUserModulesPath) -Parent + } else { + Join-Path -Path $env:HOME -ChildPath '.local/share/powershell' + } + $script:isCustomContentPath = $script:psUserContentPathAvailable ` + -and $script:sessionContentPath -ne $script:legacyContentPath + + $localRepo = "psgettestlocal" + $testModuleName = "PSContentPathTestModule" + Get-NewPSResourceRepositoryFile + Register-LocalRepos + + # Create a test module + New-TestModule -moduleName $testModuleName -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags @() + } + + AfterEach { + # Restore PSModulePath + $env:PSModulePath = $script:originalPSModulePath + # Clean up installed test modules from every scope exercised by the tests + Uninstall-PSResource $testModuleName -Version "*" -Scope CurrentUser -SkipDependencyCheck -ErrorAction SilentlyContinue + if ((Get-IsWindows) -and (Test-IsAdmin)) { + Uninstall-PSResource $testModuleName -Version "*" -Scope AllUsers -SkipDependencyCheck -ErrorAction SilentlyContinue + } + # Clear testing hooks + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::ClearPSContentPathHooks() + } + + AfterAll { + Get-RevertPSResourceRepositoryFile + } + + Context 'PSResourceGet user content path selection' { + It 'Should use $PSUserContentPath when available, Legacy when not' { + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository + + $pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource") + $pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath") + + if ($script:psUserContentPathAvailable) { + $pathSource | Should -Be '$PSUserContentPath' + $pathUsed | Should -Be $script:sessionContentPath + } else { + $pathSource | Should -Be "Legacy" + $pathUsed | Should -Be $script:legacyContentPath + } + + # Module should be installed + $res = Get-InstalledPSResource -Name $testModuleName -Scope CurrentUser + $res.Name | Should -Be $testModuleName + } + } + + Context 'When a custom $PSUserContentPath is configured' { + It "Should install to the custom user content path" { + if (-not $script:isCustomContentPath) { + Set-ItResult -Skipped -Because "A custom PSUserContentPath is not configured in this session" + return + } + + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository + + $pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource") + $pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath") + + $pathSource | Should -Be '$PSUserContentPath' + $pathUsed | Should -Be $script:sessionContentPath + + # Module should be installed in custom path + $res = Get-InstalledPSResource -Name $testModuleName -Scope CurrentUser + $expectedModulesPath = Join-Path $script:sessionContentPath 'Modules' + $expectedModulePath = Join-Path $expectedModulesPath $testModuleName + Test-Path $expectedModulePath | Should -BeTrue + $res.Name | Should -Be $testModuleName + $res.InstalledLocation | Should -Be $expectedModulesPath + } + } + + Context 'PSResourceGet delegates user content path resolution' { + It 'Should use $PSUserContentPath when the variable is available' { + if (-not $script:psUserContentPathAvailable) { + Set-ItResult -Skipped -Because "PSUserContentPath is not available" + return + } + + $beforePath = $PSUserContentPath + + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository + + $pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource") + $pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath") + + $pathSource | Should -Be '$PSUserContentPath' + + # Path should match the engine-provided session value + $pathUsed | Should -Be $beforePath + + # Module should be installed + $res = Get-InstalledPSResource -Name $testModuleName -Scope CurrentUser + $res.Name | Should -Be $testModuleName + } + } + + Context "AllUsers scope should not be affected by PSContentPath/PSUserContentPath" { + It "Should install to the shared PowerShell modules path" -Skip:(!((Get-IsWindows) -and (Test-IsAdmin))) { + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope AllUsers -TrustRepository + $expectedModulesPath = Get-AllUsersModulesPath + $expectedModulePath = Join-Path $expectedModulesPath $testModuleName + Test-Path $expectedModulePath | Should -BeTrue + $res = Get-InstalledPSResource -Name $testModuleName -Scope AllUsers + $res.Name | Should -Be $testModuleName + $res.InstalledLocation | Should -Be $expectedModulesPath + } + } +}