From 8ef8ae11386d46b3cfcbd57df42c269c0a1ad637 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 26 May 2026 16:20:04 -0500 Subject: [PATCH] Move AllUsers config out of $PSHOME and add Get-PowerShellConfiguration cmdlet --- .../CoreCLR/CorePsPlatform.cs | 17 ++++- .../engine/InitialSessionState.cs | 1 + .../engine/PSConfiguration.cs | 49 ++++++++++++-- .../engine/PSConfigurationCommand.cs | 67 +++++++++++++++++++ .../engine/Utils.cs | 3 + .../engine/hostifaces/HostUtilities.cs | 2 +- .../engine/Basic/DefaultCommands.Tests.ps1 | 1 + ...nableDisable-ExperimentalFeature.Tests.ps1 | 12 +++- .../Get-ExperimentalFeature.Tests.ps1 | 12 +++- .../Get-PowerShellConfiguration.Tests.ps1 | 53 +++++++++++++++ test/xUnit/csharp/test_PSConfiguration.cs | 4 +- tools/packaging/packaging.strings.psd1 | 6 ++ 12 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/System.Management.Automation/engine/PSConfigurationCommand.cs create mode 100644 test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 diff --git a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs index 4cbb346fb1..297bc10628 100644 --- a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs +++ b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs @@ -164,17 +164,25 @@ namespace System.Management.Automation #if UNIX // Gets the location for cache and config folders. internal static readonly string CacheDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.CACHE); - internal static readonly string ConfigDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.CONFIG); + internal static readonly string UserConfigDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.CONFIG); + + // System-wide configuration directory for AllUsers scope. + internal static readonly string SystemConfigDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.SYSTEM_CONFIG); #else // Gets the location for cache and config folders. internal static readonly string CacheDirectory = SafeDeriveFromSpecialFolder( Environment.SpecialFolder.LocalApplicationData, @"Microsoft\PowerShell"); - internal static readonly string ConfigDirectory = SafeDeriveFromSpecialFolder( + internal static readonly string UserConfigDirectory = SafeDeriveFromSpecialFolder( Environment.SpecialFolder.Personal, @"PowerShell"); + // System-wide configuration directory for AllUsers scope. + internal static readonly string SystemConfigDirectory = SafeDeriveFromSpecialFolder( + Environment.SpecialFolder.CommonApplicationData, + @"Microsoft\PowerShell"); + private static readonly Lazy _isStaSupported = new Lazy(() => { int result = Interop.Windows.CoInitializeEx(IntPtr.Zero, Interop.Windows.COINIT_APARTMENTTHREADED); @@ -307,6 +315,8 @@ namespace System.Management.Automation USER_MODULES, /// /usr/local/share/powershell/Modules SHARED_MODULES, + /// /etc/powershell + SYSTEM_CONFIG, /// XDG_CONFIG_HOME/powershell DEFAULT } @@ -360,6 +370,9 @@ namespace System.Management.Automation case XDG_Type.SHARED_MODULES: return "/usr/local/share/powershell/Modules"; + case XDG_Type.SYSTEM_CONFIG: + return "/etc/powershell"; + case XDG_Type.CACHE: // Use 'XDG_CACHE_HOME' if it's set, otherwise use the default path. if (string.IsNullOrEmpty(xdgcachehome)) diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index 62308282d1..4a5fa2698f 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -5479,6 +5479,7 @@ end { { "Get-PSHostProcessInfo", new SessionStateCmdletEntry("Get-PSHostProcessInfo", typeof(GetPSHostProcessInfoCommand), helpFile) }, { "Get-PSSession", new SessionStateCmdletEntry("Get-PSSession", typeof(GetPSSessionCommand), helpFile) }, { "Get-PSSubsystem", new SessionStateCmdletEntry("Get-PSSubsystem", typeof(Subsystem.GetPSSubsystemCommand), helpFile) }, + { "Get-PowerShellConfiguration", new SessionStateCmdletEntry("Get-PowerShellConfiguration", typeof(GetPowerShellConfigurationCommand), helpFile) }, { "Import-Module", new SessionStateCmdletEntry("Import-Module", typeof(ImportModuleCommand), helpFile) }, { "Invoke-Command", new SessionStateCmdletEntry("Invoke-Command", typeof(InvokeCommandCommand), helpFile) }, { "Invoke-History", new SessionStateCmdletEntry("Invoke-History", typeof(InvokeHistoryCommand), helpFile) }, diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 419a4cae95..caf2cc52b5 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -61,6 +61,10 @@ namespace System.Management.Automation.Configuration private string systemWideConfigFile; private string systemWideConfigDirectory; + // Legacy system-wide config file path in $PSHOME, used for fallback reads. + private readonly string legacySystemWideConfigFile; + private bool systemConfigOverridden; + // The json file containing the per-user configuration settings. private readonly string perUserConfigFile; private readonly string perUserConfigDirectory; @@ -81,14 +85,17 @@ namespace System.Management.Automation.Configuration private PowerShellConfig() { - // Sets the system-wide configuration file. - systemWideConfigDirectory = Utils.DefaultPowerShellAppBase; + // Sets the system-wide configuration file to the new platform-specific location. + // On Unix: /etc/powershell/powershell.config.json + // On Windows: %ProgramData%\Microsoft\PowerShell\powershell.config.json + systemWideConfigDirectory = InternalTestHooks.TestAllUsersConfigDirectory ?? Platform.SystemConfigDirectory; systemWideConfigFile = Path.Combine(systemWideConfigDirectory, ConfigFileName); + legacySystemWideConfigFile = Path.Combine(Utils.DefaultPowerShellAppBase, ConfigFileName); // Sets the per-user configuration directory // Note: This directory may or may not exist depending upon the execution scenario. // Writes will attempt to create the directory if it does not already exist. - perUserConfigDirectory = Platform.ConfigDirectory; + perUserConfigDirectory = Platform.UserConfigDirectory; if (!string.IsNullOrEmpty(perUserConfigDirectory)) { perUserConfigFile = Path.Combine(perUserConfigDirectory, ConfigFileName); @@ -106,6 +113,35 @@ namespace System.Management.Automation.Configuration return (scope == ConfigScope.CurrentUser) ? perUserConfigFile : systemWideConfigFile; } + private string GetConfigFilePathForRead(ConfigScope scope) + { + return (scope == ConfigScope.CurrentUser) ? perUserConfigFile : GetEffectiveSystemWideConfigFile(); + } + + private string GetEffectiveSystemWideConfigFile() + { + if (systemConfigOverridden) + { + return systemWideConfigFile; + } + + if (File.Exists(systemWideConfigFile)) + { + return systemWideConfigFile; + } + + if (!string.IsNullOrEmpty(legacySystemWideConfigFile) && File.Exists(legacySystemWideConfigFile)) + { + return legacySystemWideConfigFile; + } + + return systemWideConfigFile; + } + + internal string AllUsersConfigFilePath => systemWideConfigFile; + + internal string CurrentUserConfigFilePath => perUserConfigFile; + /// /// Sets the system wide configuration file path. /// @@ -124,6 +160,7 @@ namespace System.Management.Automation.Configuration FileInfo info = new FileInfo(value); systemWideConfigFile = info.FullName; systemWideConfigDirectory = info.Directory.FullName; + systemConfigOverridden = true; } /// @@ -389,7 +426,7 @@ namespace System.Management.Automation.Configuration /// The default value to return if the key is not present. private T ReadValueFromFile(ConfigScope scope, string key, T defaultValue = default) { - string fileName = GetConfigFilePath(scope); + string fileName = GetConfigFilePathForRead(scope); if (string.IsNullOrEmpty(fileName)) { return defaultValue; @@ -579,6 +616,10 @@ namespace System.Management.Automation.Configuration { Directory.CreateDirectory(perUserConfigDirectory); } + else if (scope == ConfigScope.AllUsers && !Directory.Exists(systemWideConfigDirectory)) + { + Directory.CreateDirectory(systemWideConfigDirectory); + } UpdateValueInFile(scope, key, value, true); } diff --git a/src/System.Management.Automation/engine/PSConfigurationCommand.cs b/src/System.Management.Automation/engine/PSConfigurationCommand.cs new file mode 100644 index 0000000000..4f2804e6e1 --- /dev/null +++ b/src/System.Management.Automation/engine/PSConfigurationCommand.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using System.Management.Automation.Configuration; + +namespace Microsoft.PowerShell.Commands +{ + /// + /// Represents the configuration file location for a PowerShell configuration scope. + /// + public sealed class PowerShellConfigurationInfo + { + /// + /// Gets the configuration scope. + /// + public ConfigScope Scope { get; internal set; } + + /// + /// Gets the file path of the configuration file. + /// + public string Path { get; internal set; } + } + + /// + /// Implements the Get-PowerShellConfiguration cmdlet. + /// Returns the configuration file paths for AllUsers and/or CurrentUser scope. + /// + [Cmdlet(VerbsCommon.Get, "PowerShellConfiguration", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2296500")] + [OutputType(typeof(PowerShellConfigurationInfo))] + public sealed class GetPowerShellConfigurationCommand : PSCmdlet + { + /// + /// Gets or sets the configuration scope to retrieve. + /// When not specified, both AllUsers and CurrentUser configurations are returned. + /// + [Parameter(Position = 0)] + public ConfigScope? Scope { get; set; } + + /// + /// EndProcessing method. + /// + protected override void EndProcessing() + { + if (Scope.HasValue) + { + WriteObject(BuildConfigInfo(Scope.Value)); + } + else + { + WriteObject(BuildConfigInfo(ConfigScope.AllUsers)); + WriteObject(BuildConfigInfo(ConfigScope.CurrentUser)); + } + } + + private static PowerShellConfigurationInfo BuildConfigInfo(ConfigScope scope) + { + PowerShellConfig config = PowerShellConfig.Instance; + + return new PowerShellConfigurationInfo + { + Scope = scope, + Path = scope == ConfigScope.AllUsers ? config.AllUsersConfigFilePath : config.CurrentUserConfigFilePath, + }; + } + } +} diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 0de9fe0d5c..0914f857c6 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -1677,6 +1677,9 @@ namespace System.Management.Automation.Internal // Test out smaller connection buffer size when calling WNetGetConnection. internal static int WNetGetConnectionBufferSize = -1; + // Override the AllUsers config directory for testing config path fallback logic. + internal static string TestAllUsersConfigDirectory; + /// This member is used for internal test purposes. public static void SetTestHook(string property, object value) { diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 8e5d30be71..36faee89cf 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -143,7 +143,7 @@ namespace System.Management.Automation if (forCurrentUser) { - basePath = Platform.ConfigDirectory; + basePath = Platform.UserConfigDirectory; } else { diff --git a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 index 7a757e6eac..b5ef6c9222 100644 --- a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 +++ b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 @@ -312,6 +312,7 @@ Describe "Verify aliases and cmdlets" -Tags "CI" { "Cmdlet", "Get-EventSubscriber", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-ExecutionPolicy", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-ExperimentalFeature", "", $( $CoreWindows -or $CoreUnix), "", "", "None" +"Cmdlet", "Get-PowerShellConfiguration", "", $( $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-FileHash", "", $( $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-FormatData", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-Help", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" diff --git a/test/powershell/engine/ExperimentalFeature/EnableDisable-ExperimentalFeature.Tests.ps1 b/test/powershell/engine/ExperimentalFeature/EnableDisable-ExperimentalFeature.Tests.ps1 index a0db58da8e..c61b4ebb34 100644 --- a/test/powershell/engine/ExperimentalFeature/EnableDisable-ExperimentalFeature.Tests.ps1 +++ b/test/powershell/engine/ExperimentalFeature/EnableDisable-ExperimentalFeature.Tests.ps1 @@ -7,7 +7,12 @@ Describe "Enable-ExperimentalFeature and Disable-ExperimentalFeature tests" -tag BeforeAll { $pwsh = "$PSHOME/pwsh" - $systemConfigPath = "$PSHOME/powershell.config.json" + if ($IsWindows) { + $systemConfigPath = Join-Path $env:ProgramData "Microsoft\PowerShell\powershell.config.json" + } + else { + $systemConfigPath = "/etc/powershell/powershell.config.json" + } if ($IsWindows) { $userConfigPath = "~/Documents/powershell/powershell.config.json" } @@ -15,6 +20,11 @@ Describe "Enable-ExperimentalFeature and Disable-ExperimentalFeature tests" -tag $userConfigPath = "~/.config/powershell/powershell.config.json" } + $systemConfigDir = Split-Path $systemConfigPath + if (!(Test-Path $systemConfigDir)) { + $null = New-Item -ItemType Directory -Path $systemConfigDir -Force -ErrorAction SilentlyContinue + } + $systemConfigExists = $false if (Test-Path $systemConfigPath) { $systemConfigExists = $true diff --git a/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 b/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 index 0c6f0d3119..6029532950 100644 --- a/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 +++ b/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 @@ -7,7 +7,12 @@ Describe "Get-ExperimentalFeature Tests" -tags "Feature","RequireAdminOnWindows" BeforeAll { $pwsh = "$PSHOME/pwsh" - $systemConfigPath = "$PSHOME/powershell.config.json" + if ($IsWindows) { + $systemConfigPath = Join-Path $env:ProgramData "Microsoft\PowerShell\powershell.config.json" + } + else { + $systemConfigPath = "/etc/powershell/powershell.config.json" + } if ($IsWindows) { $userConfigPath = "~/Documents/powershell/powershell.config.json" } @@ -15,6 +20,11 @@ Describe "Get-ExperimentalFeature Tests" -tags "Feature","RequireAdminOnWindows" $userConfigPath = "~/.config/powershell/powershell.config.json" } + $systemConfigDir = Split-Path $systemConfigPath + if (!(Test-Path $systemConfigDir)) { + $null = New-Item -ItemType Directory -Path $systemConfigDir -Force -ErrorAction SilentlyContinue + } + $systemConfigExists = $false if (Test-Path $systemConfigPath) { $systemConfigExists = $true diff --git a/test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 b/test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 new file mode 100644 index 0000000000..3eac4efd94 --- /dev/null +++ b/test/powershell/engine/PSConfiguration/Get-PowerShellConfiguration.Tests.ps1 @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "Get-PowerShellConfiguration Tests" -Tags "CI" { + + It "Returns both scopes when no -Scope is specified" { + $configs = Get-PowerShellConfiguration + $configs | Should -HaveCount 2 + $configs[0].Scope | Should -Be "AllUsers" + $configs[1].Scope | Should -Be "CurrentUser" + } + + It "Returns AllUsers scope when -Scope AllUsers is specified" { + $config = Get-PowerShellConfiguration -Scope AllUsers + $config | Should -Not -BeNullOrEmpty + $config.Scope | Should -Be "AllUsers" + } + + It "Returns CurrentUser scope when -Scope CurrentUser is specified" { + $config = Get-PowerShellConfiguration -Scope CurrentUser + $config | Should -Not -BeNullOrEmpty + $config.Scope | Should -Be "CurrentUser" + } + + It "AllUsers path points to platform-specific system config directory" { + $config = Get-PowerShellConfiguration -Scope AllUsers + if ($IsWindows) { + $config.Path | Should -BeLike "*ProgramData*Microsoft*PowerShell*powershell.config.json" + } + else { + $config.Path | Should -Be "/etc/powershell/powershell.config.json" + } + } + + It "CurrentUser path points to user config directory" { + $config = Get-PowerShellConfiguration -Scope CurrentUser + $config.Path | Should -BeLike "*powershell.config.json" + $config.Path | Should -Not -BeLike "*ProgramData*" + if (-not $IsWindows) { + $config.Path | Should -BeLike "*/.config/powershell/*" + } + } + + It "AllUsers path does not point to PSHOME" { + $config = Get-PowerShellConfiguration -Scope AllUsers + $config.Path | Should -Not -BeLike "$PSHOME*" + } + + It "Output type is PowerShellConfigurationInfo" { + $config = Get-PowerShellConfiguration -Scope AllUsers + $config.GetType().Name | Should -Be "PowerShellConfigurationInfo" + } +} diff --git a/test/xUnit/csharp/test_PSConfiguration.cs b/test/xUnit/csharp/test_PSConfiguration.cs index de94107fc6..c775d51ed6 100644 --- a/test/xUnit/csharp/test_PSConfiguration.cs +++ b/test/xUnit/csharp/test_PSConfiguration.cs @@ -38,8 +38,8 @@ namespace PSTests.Sequential public PowerShellPolicyFixture() { - systemWideConfigDirectory = Utils.DefaultPowerShellAppBase; - currentUserConfigDirectory = Platform.ConfigDirectory; + systemWideConfigDirectory = Platform.SystemConfigDirectory; + currentUserConfigDirectory = Platform.UserConfigDirectory; if (!Directory.Exists(currentUserConfigDirectory)) { diff --git a/tools/packaging/packaging.strings.psd1 b/tools/packaging/packaging.strings.psd1 index 0bf14ff0db..fa9127e5ce 100644 --- a/tools/packaging/packaging.strings.psd1 +++ b/tools/packaging/packaging.strings.psd1 @@ -11,6 +11,8 @@ if [ ! -f /etc/shells ] ; then else grep -q "^{0}$" /etc/shells || echo "{0}" >> /etc/shells fi +mkdir -p /etc/powershell +chmod 755 /etc/powershell if [ -f /lib64/libssl.so.1.1 ] ; then ln -f -s /lib64/libssl.so.1.1 {1}/libssl.so.1.0.0 ln -f -s /lib64/libcrypto.so.1.1.1 {1}/libcrypto.so.1.0.0 @@ -39,6 +41,8 @@ set -e case "$1" in (configure) add-shell "{0}" + mkdir -p /etc/powershell + chmod 755 /etc/powershell ;; (abort-upgrade|abort-remove|abort-deconfigure) exit 0 @@ -82,6 +86,8 @@ if [ ! -f /etc/shells ] ; then else grep -q "^{0}$" /etc/shells || echo "{0}" >> /etc/shells fi +mkdir -p /etc/powershell +chmod 755 /etc/powershell '@ MacOSLauncherScript = @'