Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bfe8963
Add per-machine data store (appdata:MachineFolder) to MSIX manifest
Jun 24, 2026
63d9e69
Relocate AllUsers paths to per-machine data store when packaged
Jun 24, 2026
1dcbef3
Guard per-machine data store fields with #if !UNIX to fix CS0169 buil…
Jun 29, 2026
079e746
Remove redundant per-machine store Modules path from PSModulePath
Jul 9, 2026
6a1e674
Defer AllUsers updatable-help redirection to a separate PR
Jul 9, 2026
f054049
Rework MSIX system-wide config into a distinct machine-folder scope
Jul 13, 2026
1939cfb
Grant Built-in Users read on the MSIX machine-folder data store
Jul 13, 2026
2fd862f
Fix experimental-feature overrides with explicit enable/disable sets
Jul 13, 2026
a6108cc
Defer experimental features from MachineFolder; restore legacy scope …
Jul 20, 2026
76b45ed
Scope PR to config merging: drop profile relocation; union WinCompat …
Jul 20, 2026
d038853
Keep legacy execution-policy scope order; document policy vs preference
Jul 20, 2026
cdfe9b7
Clarify that config-based execution policy is a preference, not a policy
Jul 20, 2026
844ee34
Extract config merge helpers: MergePolicyList and MergePreferenceValue
Jul 20, 2026
a0088ce
Move config merge rationale into the merge helpers
Jul 20, 2026
401609d
Add xUnit tests for MachineFolder WinCompat config merge
Jul 20, 2026
f1f6c6c
Fix CodeFactor style issues in MachineFolder WinCompat tests
pwshBot Jul 21, 2026
bfd0cf9
Reorder test helpers so internal member precedes private members
pwshBot Jul 21, 2026
eb40468
Read PackageRepositoryRoot from 64-bit registry view
Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions assets/AppxManifest.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>

<Package IgnorableNamespaces="uap mp rescap desktop6"
<Package IgnorableNamespaces="uap mp rescap desktop6 appdata"
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:appdata="http://schemas.microsoft.com/appx/manifest/appdata/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities">

<Identity Name="Microsoft.$PRODUCTNAME$" ProcessorArchitecture="$ARCH$" Publisher="$PUBLISHER$" Version="$VERSION$" />
Expand All @@ -17,10 +18,13 @@
<Logo>assets\StoreLogo.png</Logo>
<desktop6:RegistryWriteVirtualization>disabled</desktop6:RegistryWriteVirtualization>
<desktop6:FileSystemWriteVirtualization>disabled</desktop6:FileSystemWriteVirtualization>
<appdata:ApplicationData>
<appdata:MachineFolder Sddl="(A;OICI;GA;;;BA)(A;OICI;GR;;;BU)" />

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First set of sddl's grants built in admin to write to all files and directories
Second set of sddl's grants built in users to read all files and directories

</appdata:ApplicationData>
</Properties>

<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.18362.0" />
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0" />
</Dependencies>

<Resources>
Expand Down
169 changes: 154 additions & 15 deletions src/System.Management.Automation/engine/PSConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ public enum ConfigScope
/// <summary>
/// CurrentUser configuration applies to the current user.
/// </summary>
CurrentUser = 1
CurrentUser = 1,

/// <summary>
/// MachineFolder configuration applies to all users and is stored in the writable per-machine
/// data store of a packaged (MSIX) install. It is only present when running as such a package;
/// otherwise this scope has no backing file.
/// </summary>
MachineFolder = 2
}

/// <summary>
Expand Down Expand Up @@ -61,6 +68,11 @@ internal sealed class PowerShellConfig
private string systemWideConfigFile;
private string systemWideConfigDirectory;

// When running as a packaged MSIX app with the per-machine data store provisioned, the path to the
// writable per-machine (admin) 'MachineFolder' config file. Null when not packaged, in which case
// the MachineFolder scope has no backing file and merge orders collapse to the legacy behavior.
private string machineFolderConfigFile;

// The json file containing the per-user configuration settings.
private readonly string perUserConfigFile;
private readonly string perUserConfigDirectory;
Expand All @@ -85,6 +97,17 @@ private PowerShellConfig()
systemWideConfigDirectory = Utils.DefaultPowerShellAppBase;
systemWideConfigFile = Path.Combine(systemWideConfigDirectory, ConfigFileName);

// When running as a packaged MSIX app that opts into the per-machine data store, expose that
// writable location as an additional 'MachineFolder' (admin) configuration scope. The shipped
// $PSHOME config remains the read-only product defaults; system-wide writes are redirected to
// the machine folder and only the changed keys are stored there (no full seed). Policy and
// preference settings merge these scopes with different precedence (see the Utils merge orders).
string machineStore = Utils.GetPackagedMachineDataStorePath();
if (!string.IsNullOrEmpty(machineStore))
{
machineFolderConfigFile = Path.Combine(machineStore, 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.
Expand All @@ -95,15 +118,41 @@ private PowerShellConfig()
}

emptyConfig = new JObject();
configRoots = new JObject[2];
configRoots = new JObject[3];
serializer = JsonSerializer.Create(new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.None, MaxDepth = 10 });

fileLock = new ReaderWriterLockSlim();
}

private string GetConfigFilePath(ConfigScope scope)
{
return (scope == ConfigScope.CurrentUser) ? perUserConfigFile : systemWideConfigFile;
switch (scope)
{
case ConfigScope.CurrentUser:
return perUserConfigFile;
case ConfigScope.MachineFolder:
// Null when not running as a packaged app with the per-machine data store provisioned.
return machineFolderConfigFile;
default:
// AllUsers -> the read-only $PSHOME (product) config.
return systemWideConfigFile;
}
}

/// <summary>
/// Maps a logical write scope to the physical config scope that receives the write. System-wide
/// (AllUsers) writes are redirected to the writable per-machine data store (MachineFolder) when
/// running as a packaged app, so only the changed keys are stored there and the read-only $PSHOME
/// product config is left untouched. All other scopes write in place.
/// </summary>
private ConfigScope ResolveWriteScope(ConfigScope scope)
{
if (scope == ConfigScope.AllUsers && !string.IsNullOrEmpty(machineFolderConfigFile))
{
return ConfigScope.MachineFolder;
}

return scope;
}

/// <summary>
Expand All @@ -124,6 +173,9 @@ internal void SetSystemConfigFilePath(string value)
FileInfo info = new FileInfo(value);
systemWideConfigFile = info.FullName;
systemWideConfigDirectory = info.Directory.FullName;

// An explicit settings file overrides the per-machine data store; disable the MachineFolder scope.
machineFolderConfigFile = null;
}

/// <summary>
Expand Down Expand Up @@ -207,39 +259,43 @@ internal string[] GetExperimentalFeatures()
/// <param name="setEnabled">If true, add to configuration; otherwise, remove from configuration.</param>
internal void SetExperimentalFeatures(ConfigScope scope, string featureName, bool setEnabled)
{
// Experimental features intentionally stay on the legacy scopes ($PSHOME for AllUsers) and are not
// redirected to the packaged per-machine data store; MachineFolder support for experimental features
// is deferred to a separate change (see https://github.com/PowerShell/PowerShell/issues/27702).
// This keeps behavior identical to non-packaged installs, so an AllUsers write under MSIX fails
// against the read-only $PSHOME just as it does today.
var features = new List<string>(GetExperimentalFeatures());
bool containsFeature = features.Contains(featureName);
if (setEnabled && !containsFeature)
{
features.Add(featureName);
WriteValueToFile<string[]>(scope, "ExperimentalFeatures", features.ToArray());
WriteValueToFile<string[]>(scope, "ExperimentalFeatures", features.ToArray(), allowMachineFolderRedirect: false);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove experimental feature merging for another PR

}
else if (!setEnabled && containsFeature)
{
features.Remove(featureName);
WriteValueToFile<string[]>(scope, "ExperimentalFeatures", features.ToArray());
WriteValueToFile<string[]>(scope, "ExperimentalFeatures", features.ToArray(), allowMachineFolderRedirect: false);
}
}

internal bool IsImplicitWinCompatEnabled()
{
bool settingValue = ReadValueFromFile<bool?>(ConfigScope.CurrentUser, DisableImplicitWinCompatKey)
?? ReadValueFromFile<bool?>(ConfigScope.AllUsers, DisableImplicitWinCompatKey)
?? false;
// DisableImplicitWinCompat is a preference: the highest-precedence scope that sets it wins.
bool settingValue = MergePreferenceValue<bool?>(DisableImplicitWinCompatKey) ?? false;

return !settingValue;
}

internal string[] GetWindowsPowerShellCompatibilityModuleDenyList()
{
return ReadValueFromFile<string[]>(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityModuleDenyListKey)
?? ReadValueFromFile<string[]>(ConfigScope.AllUsers, WindowsPowerShellCompatibilityModuleDenyListKey);
// The compatibility module deny list is a policy: its entries are unioned across every scope.
return MergePolicyList(WindowsPowerShellCompatibilityModuleDenyListKey);
}

internal string[] GetWindowsPowerShellCompatibilityNoClobberModuleList()
{
return ReadValueFromFile<string[]>(ConfigScope.CurrentUser, WindowsPowerShellCompatibilityNoClobberModuleListKey)
?? ReadValueFromFile<string[]>(ConfigScope.AllUsers, WindowsPowerShellCompatibilityNoClobberModuleListKey);
// The no-clobber list is a preference: the highest-precedence scope that defines it wins.
return MergePreferenceValue<string[]>(WindowsPowerShellCompatibilityNoClobberModuleListKey);
}

/// <summary>
Expand Down Expand Up @@ -380,6 +436,78 @@ internal PSKeyword GetLogKeywords()
}
#endif // UNIX

/// <summary>
/// The order in which configuration scopes are consulted when resolving a *preference* setting:
/// the current user's value wins, then the admin-writable MachineFolder override, then the
/// $PSHOME (AllUsers) product default. Policy lists resolved by <see cref="MergePolicyList"/>
/// union every scope, so the iteration order does not affect them.
/// </summary>
private static readonly ConfigScope[] s_configScopePreferenceOrder = new[]
{
ConfigScope.CurrentUser,
ConfigScope.MachineFolder,
ConfigScope.AllUsers
};

/// <summary>
/// Resolves a *preference* setting: returns the value from the highest-precedence scope that
/// defines <paramref name="key"/> (see <see cref="s_configScopePreferenceOrder"/>), so that a
/// more specific scope overrides a broader one. Use this for settings that behave like a
/// preference - the current user's choice should take precedence over a system-wide value.
/// In contrast to <see cref="MergePolicyList"/> (which unions every scope), only the winning
/// scope's value is used; lower scopes are ignored once a higher one defines the key. Returns
/// <paramref name="defaultValue"/> when no scope defines the key. <typeparamref name="T"/> must
/// be a reference type or a nullable value type so that an unset scope is observable as null.
/// </summary>
/// <typeparam name="T">The type of the value.</typeparam>
/// <param name="key">The configuration key to resolve.</param>
/// <param name="defaultValue">The value to return when no scope defines the key.</param>
private T MergePreferenceValue<T>(string key, T defaultValue = default)
{
foreach (ConfigScope scope in s_configScopePreferenceOrder)
{
T value = ReadValueFromFile<T>(scope, key);
if (value is not null)
{
return value;
}
}

return defaultValue;
}

/// <summary>
/// Resolves a *policy* list: returns the case-insensitive union of the string entries defined in
/// every configuration scope for <paramref name="key"/>. Use this for list settings that behave
/// like a policy and must accumulate across scopes, so that an entry contributed by one scope
/// (for example a module added to the list by a product update in $PSHOME) is never dropped
/// because a higher-precedence scope also defines the key. Every scope can only add entries; a
/// higher-precedence scope never removes an entry contributed by a lower scope. Returns null when
/// no scope contributes an entry.
/// </summary>
/// <param name="key">The configuration key to resolve.</param>
private string[] MergePolicyList(string key)
{
var merged = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (ConfigScope scope in s_configScopePreferenceOrder)
{
string[] values = ReadValueFromFile<string[]>(scope, key);
if (values is not null)
{
merged.UnionWith(values);
}
}

if (merged.Count == 0)
{
return null;
}

var result = new string[merged.Count];
merged.CopyTo(result);
return result;
}

/// <summary>
/// Read a value from the configuration file.
/// </summary>
Expand Down Expand Up @@ -472,10 +600,20 @@ private static FileStream OpenFileStreamWithRetry(string fullPath, FileMode mode
/// <param name="key">The string key of the value.</param>
/// <param name="value">The value to set.</param>
/// <param name="addValue">Whether the key-value pair should be added to or removed from the file.</param>
private void UpdateValueInFile<T>(ConfigScope scope, string key, T value, bool addValue)
/// <param name="allowMachineFolderRedirect">When true (default), system-wide (AllUsers) writes are redirected to the writable per-machine data store on a packaged install; pass false to write to the literal scope.</param>
private void UpdateValueInFile<T>(ConfigScope scope, string key, T value, bool addValue, bool allowMachineFolderRedirect = true)
{
try
{
// Redirect system-wide writes to the writable per-machine data store when packaged, so only
// the changed keys are stored there and the read-only $PSHOME product config is untouched.
// Callers that must target the literal scope (e.g. experimental features, whose MachineFolder
// support is deferred) pass allowMachineFolderRedirect: false.
if (allowMachineFolderRedirect)
{
scope = ResolveWriteScope(scope);
}

string fileName = GetConfigFilePath(scope);
fileLock.EnterWriteLock();

Expand Down Expand Up @@ -573,14 +711,15 @@ private void UpdateValueInFile<T>(ConfigScope scope, string key, T value, bool a
/// <param name="scope">The ConfigScope of the file to update.</param>
/// <param name="key">The string key of the value.</param>
/// <param name="value">The value to write.</param>
private void WriteValueToFile<T>(ConfigScope scope, string key, T value)
/// <param name="allowMachineFolderRedirect">When true (default), system-wide (AllUsers) writes are redirected to the writable per-machine data store on a packaged install; pass false to write to the literal scope.</param>
private void WriteValueToFile<T>(ConfigScope scope, string key, T value, bool allowMachineFolderRedirect = true)
{
if (scope == ConfigScope.CurrentUser && !Directory.Exists(perUserConfigDirectory))
{
Directory.CreateDirectory(perUserConfigDirectory);
}

UpdateValueInFile<T>(scope, key, value, true);
UpdateValueInFile<T>(scope, key, value, true, allowMachineFolderRedirect);
}

/// <summary>
Comment thread
jshigetomi marked this conversation as resolved.
Expand Down
Loading
Loading