diff --git a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs index dc5db5f2c48..7a23fef96a6 100644 --- a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs +++ b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs @@ -165,11 +165,12 @@ public static bool IsStaSupported // 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 DefaultPSContentDirectory = Path.Combine(Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA), "PowerShell"); #else // Gets the location for cache and config folders. internal static readonly string CacheDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\Microsoft\PowerShell"; internal static readonly string ConfigDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; - + internal static readonly string DefaultPSContentDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\PowerShell"; private static readonly Lazy _isStaSupported = new Lazy(() => { int result = Interop.Windows.CoInitializeEx(IntPtr.Zero, Interop.Windows.COINIT_APARTMENTTHREADED); diff --git a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs new file mode 100644 index 00000000000..894aa7af1d1 --- /dev/null +++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Configuration; +using System.Management.Automation.Internal; + +namespace Microsoft.PowerShell.Commands +{ + /// + /// Implements Get-PSContentPath cmdlet. + /// + /// TODO:: Add helpURI + [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=")] + [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] + public class GetPSContentPathCommand : PSCmdlet + { + /// + /// ProcessRecord method of this cmdlet. + /// + protected override void ProcessRecord() + { + try + { + var psContentPath = Utils.GetPSContentPath(); + WriteObject(psContentPath); + } + catch (Exception ex) + { + WriteError(new ErrorRecord( + ex, + "GetPSContentPathFailed", + ErrorCategory.ReadError, + null)); + } + } + } + + /// + /// Implements Set-PSContentPath cmdlet. + /// + /// TODO:: Add helpURI + [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=")] + [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] + public class SetPSContentPathCommand : PSCmdlet + { + /// + /// Gets or sets the PSContentPath to configure. + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// ProcessRecord method of this cmdlet. + /// + protected override void ProcessRecord() + { + // Validate the path before processing + if (!ValidatePath(Path)) + { + return; // Error already written in ValidatePath + } + + if (ShouldProcess($"PSContentPath = {Path}", "Set PSContentPath")) + { + try + { + PowerShellConfig.Instance.SetPSContentPath(Path); + WriteVerbose($"Successfully set PSContentPath to '{Path}'"); + WriteWarning("PSContentPath changes will take effect after restarting PowerShell."); + } + catch (Exception ex) + { + WriteError(new ErrorRecord( + ex, + "SetPSContentPathFailed", + ErrorCategory.WriteError, + Path)); + } + } + } + + /// + /// Validates that the provided path is a valid directory path. + /// + /// The path to validate. + /// True if the path is valid, false otherwise. + private bool ValidatePath(string path) + { + try + { + // Expand environment variables if present + string expandedPath = Environment.ExpandEnvironmentVariables(path); + + // Check if the path contains invalid characters using PowerShell's existing utility + if (PathUtils.ContainsInvalidPathChars(expandedPath)) + { + WriteError(new ErrorRecord( + new ArgumentException($"The path '{path}' contains invalid characters."), + "InvalidPathCharacters", + ErrorCategory.InvalidArgument, + path)); + return false; + } + + // Check if the path is rooted (absolute path) + if (!System.IO.Path.IsPathRooted(expandedPath)) + { + WriteError(new ErrorRecord( + new ArgumentException($"The path '{path}' must be an absolute path."), + "RelativePathNotAllowed", + ErrorCategory.InvalidArgument, + path)); + return false; + } + + // Try to get the full path to validate format + string fullPath = System.IO.Path.GetFullPath(expandedPath); + + // Warn if the directory doesn't exist, but don't fail + if (!Directory.Exists(fullPath)) + { + WriteWarning($"The directory '{fullPath}' does not exist. It will be created when needed."); + } + + return true; + } + catch (ArgumentException ex) + { + WriteError(new ErrorRecord( + ex, + "InvalidPathFormat", + ErrorCategory.InvalidArgument, + path)); + return false; + } + catch (System.Security.SecurityException ex) + { + WriteError(new ErrorRecord( + ex, + "PathAccessDenied", + ErrorCategory.PermissionDenied, + path)); + return false; + } + catch (NotSupportedException ex) + { + WriteError(new ErrorRecord( + ex, + "PathNotSupported", + ErrorCategory.InvalidArgument, + path)); + return false; + } + catch (PathTooLongException ex) + { + WriteError(new ErrorRecord( + ex, + "PathTooLong", + ErrorCategory.InvalidArgument, + path)); + return false; + } + } + } +} diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index dd26e609641..f24faec0ac9 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -25,6 +25,7 @@ public class ExperimentalFeature internal const string PSNativeWindowsTildeExpansion = nameof(PSNativeWindowsTildeExpansion); internal const string PSRedirectToVariable = "PSRedirectToVariable"; internal const string PSSerializeJSONLongEnumAsNumber = nameof(PSSerializeJSONLongEnumAsNumber); + internal const string PSContentPath = "PSContentPath"; #endregion @@ -124,7 +125,10 @@ static ExperimentalFeature() description: "Add support for redirecting to the variable drive"), new ExperimentalFeature( name: PSSerializeJSONLongEnumAsNumber, - description: "Serialize enums based on long or ulong as an numeric value rather than the string representation when using ConvertTo-Json." + description: "Serialize enums based on long or ulong as an numeric value rather than the string representation when using ConvertTo-Json."), + new ExperimentalFeature( + name: PSContentPath, + description: "Moves PS content to the new default location in LocalAppData/PowerShell and allows users to specify the content path." ) }; diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index 84f513d8450..c1c93b7a81f 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -5468,6 +5468,7 @@ private static void InitializeCoreCmdletsAndProviders( { "Get-History", new SessionStateCmdletEntry("Get-History", typeof(GetHistoryCommand), helpFile) }, { "Get-Job", new SessionStateCmdletEntry("Get-Job", typeof(GetJobCommand), helpFile) }, { "Get-Module", new SessionStateCmdletEntry("Get-Module", typeof(GetModuleCommand), helpFile) }, + { "Get-PSContentPath", new SessionStateCmdletEntry("Get-PSContentPath", typeof(GetPSContentPathCommand), helpFile) }, { "Get-PSHostProcessInfo", new SessionStateCmdletEntry("Get-PSHostProcessInfo", typeof(GetPSHostProcessInfoCommand), helpFile) }, { "Get-PSSession", new SessionStateCmdletEntry("Get-PSSession", typeof(GetPSSessionCommand), helpFile) }, { "Import-Module", new SessionStateCmdletEntry("Import-Module", typeof(ImportModuleCommand), helpFile) }, @@ -5489,6 +5490,7 @@ private static void InitializeCoreCmdletsAndProviders( { "Remove-Module", new SessionStateCmdletEntry("Remove-Module", typeof(RemoveModuleCommand), helpFile) }, { "Remove-PSSession", new SessionStateCmdletEntry("Remove-PSSession", typeof(RemovePSSessionCommand), helpFile) }, { "Save-Help", new SessionStateCmdletEntry("Save-Help", typeof(SaveHelpCommand), helpFile) }, + { "Set-PSContentPath", new SessionStateCmdletEntry("Set-PSContentPath", typeof(SetPSContentPathCommand), helpFile) }, { "Set-PSDebug", new SessionStateCmdletEntry("Set-PSDebug", typeof(SetPSDebugCommand), helpFile) }, { "Set-StrictMode", new SessionStateCmdletEntry("Set-StrictMode", typeof(SetStrictModeCommand), helpFile) }, { "Start-Job", new SessionStateCmdletEntry("Start-Job", typeof(StartJobCommand), helpFile) }, diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index b687e502763..6e344352493 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -21,6 +21,7 @@ namespace System.Management.Automation internal static class Constants { public const string PSModulePathEnvVar = "PSModulePath"; + public const string PSUserContentPathEnvVar = "PSUserContentPath"; } /// @@ -490,7 +491,7 @@ internal static bool IsModuleMatchingConstraints( /// The required version of the expected module. /// The minimum required version of the expected module. /// The maximum required version of the expected module. - /// True if the module info object matches all given constraints, false otherwise. + /// True if the module info object matches all the constraints on the module specification, false otherwise. internal static bool IsModuleMatchingConstraints( out ModuleMatchFailure matchFailureReason, PSModuleInfo moduleInfo, @@ -964,12 +965,7 @@ internal static string GetModuleName(string path) /// Personal module path. internal static string GetPersonalModulePath() { -#if UNIX - return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); -#else - string myDocumentsPath = InternalTestHooks.SetMyDocumentsSpecialFolderToBlank ? string.Empty : Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - return string.IsNullOrEmpty(myDocumentsPath) ? null : Path.Combine(myDocumentsPath, Utils.ModuleDirectory); -#endif + return Path.Combine(Utils.GetPSContentPath(), "Modules"); } /// @@ -1366,9 +1362,14 @@ private static string SetModulePath() } #endif string allUsersModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers); - string personalModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser); + string personalModulePath = Utils.GetPSContentPath(true) ?? string.Empty; string newModulePathString = GetModulePath(currentModulePath, allUsersModulePath, personalModulePath); + if (!string.IsNullOrEmpty(personalModulePath)) + { + Environment.SetEnvironmentVariable(Constants.PSUserContentPathEnvVar, personalModulePath); + } + if (!string.IsNullOrEmpty(newModulePathString)) { Environment.SetEnvironmentVariable(Constants.PSModulePathEnvVar, newModulePathString); diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index e321423f768..f4992e219a8 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -62,8 +62,11 @@ internal sealed class PowerShellConfig private string systemWideConfigDirectory; // The json file containing the per-user configuration settings. - private readonly string perUserConfigFile; - private readonly string perUserConfigDirectory; + private string perUserConfigFile; + private string perUserConfigDirectory; + + // Flag to track if migration has been checked + private bool migrationChecked = false; // Note: JObject and JsonSerializer are thread safe. // Root Json objects corresponding to the configuration file for 'AllUsers' and 'CurrentUser' respectively. @@ -141,6 +144,36 @@ internal string GetModulePath(ConfigScope scope) return modulePath; } + /// + /// Gets the PSContentPath from the configuration file. + /// + /// The configured PSContentPath if found, null otherwise. + internal string GetPSContentPath() + { + string contentPath = ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); + if (!string.IsNullOrEmpty(contentPath)) + { + contentPath = Environment.ExpandEnvironmentVariables(contentPath); + } + return contentPath; + } + + /// + /// Sets the PSContentPath in the configuration file. + /// + /// The path to set as PSContentPath. + internal void SetPSContentPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + RemoveValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); + } + else + { + WriteValueToFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar, path); + } + } + /// /// Existing Key = HKCU and HKLM\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell /// Proposed value = Existing default execution policy if not already specified @@ -386,6 +419,9 @@ internal PSKeyword GetLogKeywords() /// The default value to return if the key is not present. private T ReadValueFromFile(ConfigScope scope, string key, T defaultValue = default) { + // Check for PSContentPath migration on first config access + CheckForMigrationOnFirstAccess(); + string fileName = GetConfigFilePath(scope); JObject configData = configRoots[(int)scope]; @@ -590,6 +626,101 @@ private void RemoveValueFromFile(ConfigScope scope, string key) UpdateValueInFile(scope, key, default(T), false); } } + + internal void MigrateUserConfig(string oldPath, string newPath) + { + try + { + // Ensure new directory exists + Directory.CreateDirectory(Path.GetDirectoryName(newPath)); + + // Copy the config file + File.Copy(oldPath, newPath); + + perUserConfigDirectory = Path.GetDirectoryName(newPath); + perUserConfigFile = newPath; + } + catch (Exception) + { + // Migration failed, but don't break the system + // Log the error if logging is available + } + } + + /// + /// Checks for migration on first configuration access to avoid circular dependencies. + /// + private void CheckForMigrationOnFirstAccess() + { + if (migrationChecked) + { + return; + } + + migrationChecked = true; + + try + { + // Only perform migration if PSContentPath experimental feature is enabled + // This is safe to call here because ExperimentalFeature will be initialized by now + if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + return; + } + + CheckAndPerformPSContentPathMigration(); + } + catch + { + // Migration is best-effort; don't fail config access if it fails + } + } + + /// + /// Checks if PSContentPath migration is needed and performs it if the experimental feature is enabled. + /// + private void CheckAndPerformPSContentPathMigration() + { + try + { + string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); + string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); + + // If paths are the same, no migration needed + if (string.Equals(oldConfigFile, newConfigFile, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Always update to use the new location when experimental feature is enabled + string newConfigDir = Path.GetDirectoryName(newConfigFile); + + // If migration was already completed (new config exists), just update paths + if (File.Exists(newConfigFile)) + { + perUserConfigDirectory = newConfigDir; + perUserConfigFile = newConfigFile; + return; + } + + // If old config exists and needs migration, perform the migration + if (File.Exists(oldConfigFile)) + { + MigrateUserConfig(oldConfigFile, newConfigFile); + } + else + { + // No existing config, but still use new location going forward + perUserConfigDirectory = newConfigDir; + perUserConfigFile = newConfigFile; + } + } + catch + { + // Migration is best-effort; don't fail PowerShell startup if it fails + // The user can manually copy the file if needed + } + } } #region GroupPolicy Configs diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 2a2091af31a..48be44e2085 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -17,6 +17,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Security; +using System.Text.Json; #if !UNIX using System.Security.Principal; #endif @@ -706,6 +707,41 @@ internal static bool IsValidPSEditionValue(string editionValue) /// internal static readonly string ModuleDirectory = Path.Combine(ProductNameForDirectory, "Modules"); + /// + /// Gets the PSContent path from PowerShell.config.json or falls back to platform defaults. + /// + /// The PSContent directory path + internal static string GetPSContentPath(bool setModulePath = false) + { + try + { + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + return PowerShellConfig.Instance.GetPSContentPath(); + } + } + catch (Exception) + { + // On Startup there is a cirular dependency between PowerShellConfig and Utils. + } + + // Fall back to platform defaults + if (setModulePath) + { + return PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) ?? +#if UNIX + Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; +#endif + } +#if UNIX + return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + return Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; +#endif + } + internal static readonly ConfigScope[] SystemWideOnlyConfig = new[] { ConfigScope.AllUsers }; internal static readonly ConfigScope[] CurrentUserOnlyConfig = new[] { ConfigScope.CurrentUser }; internal static readonly ConfigScope[] SystemWideThenCurrentUserConfig = new[] { ConfigScope.AllUsers, ConfigScope.CurrentUser }; diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 003625791b1..9aceca17d9d 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -203,7 +203,7 @@ internal static string GetFullProfileFileName(string shellId, bool forCurrentUse if (forCurrentUser) { - basePath = Platform.ConfigDirectory; + basePath = Utils.GetPSContentPath(); } else { diff --git a/src/System.Management.Automation/help/HelpUtils.cs b/src/System.Management.Automation/help/HelpUtils.cs index ab4a39f362a..9316fd8e6ee 100644 --- a/src/System.Management.Automation/help/HelpUtils.cs +++ b/src/System.Management.Automation/help/HelpUtils.cs @@ -20,12 +20,7 @@ internal static string GetUserHomeHelpSearchPath() { if (userHomeHelpPath == null) { -#if UNIX - var userModuleFolder = Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); - string userScopeRootPath = System.IO.Path.GetDirectoryName(userModuleFolder); -#else - string userScopeRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell"); -#endif + string userScopeRootPath = Utils.GetPSContentPath(); userHomeHelpPath = Path.Combine(userScopeRootPath, "Help"); }