diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs index 8321cb8b810..840e8fb4d68 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs @@ -188,6 +188,7 @@ internal class CommandLineParameterParser "file", "executionpolicy", "command", + "settingsfile", "help" }; @@ -709,6 +710,36 @@ private void ParseHelper(string[] args) break; } } + + else if (MatchSwitch(switchKey, "settingsfile", "settings") ) + { + ++i; + if (i >= args.Length) + { + WriteCommandLineError( + CommandLineParameterParserStrings.MissingSettingsFileArgument); + break; + } + string configFile = null; + try + { + configFile = NormalizeFilePath(args[i]); + } + catch (Exception ex) + { + string error = string.Format(CultureInfo.CurrentCulture, CommandLineParameterParserStrings.InvalidSettingsFileArgument, args[i], ex.Message); + WriteCommandLineError(error); + break; + } + + if (!System.IO.File.Exists(configFile)) + { + string error = string.Format(CultureInfo.CurrentCulture, CommandLineParameterParserStrings.SettingsFileNotExists, configFile); + WriteCommandLineError(error); + break; + } + PowerShellConfig.Instance.SetSystemConfigFilePath(configFile); + } #if STAMODE // explicit setting of the ApartmentState Not supported on NanoServer else if (MatchSwitch(switchKey, "sta", "s")) @@ -849,6 +880,15 @@ private void ParseExecutionPolicy(string[] args, ref int i, ref string execution executionPolicy = args[i]; } + private static string NormalizeFilePath(string path) + { + // Normalize slashes + path = path.Replace(StringLiterals.AlternatePathSeparator, + StringLiterals.DefaultPathSeparator); + + return Path.GetFullPath(path); + } + private bool ParseFile(string[] args, ref int i, bool noexitSeen) { // Process file execution. We don't need to worry about checking -command @@ -906,10 +946,7 @@ bool TryGetBoolValue(string arg, out bool boolValue) string exceptionMessage = null; try { - // Normalize slashes - _file = args[i].Replace(StringLiterals.AlternatePathSeparator, - StringLiterals.DefaultPathSeparator); - _file = Path.GetFullPath(_file); + _file = NormalizeFilePath(args[i]); } catch (Exception e) { @@ -986,11 +1023,11 @@ bool TryGetBoolValue(string arg, out bool boolValue) string argName = arg.Substring(0, offset); if (TryGetBoolValue(argValue, out bool boolValue)) { - _collectedArgs.Add(new CommandParameter(argName, boolValue)); + _collectedArgs.Add(new CommandParameter(argName, boolValue)); } else { - _collectedArgs.Add(new CommandParameter(argName, argValue)); + _collectedArgs.Add(new CommandParameter(argName, argValue)); } } } diff --git a/src/Microsoft.PowerShell.ConsoleHost/resources/CommandLineParameterParserStrings.resx b/src/Microsoft.PowerShell.ConsoleHost/resources/CommandLineParameterParserStrings.resx index 93b299bc817..d600de01d91 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/resources/CommandLineParameterParserStrings.resx +++ b/src/Microsoft.PowerShell.ConsoleHost/resources/CommandLineParameterParserStrings.resx @@ -186,6 +186,15 @@ Cannot process the command because -Configuration requires an argument that is a remote endpoint configuration name. Specify this argument and try again. + + Cannot process the command because -SettingsFile requires a file path. Supply a path for the SettingsFile parameter and then try the command again. + + + Processing -SettingsFile '{0}' failed: {1}. Specify a valid path for the -SettingsFile parameter. + + + The argument '{0}' passed to the -SettingsFile does not exist. Provide the path to an existing json file as an argument to the -SettingsFile parameter. + Invalid argument '{0}', did you mean: diff --git a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx index 8bef56e8c24..67c74c04317 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx +++ b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx @@ -239,6 +239,17 @@ All parameters are case-insensitive. -WindowStyle | -w Sets the window style to Normal, Minimized, Maximized or Hidden. + +-SettingsFile | -settings + Overrides the system-wide powershell.config.json settings file for the session. + By default, system-wide settings are read from the powershell.config.json + in the $PSHOME directory. + + Note that these settings are not used by the endpoint specified + by the -ConfigurationName argument. + + Example: pwsh -SettingsFile c:\myproject\powershell.config.json + diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index ef01eba36c7..8c60d6735d8 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -27,8 +27,16 @@ internal sealed class PowerShellConfig private static readonly PowerShellConfig s_instance = new PowerShellConfig(); internal static PowerShellConfig Instance => s_instance; - private string psHomeConfigDirectory; - private string appDataConfigDirectory; + // The json file containing system-wide configuration settings. + // When passed as a pwsh command-line option, + // overrides the system wide configuration file. + private string systemWideConfigFile; + private string systemWideConfigDirectory; + + // The json file containing the per-user configuration settings. + private string perUserConfigFile; + private string perUserConfigDirectory; + private const string configFileName = "powershell.config.json"; /// @@ -40,14 +48,40 @@ internal sealed class PowerShellConfig private PowerShellConfig() { - // Sets the system-wide configuration directory - psHomeConfigDirectory = Utils.DefaultPowerShellAppBase; + // Sets the system-wide configuration file. + systemWideConfigDirectory = Utils.DefaultPowerShellAppBase; + systemWideConfigFile = Path.Combine(systemWideConfigDirectory, 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. - appDataConfigDirectory = Utils.GetUserConfigurationDirectory(); + perUserConfigDirectory = Utils.GetUserConfigurationDirectory(); + perUserConfigFile = Path.Combine(perUserConfigDirectory, configFileName); + } + + private string GetConfigFilePath(ConfigScope scope) + { + return (scope == ConfigScope.CurrentUser) ? perUserConfigFile : systemWideConfigFile; + } + + /// + /// Sets the system wide configuration file path. + /// + /// A fully qualified path to the system wide configuration file. + /// is a null reference or the associated file does not exist. + /// + /// This method is for use when processing the -SettingsFile configuration setting and should not be used for any other purpose. + /// + internal void SetSystemConfigFilePath(string value) + { + if (!string.IsNullOrEmpty(value) && !File.Exists(value)) + { + throw new FileNotFoundException(value); + } + FileInfo info = new FileInfo(value); + systemWideConfigFile = info.FullName; + systemWideConfigDirectory = info.Directory.FullName; } /// @@ -59,10 +93,7 @@ private PowerShellConfig() /// Value if found, null otherwise. The behavior matches ModuleIntrinsics.GetExpandedEnvironmentVariable(). internal string GetModulePath(ConfigScope scope) { - string scopeDirectory = scope == ConfigScope.SystemWide ? psHomeConfigDirectory : appDataConfigDirectory; - string fileName = Path.Combine(scopeDirectory, configFileName); - - string modulePath = ReadValueFromFile(fileName, Constants.PSModulePathEnvVar); + string modulePath = ReadValueFromFile(scope, Constants.PSModulePathEnvVar); if (!string.IsNullOrEmpty(modulePath)) { modulePath = Environment.ExpandEnvironmentVariables(modulePath); @@ -87,17 +118,9 @@ internal string GetModulePath(ConfigScope scope) internal string GetExecutionPolicy(ConfigScope scope, string shellId) { string execPolicy = null; - string scopeDirectory = psHomeConfigDirectory; - - // Defaults to system wide. - if(ConfigScope.CurrentUser == scope) - { - scopeDirectory = appDataConfigDirectory; - } - string fileName = Path.Combine(scopeDirectory, configFileName); string valueName = string.Concat(shellId, ":", "ExecutionPolicy"); - string rawExecPolicy = ReadValueFromFile(fileName, valueName); + string rawExecPolicy = ReadValueFromFile(scope, valueName); if (!String.IsNullOrEmpty(rawExecPolicy)) { @@ -108,23 +131,12 @@ internal string GetExecutionPolicy(ConfigScope scope, string shellId) internal void RemoveExecutionPolicy(ConfigScope scope, string shellId) { - string scopeDirectory = psHomeConfigDirectory; - - // Defaults to system wide. - if (ConfigScope.CurrentUser == scope) - { - scopeDirectory = appDataConfigDirectory; - } - - string fileName = Path.Combine(scopeDirectory, configFileName); string valueName = string.Concat(shellId, ":", "ExecutionPolicy"); - RemoveValueFromFile(fileName, valueName); + RemoveValueFromFile(scope, valueName); } internal void SetExecutionPolicy(ConfigScope scope, string shellId, string executionPolicy) { - string scopeDirectory = psHomeConfigDirectory; - // Defaults to system wide. if (ConfigScope.CurrentUser == scope) { @@ -132,13 +144,10 @@ internal void SetExecutionPolicy(ConfigScope scope, string shellId, string execu // host for display to the user. // CreateDirectory will succeed if the directory already exists // so there is no reason to check Directory.Exists(). - Directory.CreateDirectory(appDataConfigDirectory); - scopeDirectory = appDataConfigDirectory; + Directory.CreateDirectory(perUserConfigDirectory); } - - string fileName = Path.Combine(scopeDirectory, configFileName); string valueName = string.Concat(shellId, ":", "ExecutionPolicy"); - WriteValueToFile(fileName, valueName, executionPolicy); + WriteValueToFile(scope, valueName, executionPolicy); } /// @@ -153,14 +162,12 @@ internal void SetExecutionPolicy(ConfigScope scope, string shellId, string execu /// Whether console prompting should happen. If the value cannot be read it defaults to false. internal bool GetConsolePrompting() { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - return ReadValueFromFile(fileName, "ConsolePrompting"); + return ReadValueFromFile(ConfigScope.SystemWide, "ConsolePrompting"); } internal void SetConsolePrompting(bool shouldPrompt) { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - WriteValueToFile(fileName, "ConsolePrompting", shouldPrompt); + WriteValueToFile(ConfigScope.SystemWide, "ConsolePrompting", shouldPrompt); } /// @@ -175,14 +182,12 @@ internal void SetConsolePrompting(bool shouldPrompt) /// Boolean indicating whether Update-Help should prompt. If the value cannot be read, it defaults to false. internal bool GetDisablePromptToUpdateHelp() { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - return ReadValueFromFile(fileName, "DisablePromptToUpdateHelp"); + return ReadValueFromFile(ConfigScope.SystemWide, "DisablePromptToUpdateHelp"); } internal void SetDisablePromptToUpdateHelp(bool prompt) { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - WriteValueToFile(fileName, "DisablePromptToUpdateHelp", prompt); + WriteValueToFile(ConfigScope.SystemWide, "DisablePromptToUpdateHelp", prompt); } /// @@ -190,9 +195,7 @@ internal void SetDisablePromptToUpdateHelp(bool prompt) /// internal PowerShellPolicies GetPowerShellPolicies(ConfigScope scope) { - string scopeDirectory = (scope == ConfigScope.SystemWide) ? psHomeConfigDirectory : appDataConfigDirectory; - string fileName = Path.Combine(scopeDirectory, configFileName); - return ReadValueFromFile(fileName, nameof(PowerShellPolicies)); + return ReadValueFromFile(scope, nameof(PowerShellPolicies)); } #if UNIX @@ -204,8 +207,7 @@ internal PowerShellPolicies GetPowerShellPolicies(ConfigScope scope) /// internal string GetSysLogIdentity() { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - string identity = ReadValueFromFile(fileName, "LogIdentity"); + string identity = ReadValueFromFile(ConfigScope.SystemWide, "LogIdentity"); if (string.IsNullOrEmpty(identity) || identity.Equals(LogDefaultValue, StringComparison.OrdinalIgnoreCase)) @@ -223,8 +225,7 @@ internal string GetSysLogIdentity() /// internal PSLevel GetLogLevel() { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - string levelName = ReadValueFromFile(fileName, "LogLevel"); + string levelName = ReadValueFromFile(ConfigScope.SystemWide, "LogLevel"); PSLevel level; if (string.IsNullOrEmpty(levelName) || @@ -256,8 +257,7 @@ internal PSLevel GetLogLevel() /// internal PSChannel GetLogChannels() { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - string values = ReadValueFromFile(fileName, "LogChannels"); + string values = ReadValueFromFile(ConfigScope.SystemWide, "LogChannels"); PSChannel result = 0; if (!string.IsNullOrEmpty(values)) @@ -299,8 +299,7 @@ internal PSChannel GetLogChannels() /// internal PSKeyword GetLogKeywords() { - string fileName = Path.Combine(psHomeConfigDirectory, configFileName); - string values = ReadValueFromFile(fileName, "LogKeywords"); + string values = ReadValueFromFile(ConfigScope.SystemWide, "LogKeywords"); PSKeyword result = 0; if (!string.IsNullOrEmpty(values)) @@ -331,10 +330,19 @@ internal PSKeyword GetLogKeywords() return result; } #endif // UNIX - - private T ReadValueFromFile(string fileName, string key, T defaultValue = default(T), + + /// + /// Read a value from the configuration file. + /// + /// The type of the value + /// The ConfigScope of the configuration file to update. + /// The string key of the value. + /// The default value to return if the key is not present. + /// + private T ReadValueFromFile(ConfigScope scope, string key, T defaultValue = default(T), Func readImpl = null) { + string fileName = GetConfigFilePath(scope); if (!File.Exists(fileName)) { return defaultValue; } // Open file for reading, but allow multiple readers @@ -363,15 +371,16 @@ internal PSKeyword GetLogKeywords() } /// - /// TODO: Should this return success fail or throw? + /// Update a value in the configuration file. /// - /// - /// - /// - /// + /// The type of the value + /// The ConfigScope of the configuration file to update. + /// The string key of the value. + /// The value to set. /// Whether the key-value pair should be added to or removed from the file - private void UpdateValueInFile(string fileName, string key, T value, bool addValue) + private void UpdateValueInFile(ConfigScope scope, string key, T value, bool addValue) { + string fileName = GetConfigFilePath(scope); fileLock.EnterWriteLock(); try { @@ -469,27 +478,28 @@ private void UpdateValueInFile(string fileName, string key, T value, bool add /// /// TODO: Should this return success, fail, or throw? /// - /// - /// - /// - /// - private void WriteValueToFile(string fileName, string key, T value) + /// The type of value to write. + /// The ConfigScope of the file to update. + /// The string key of the value. + /// The value to write. + private void WriteValueToFile(ConfigScope scope, string key, T value) { - UpdateValueInFile(fileName, key, value, true); + UpdateValueInFile(scope, key, value, true); } /// /// TODO: Should this return success, fail, or throw? /// - /// - /// - /// - private void RemoveValueFromFile(string fileName, string key) + /// The type of value to remove. + /// The ConfigScope of the file to update. + /// The string key of the value. + private void RemoveValueFromFile(ConfigScope scope, string key) { + string fileName = GetConfigFilePath(scope); // Optimization: If the file doesn't exist, there is nothing to remove if (File.Exists(fileName)) { - UpdateValueInFile(fileName, key, default(T), false); + UpdateValueInFile(scope, key, default(T), false); } } } diff --git a/test/powershell/Host/ConsoleHost.Tests.ps1 b/test/powershell/Host/ConsoleHost.Tests.ps1 index f6b08ca4dbc..70bbc0006a8 100644 --- a/test/powershell/Host/ConsoleHost.Tests.ps1 +++ b/test/powershell/Host/ConsoleHost.Tests.ps1 @@ -249,6 +249,51 @@ Describe "ConsoleHost unit tests" -tags "Feature" { } } + Context "-SettingsFile Commandline switch" { + + BeforeAll { + $CustomSettingsFile = Join-Path -Path $TestDrive -ChildPath 'Powershell.test.json' + $DefaultExecutionPolicy = 'RemoteSigned' + } + BeforeEach { + # reset the content of the settings file to a known state. + Set-Content -Path $CustomSettingsfile -Value "{`"Microsoft.PowerShell:ExecutionPolicy`":`"$DefaultExecutionPolicy`"}" -ErrorAction Stop + } + + # NOTE: The -settingsFile command-line option only reads settings for the local machine. As a result, the tests that use Set/Get-ExecutionPolicy + # must use an explicit scope of LocalMachine to ensure the setting is written to the expected file. + + It "Verifies PowerShell reads from the custom -settingsFile" { + $actualValue = & $powershell -NoProfile -SettingsFile $CustomSettingsFile -Command {(Get-ExecutionPolicy -Scope LocalMachine).ToString()} + $actualValue | Should Be $DefaultExecutionPolicy + } + + It "Verifies PowerShell writes to the custom -settingsFile" { + $expectedValue = 'AllSigned' + + # Update the execution policy; this should update the settings file. + & $powershell -NoProfile -SettingsFile $CustomSettingsFile -Command {Set-ExecutionPolicy -ExecutionPolicy AllSigned -Scope LocalMachine } + + # ensure the setting was written to the settings file. + $content = (Get-Content -Path $CustomSettingsFile | ConvertFrom-Json) + $content.'Microsoft.PowerShell:ExecutionPolicy' | Should Be $expectedValue + + # ensure the setting is applied on next run + $actualValue = & $powershell -NoProfile -SettingsFile $CustomSettingsFile -Command {(Get-ExecutionPolicy -Scope LocalMachine).ToString()} + $actualValue | Should Be $expectedValue + } + + It "Verify PowerShell removes a setting from the custom -settingsFile" { + # Remove the LocalMachine execution policy; this should update the settings file. + & $powershell -NoProfile -SettingsFile $CustomSettingsFile -Command {Set-ExecutionPolicy -ExecutionPolicy Undefined -Scope LocalMachine } + + # ensure the setting was removed from the settings file. + $content = (Get-Content -Path $CustomSettingsFile | ConvertFrom-Json) + $content.'Microsoft.PowerShell:ExecutionPolicy' | Should Be $null + } + + } + Context "Pipe to/from powershell" { $p = [PSCustomObject]@{X=10;Y=20}