Skip to content
Open
16 changes: 16 additions & 0 deletions src/code/InternalHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
52 changes: 44 additions & 8 deletions src/code/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,14 +1101,14 @@ public static List<string> GetPathsFromEnvVarAndScope(
{
GetStandardPlatformPaths(
psCmdlet,
out string myDocumentsPath,
out string psUserContentPath,
out string programFilesPath);

List<string> resourcePaths = new List<string>();
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)
Expand Down Expand Up @@ -1207,28 +1207,64 @@ private static string GetHomeOrCreateTempHome()
return s_tempHome;
}

/// <summary>
/// Gets the user content directory path using PowerShell's $PSUserContentPath variable.
/// Falls back to the legacy path if the variable is not available.
/// </summary>
private static string GetUserContentPath(PSCmdlet psCmdlet, string legacyPath)
{
object userContentPathValue = psCmdlet.SessionState.PSVariable.GetValue("PSUserContentPath");
if (userContentPathValue is PSObject userContentPathObject)
{
Comment thread
jshigetomi marked this conversation as resolved.
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,
out string allUsersDir)
{
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");
}
}

Expand Down
138 changes: 138 additions & 0 deletions test/PSContentPath.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading