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 ae540f9e4..8fa94d13a 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -22,6 +22,7 @@ using Azure.Identity; using System.Text.RegularExpressions; using System.Threading; +using System.Text.Json; using System.Threading.Tasks; using System.Xml; @@ -1049,14 +1050,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) @@ -1156,28 +1157,108 @@ private static string GetHomeOrCreateTempHome() } private readonly static Version PSVersion6 = new Version(6, 0); + private readonly static Version PSVersion7_7 = new Version(7, 7); + + /// + /// Gets the user content directory path using PowerShell's Get-PSContentPath cmdlet. + /// Falls back to legacy path if the cmdlet is not available or PowerShell version is below 7.7.0. + /// + private static string GetUserContentPath(PSCmdlet psCmdlet, Version psVersion, string legacyPath) + { + + // Only use Get-PSContentPath cmdlet if PowerShell version is 7.7.0 or greater (when PSContentPath feature is available) + if (psVersion >= PSVersion7_7) + { + // Try to use PowerShell's Get-PSContentPath cmdlet in the current runspace + // This cmdlet is only available if experimental feature PSContentPath is enabled + try + { + var results = psCmdlet.InvokeCommand.InvokeScript("Get-PSContentPath"); + + if (results != null && results.Count > 0) + { + // Get-PSContentPath returns a PSObject, extract the path string + string userContentPath = results[0]?.ToString(); + if (!string.IsNullOrEmpty(userContentPath)) + { + psCmdlet.WriteVerbose($"User content path from Get-PSContentPath: {userContentPath}"); + InternalHooks.LastUserContentPathSource = "Get-PSContentPath"; + InternalHooks.LastUserContentPath = userContentPath; + return userContentPath; + } + } + } + catch (Exception ex) + { + psCmdlet.WriteVerbose($"Get-PSContentPath cmdlet not available: {ex.Message}"); + } + } + else + { + psCmdlet.WriteVerbose($"PowerShell version {psVersion} is below 7.7.0, using legacy location"); + } + + // 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, out string allUsersDir) { + // Get PowerShell engine version from $PSVersionTable.PSVersion + Version psVersion = new Version(5, 1); + try + { + dynamic psVersionObj = (psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") as Hashtable)?["PSVersion"]; + if (psVersionObj != null) psVersion = new Version((int)psVersionObj.Major, (int)psVersionObj.Minor); + } + catch { + // Fallback if dynamic access fails + psCmdlet.WriteWarning("Unable to determine PowerShell version from $PSVersionTable"); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; - localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + string powerShellType = (psVersion >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; + + // Windows PowerShell doesn't support experimental features or PSContentPath + if (powerShellType == "WindowsPowerShell") + { + // Use legacy Documents folder for Windows PowerShell + localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + psCmdlet.WriteVerbose($"Using Windows PowerShell Documents folder: {localUserDir}"); + } + else + { + string legacyPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + powerShellType + ); + + localUserDir = GetUserContentPath(psCmdlet, psVersion, 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, psVersion, 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..32f3919fd --- /dev/null +++ b/test/PSContentPath.Tests.ps1 @@ -0,0 +1,171 @@ +# 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 + $script:actualConfigPath = Join-Path $env:LOCALAPPDATA "PowerShell\powershell.config.json" + $script:configBackup = $null + + # Detect if Get-PSContentPath cmdlet is available (requires PSContentPath experimental feature) + $script:getPSContentPathAvailable = $false + $script:isPSContentPathEnabled = $false + $script:sessionContentPath = $null + try { + # Check if Get-PSContentPath cmdlet exists + $null = Get-Command Get-PSContentPath -ErrorAction Stop + $script:getPSContentPathAvailable = $true + + # Get the actual session path + $script:sessionContentPath = Get-PSContentPath + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + $documentsPS = Join-Path $documentsPath "PowerShell" + + # If Get-PSContentPath returns something other than Documents, the feature is enabled + $script:isPSContentPathEnabled = $script:sessionContentPath -ne $documentsPS + } catch { + # Get-PSContentPath not available (feature disabled) + } + + # Backup existing config if it exists + if (Test-Path $script:actualConfigPath) { + $script:configBackup = Get-Content $script:actualConfigPath -Raw + } + + $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 + Uninstall-PSResource $testModuleName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + # Clear testing hooks + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::ClearPSContentPathHooks() + } + + AfterAll { + # Restore original config + if ($null -ne $script:configBackup) { + Set-Content -Path $script:actualConfigPath -Value $script:configBackup -Force + } + Get-RevertPSResourceRepositoryFile + } + + Context "PSResourceGet behavior on PS 7.7+" { + It "Should use Get-PSContentPath 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:getPSContentPathAvailable) { + # When Get-PSContentPath cmdlet exists, PSResourceGet should use it + $pathSource | Should -Be "Get-PSContentPath" + $pathUsed | Should -Be $script:sessionContentPath + } else { + # When Get-PSContentPath cmdlet doesn't exist, PSResourceGet should use legacy path + $pathSource | Should -Be "Legacy" + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + $pathUsed | Should -BeLike "*$documentsPath*PowerShell" + } + + # Module should be installed + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + } + } + + Context "When PSContentPath feature is enabled in session (PS >= 7.7)" { + It "Should install to custom LocalAppData path (not Documents)" { + if (-not $script:getPSContentPathAvailable -or -not $script:isPSContentPathEnabled) { + Set-ItResult -Skipped -Because "PSContentPath feature not enabled 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") + + # PSResourceGet should call Get-PSContentPath + $pathSource | Should -Be "Get-PSContentPath" + + # Path should NOT be Documents (feature enabled means custom path) + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + $pathUsed | Should -Not -BeLike "*$documentsPath*" + + # Module should be installed in custom path + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.InstalledLocation | Should -Not -BeLike "*$documentsPath*" + } + } + + Context "PSResourceGet correctly delegates path resolution (PS >= 7.7)" { + It "Should always defer to Get-PSContentPath when cmdlet is available" { + if (-not $script:getPSContentPathAvailable) { + Set-ItResult -Skipped -Because "Get-PSContentPath cmdlet not available" + return + } + + $beforePath = Get-PSContentPath + + 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") + + # PSResourceGet should call Get-PSContentPath + $pathSource | Should -Be "Get-PSContentPath" + + # Path should match what Get-PSContentPath returns + $pathUsed | Should -Be $beforePath + + # Module should be installed + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + } + } + + Context "AllUsers scope should not be affected by PSContentPath/PSUserContentPath" { + BeforeAll { + if (!$IsWindows -or !(Test-IsAdmin)) { return } + } + It "Should install to Program Files (AllUsers not affected by PSContentPath)" { + if (!$IsWindows -or !(Test-IsAdmin)) { + Set-ItResult -Skipped -Because "Test requires Windows and Administrator privileges" + return + } + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope AllUsers -TrustRepository + $programFilesPath = [Environment]::GetFolderPath('ProgramFiles') + $expectedPath = Join-Path $programFilesPath "PowerShell\Modules\$testModuleName" + Test-Path $expectedPath | Should -BeTrue + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.InstalledLocation | Should -BeLike "*Program Files*PowerShell*Modules*" + } + } +} + +function Test-IsAdmin { + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + return $false +}