diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 03c90833f5..cb3a3f23a9 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -8,6 +8,7 @@ addportablefile addstore admins AFX +anonymization appinstaller appmodel appxbundle @@ -264,6 +265,7 @@ prioritization processthreadsapi PRODUCTNAME PRODUCTVERSION +PROGRAMDATA PROGRESSONLY promptrestart PROPERTYDUMP diff --git a/doc/Settings.md b/doc/Settings.md index 6aa29dc770..9b02854d25 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -10,6 +10,92 @@ If you are using the non-packaged WinGet version by building it from source code If you are creating a settings file from scratch, make sure that the line `"$schema": "https://aka.ms/winget-settings.schema.json",` is in it. +## Administrator Settings + +WinGet supports an administrator-managed `admin-settings.json` file for each +user. Values in this file take precedence over regular user settings, including +when WinGet runs without administrator privileges. Default install and module +folders are the exception: you can configure separate folders for normal and +administrator use. + +You can add any supported setting to this file. Your organization's Group Policy +settings still take precedence over both settings files. + +To create or edit administrator settings, open a terminal using **Run as +administrator**, then run: + +```powershell +winget settings --admin +``` + +This opens `admin-settings.json`, creating it if needed. It uses the same setting +names and JSON format as `settings.json`, but only an administrator can save +changes. Each user has their own administrator settings file. You can open an +existing file without administrator privileges to read it. + +Run `winget settings export` to find both file locations, listed as +`userSettingsFile` and `adminSettingsFile`. The new file is separate from the +features enabled or disabled with `winget settings --enable` and `--disable`. + +### Settings that require administrator configuration + +When running as administrator, WinGet ignores the following settings in your +regular `settings.json`. To keep your custom values for administrator use, add +them to `admin-settings.json`; otherwise, WinGet uses their defaults. A warning +identifies settings that need to be moved. + +| Setting | When the value in `admin-settings.json` is used | +|---|---| +| `installBehavior.portablePackageUserRoot` | With or without administrator privileges. | +| `installBehavior.portablePackageMachineRoot` | With or without administrator privileges. Installing or changing machine-wide portable packages still requires administrator privileges. | +| `downloadBehavior.defaultDownloadDirectory` | With or without administrator privileges. | +| `uninstallBehavior.purgePortablePackage` | With or without administrator privileges. | +| `installBehavior.defaultInstallRoot` | Only when running as administrator. Otherwise, your regular settings apply. | +| `configureBehavior.defaultModuleRoot` | Only when running as administrator. Otherwise, your regular settings apply. | + +If an administrator setting has an invalid value, WinGet warns and uses its +default rather than the value from your regular settings. If the administrator +file cannot be read and its backup cannot be used, WinGet warns and uses defaults +instead of either settings file. You can still open the file to repair it. + +### Keeping a default value + +Set a value to `null` in `admin-settings.json` to keep WinGet's default, even if +your regular settings specify something else. This does not produce a warning. +The two settings marked "Only when running as administrator" above still apply +only in that case, and Group Policy continues to take precedence. + +For example, this keeps the default download folder whether or not you run +WinGet as administrator: + +```json +{ + "$schema": "https://aka.ms/winget-settings.schema.json", + "downloadBehavior": { + "defaultDownloadDirectory": null + } +} +``` + +### PowerShell and configuration files + +PowerShell supports `-Admin` on `Get-WinGetUserSetting`, +`Test-WinGetUserSetting`, and `Set-WinGetUserSetting` to select the administrator +file. Only Set requires administrator privileges. These commands read or change +the selected file, rather than combining the two files. + +`Set-WinGetUserSetting -Merge` skips values set to `$null`. To save a null, omit +`-Merge` and include all settings you want to keep, since this replaces the file. + +For DSC, use `Admin = $true` with `WinGetUserSettings`, or `"admin": true` with +the DSCv3 `UserSettingsFile` resource. Saving administrator settings requires +administrator privileges. With PowerShell DSC, use `Action = 'Full'` to save a null. + +> [!IMPORTANT] +> For administrator module folders, choose a location that requires +> administrator privileges to change. Adding a folder to administrator settings +> does not change its permissions or protect files already in it. + ## Source The `source` settings involve configuration to the WinGet source. @@ -92,6 +178,8 @@ The `disableInstallNotes` behavior affects whether installation notes are shown The `portablePackageUserRoot` setting affects the default root directory where packages are installed to under `User` scope. This setting only applies to packages with the `portable` installer type. Defaults to `%LOCALAPPDATA%/Microsoft/WinGet/Packages/` if value is not set or is invalid. +To use this setting when running as administrator, configure it in [administrator settings](#administrator-settings). That value also applies when running normally. + > Note: This setting value must be an absolute path. ```json @@ -104,6 +192,8 @@ The `portablePackageUserRoot` setting affects the default root directory where p The `portablePackageMachineRoot` setting affects the default root directory where packages are installed to under `Machine` scope. This setting only applies to packages with the `portable` installer type. Defaults to `%PROGRAMFILES%/WinGet/Packages/` if value is not set or is invalid. +To use this setting when running as administrator, configure it in [administrator settings](#administrator-settings). That value also applies when running normally. Changing machine-wide portable packages requires administrator privileges. + > Note: This setting value must be an absolute path. ```json @@ -205,6 +295,8 @@ By default, when no user preference or requirement is configured, WinGet selects The `defaultInstallRoot` affects the install location when a package requires one. This can be overridden by the `--location` parameter. This setting is only used when a package manifest includes `InstallLocationRequired`, and the actual location is obtained by appending the package ID to the root. +Use [administrator settings](#administrator-settings) to choose the root for commands run as administrator. Your regular settings continue to apply when running normally. + ```json "installBehavior": { "defaultInstallRoot": "C:/installRoot" @@ -231,6 +323,8 @@ The `uninstallBehavior` settings affect the default behavior of uninstalling (wh The `purgePortablePackage` behavior affects the default behavior for uninstalling a portable package. If set to `true`, uninstall will remove all files and directories relevant to the `portable` package. This setting only applies to packages with the `portable` installer type. Defaults to `false` if value is not set or is invalid. +To use this setting when running as administrator, configure it in [administrator settings](#administrator-settings). That value also applies when running normally. + ```json "uninstallBehavior": { "purgePortablePackage": true @@ -243,7 +337,13 @@ The `configureBehavior` settings affect the default behavior of applying a confi ### Default Module Root -The `defaultModuleRoot` behavior affects the default root directory where modules are installed to. Defaults to `%LOCALAPPDATA%/Microsoft/WinGet/Configuration/Modules` if value is not set or is invalid. +The `defaultModuleRoot` behavior affects the default root directory where modules are installed to. Normally, the default is `%LOCALAPPDATA%\Microsoft\WinGet\Configuration\Modules`. When running as administrator, WinGet uses a separate protected folder instead. + +Use [administrator settings](#administrator-settings) to choose the module folder for configurations run as administrator. Your regular settings continue to apply when running normally. + +Configurations that require administrator privileges use the administrator module folder, even when started from a regular terminal. Modules from your regular WinGet module folder are not automatically reused, so WinGet may need to download them again. + +You can still choose a folder with `--module-path` (or PowerShell `-ModulePath`). Specifying `default` uses WinGet's default folder rather than this setting. > Note: This setting value must be an absolute path. @@ -261,6 +361,8 @@ The `downloadBehavior` settings affect the default behavior of downloading packa The `defaultDownloadDirectory` setting affects the default directory where installers are downloaded to. Defaults to `%USERPROFILE%/Downloads/` if value is not set or is invalid. +To use this setting when running as administrator, configure it in [administrator settings](#administrator-settings). That value also applies when running normally. + > Note: This setting value must be an absolute path. ```json diff --git a/doc/release-notes/1.31/protected-settings-overlay.md b/doc/release-notes/1.31/protected-settings-overlay.md new file mode 100644 index 0000000000..5c78ae14b9 --- /dev/null +++ b/doc/release-notes/1.31/protected-settings-overlay.md @@ -0,0 +1,25 @@ +## New Features + +### Protected administrator settings + +WinGet now supports an administrator-managed settings file for each user. +Settings in this file take precedence over regular user settings, including +when WinGet runs without administrator privileges. Install and module folders +are the exception: you can configure separate folders for normal and +administrator use. + +To edit the new `admin-settings.json` file, open a terminal using +**Run as administrator**, then run +`winget settings --admin`. +PowerShell user-setting cmdlets also support `-Admin`. + +When running as administrator, WinGet now uses this file instead of your regular +settings for portable package folders, the default install folder, the +configuration module folder, the download folder, and portable-package cleanup. +If you previously customized these settings, WinGet displays a warning directing +you to configure their administrator values. + +Configurations run as administrator now use a separate default module folder, so +WinGet may need to download modules again. + +See [Administrator Settings](https://github.com/microsoft/winget-cli/blob/master/doc/Settings.md#administrator-settings) for details. diff --git a/doc/specs/Protected Settings Overlay.md b/doc/specs/Protected Settings Overlay.md new file mode 100644 index 0000000000..1552134574 --- /dev/null +++ b/doc/specs/Protected Settings Overlay.md @@ -0,0 +1,768 @@ +--- +author: JohnMcPMS, GitHub Copilot +created on: 2026-10-29 +last updated: 2026-11-10 +--- + +# Protected Settings Overlay + +## Abstract + +Add a per-user, administrator-writable settings file using the existing user +settings schema, whose explicitly configured values override the ordinary user +settings file where eligible for the execution context. Selected settings ignore +ordinary-file values with the non-default full token; module and default install +roots additionally use separate values across the token boundary, while keeping +the existing settings interface and authoritative in-process settings. + +## Inspiration + +The ordinary `settings.json` file is writable by a medium-integrity process but +can be read by an elevated process. Its values can influence privileged +filesystem destinations, recursive portable-package deletion, and configuration +module discovery. JSON type and path validation do not establish that an +administrator selected those values. + +Ignoring sensitive values in that file prevents administrators from configuring +those settings for elevated use unless an alternative is provided. A protected +file supplies that alternative without introducing a second settings schema. +Protected overrides can be shared across contexts for output locations and +other suitable controls. Locations used for reading or importing content may +instead need independent ordinary and full-token values so ordinary operations +do not require the administrator-selected location to be medium-writable. + +## Solution Design + +### Terminology and scope + +| Term | Meaning | +|---|---| +| Ordinary file | Existing medium-writable user settings file and its backup. | +| Protected file | New `admin-settings.json`, writable only with administrative privileges and readable by the current user without elevation. | +| Full token | The condition identified by the existing `Runtime::IsRunningWithNonDefaultFullToken()` helper. | +| Restricted setting | A setting whose ordinary-file value is ignored with a full token. | +| Shared override | A protected-file value eligible in either token context. | +| Split setting | A restricted setting whose protected-file value is eligible only with the full token. | +| In-process settings | Custom JSON supplied through the existing one-time settings initialization API instead of settings files. | + +The full-token helper checks `TokenElevationTypeFull`: the non-default full side +of a split token. Reuse it without adding checks for administrator membership, +integrity level, SYSTEM, or impersonation. This restriction is not defined as a +new universal check for every privileged process. + +The protected file is separate from the existing admin capability settings, +such as `InstallerHashOverride`, and does not change those controls. + +### Protected storage + +Store `admin-settings.json` directly in the existing protected settings +directory, using `PathName::SecureSettingsForRead` for reading and +`PathName::SecureSettingsForWrite` for creation and writes. + +The existing paths are: + +```text +Packaged: +%PROGRAMDATA%\Microsoft\WinGet\\settings\pkg\\admin-settings.json + +Unpackaged: +%PROGRAMDATA%\Microsoft\WinGet\\settings\win\\admin-settings.json +``` + +These are per-user locations, not machine-wide settings. Reuse the same path +enum values in both contexts; their existing implementations separate package +identity and unpackaged runtime state. Discover the actual path through +`winget settings export` rather than reconstructing it in management tools. + +Reuse the existing administrator ownership, SYSTEM access, and current-user +read permissions. Reads must not create the directory or change permissions. +Primary, backup, temporary files, and their containing directories must retain +protection against medium-token replacement, deletion, or redirection. + +The existing `Type::Secure` container stores content separately from protected +verification metadata. This feature reuses the protected directory and its ACL +mechanisms, not that content-plus-hash arrangement. Existing admin capability +settings and source configuration hashes are unchanged. + +Use `admin-settings.json.backup` for protected recovery. Mirror the existing +backup-on-open behavior: an elevated CLI open of a successfully loaded primary +can update the backup before editing. Do not back up every write, create a +backup during an unelevated open, or replace a good backup with a primary known +to be invalid. + +### Setting classifications + +Add explicit treatment metadata to every canonical `SettingMapping`, alongside +its JSON path, type, default, and policy mapping. Three treatments are needed: +unrestricted/shared override, restricted/shared override, and restricted/split. +Do not introduce a general flags framework for hypothetical future treatments. + +Require an explicit classification for new settings, including settings +compiled only with test hooks. + +The initial restricted set is: + +| Setting | Protected override | Rationale | +|---|---|---| +| `configureBehavior.defaultModuleRoot` | Split | Modules are saved, discovered, and imported here; ordinary operations must not force the full-token root to be medium-writable. | +| `installBehavior.portablePackageUserRoot` | Shared | Keep consistent paths; separate portable work will block full-token user-scope operations, then implement de-elevation. | +| `installBehavior.portablePackageMachineRoot` | Shared | Keep consistent paths; machine-scope portable mutations require elevation. | +| `installBehavior.defaultInstallRoot` | Split | Package installers may read existing contents or installed components; not a guaranteed write-only destination. | +| `downloadBehavior.defaultDownloadDirectory` | Shared | Download-only output, not ordinary installation staging or automatic execution input. | +| `uninstallBehavior.purgePortablePackage` | Shared | Boolean deletion policy, not a configurable content search location. | + +Portable roots are not write-only: WinGet later reads hashes and portable SQLite +indexes at installed locations. Sharing the user root is an explicit design +choice based on separate blocking/de-elevation work, not a claim that those +protections are implemented by this feature. Document machine-scope elevation +requirements in the schema/spec; do not add another runtime metadata dimension. + +Keep all other settings unrestricted initially. Installer preferences select +among otherwise applicable artifacts; extraction settings select fixed +implementations; network settings do not supply endpoints or bypass +verification; log retention settings operate within the selected log location. +These controls affect behavior, but are not automatically equivalent to +supplying a privileged destination or module search path. + +### Settings schema and examples + +Both files use the existing settings schema. No new settings keys or manifest +schema version are required. + +Update the descriptions of all six restricted properties in +`schemas\JSON\settings\settings.schema.0.2.json` to explain that ordinary-file +values are ignored with a non-default full token and direct users to +`winget settings --admin` to configure the protected file. This makes the +restriction discoverable in editors using the shared schema without changing +property types or validation. For the two split properties, also explain that +protected-file values do not apply without the full token. Document the +context-specific module default and the machine portable root's elevated-only +operational use. + +For example, the ordinary file can contain: + +```json +{ + "$schema": "https://aka.ms/winget-settings.schema.json", + "visual": { + "progressBar": "rainbow" + }, + "downloadBehavior": { + "defaultDownloadDirectory": "C:\\UserDownloads" + }, + "configureBehavior": { + "defaultModuleRoot": "C:\\UserModules" + } +} +``` + +The protected file can contain: + +```json +{ + "$schema": "https://aka.ms/winget-settings.schema.json", + "downloadBehavior": { + "defaultDownloadDirectory": "C:\\ManagedDownloads" + }, + "configureBehavior": { + "defaultModuleRoot": "C:\\ManagedModules" + } +} +``` + +The resulting view uses `C:\ManagedDownloads` in both token contexts and retains +the ordinary progress-bar preference. If the protected download setting is +absent, an ordinary-token process uses `C:\UserDownloads`; a full-token process +uses the compiled default and records a migration warning. + +The module root instead uses `C:\UserModules` without the full token and +`C:\ManagedModules` with it. The administrator must protect the latter directory +against ordinary writes; storing its name in the protected file does not +establish that directory's permissions. + +An administrator may override unrestricted settings as well. A protected file +containing only selected values does not default the remaining user settings. + +An eligible protected field can explicitly select the built-in default with +`null`, even when the ordinary file supplies a value: + +```json +{ + "$schema": "https://aka.ms/winget-settings.schema.json", + "downloadBehavior": { + "defaultDownloadDirectory": null + } +} +``` + +This intentional selection emits neither a validation nor migration warning and +blocks ordinary-file fallback. Eligibility still applies: a protected module or +default install root, including null, has no effect without the full token. +Existing Group Policy handling still takes precedence. + +### Loading and effective settings + +Implement the change within `UserSettings`. Preserve `User().Get()` and the +operational consumers that already use it. + +Preserve the existing settings policy gate. If `EnableSettings` disables +settings, retain the early exit and do not load either file. + +When in-process content is supplied, use that content instead of loading either +file. Do not filter its values by file classification, overlay protected-file +values, recover from disk on custom parse failure, or emit file migration +warnings. Preserve existing typed validation, policy handling, and one-time +initialization behavior. Its authority is over file loading, not Group Policy. + +For file-backed initialization: + +1. Select the ordinary primary or backup using existing behavior. +2. Load the protected primary or recover its protected backup. +3. Compose explicit entries at canonical setting granularity, filtering + restricted ordinary entries with the full token and protected entries for + split settings without it. +4. Apply existing typed validation and expose the effective settings view. + +Preserve the existing policy-first validation in `Validate()`. It already +selects a mapped Group Policy value before reading JSON; an invalid policy value +uses the default rather than JSON. Do not add a separate policy precedence +layer or change unrelated existing no-valid-JSON behavior. + +Absent a mapped policy, file-backed selection is: + +| Context | Classification | Protected field absent | Protected field valid | Protected field null | Protected field invalid | +|---|---|---|---|---|---| +| Ordinary token | Unrestricted/shared | Ordinary value or default | Protected value | Default; no warning | Warn; default | +| Ordinary token | Restricted/shared | Ordinary value or default | Protected value | Default; no warning | Warn; default | +| Ordinary token | Restricted/split | Ordinary value or default | Ordinary value or default | Ordinary value or default | Ordinary value or default | +| Full token | Unrestricted/shared | Ordinary value or default | Protected value | Default; no warning | Warn; default | +| Full token | Restricted/shared | Default | Protected value | Default; no warning | Warn; default | +| Full token | Restricted/split | Full-token default | Protected value | Full-token default; no warning | Warn; full-token default | + +An eligible invalid protected field is not the same as an absent field: it blocks +ordinary-file fallback and selects the default. Protected entries for split +settings are ineligible without the full token, so even an invalid entry cannot +replace the ordinary value or force its default. Other valid eligible protected +settings still apply. Any whole-file load failure with no usable protected +backup leaves both files' settings unapplied and runs policy-first validation +with empty file sources. Mapped Group Policy values still apply; other settings +select compiled defaults, with load warnings in either context. Settings load +failures must not prevent functionality. + +"Default" normally means the existing compiled default. The module root has a +context-specific default described below. An absent default install root still +uses the existing empty default and location-prompt behavior. + +Null is an explicit default selection, not an absent or invalid field. +False, zero, and valid empty collections are explicit values. Arrays replace +the lower-precedence array as a whole; they are not concatenated. Overriding one +nested setting must preserve unrelated siblings. + +Keep explicit presence and source information long enough to make these +decisions and produce diagnostics. Do not overlay two fully default-expanded +objects or implement a second schema/parser. Preserve the existing user source +and edit semantics exposed by `GetType()`; track overlay state separately as +needed. + +### Full-token module storage and discovery + +Without a valid protected module-root value, use a distinct administrator-writable +per-user module directory with the full token. Reuse protected per-user path/ACL +mechanisms and preserve packaged/unpackaged separation. Ordinary-token processes +retain their existing module default and cannot change the full-token default's +contents. Invalid eligible root values select this context-appropriate default, +not an empty value that would fall back to LocalAppData. + +Use `PathName::DefaultConfigurationModules` through the existing +`GetPathDetailsFor`/`GetPathTo` initialization rather than custom path getters. +`PathName::ConfigurationModules` continues to select the effective configured +root, delegating an empty value to that built-in default. Initialize the +protected parent before creating module descendants. Display-only default +lookups do not create directories and honor the path anonymization setting. + +The internal native export +`WindowsPackageManagerConfigurationGetModulePath(BOOL useSettings, BOOL* fullToken, BSTR* path)` +in WindowsPackageManager.dll supplies the managed processor with its local +token decision and module path. `useSettings` selects effective settings or the +built-in default; it does not select DSC executables or other remoting inputs. +The HRESULT-returning export allocates the returned BSTR for the caller to free. +It reuses the existing settings view and runtime path initialization, not a new +parser or shared-library path implementation. + +For file-backed CLI settings, leave the factory location unset when no module +path argument is supplied. The receiving processor, including an elevated +receiver, resolves its own effective `ConfigureDefaultModuleRoot`. PowerShell +configuration cmdlets likewise preserve an omitted module path. An explicitly +assigned default instead chooses the built-in default, ignoring the configured +root. Preserve explicit current-user/all-users/custom roots and authoritative +in-process content, including a custom-content selection of the default. + +The dynamic factory omits `modulePath` for an implicit default and serializes +an explicit default as `"modulePath": "Default"`. Scope/custom selections retain +their existing serialization. DSC executable discovery, expected hash/alias +binding, and other remoting inputs remain unchanged. + +Full-token module discovery does not automatically prepend the ordinary WinGet +module directory as an additional fallback when selecting a protected custom +or default root. Remove any preexisting equivalent ordinary-root entry before +applying explicit paths. Match whole normalized entries, not substrings or +child/sibling paths. Such an entry is not a PowerShell built-in default; it may +already be present in the process environment. + +Preserve explicit additional/implicit paths and custom roots by applying them +after cleanup. AdditionalModulePaths is caller-controlled through the factory +interface. The dynamic elevation factory does not forward that collection. +Each remoting receiver derives its ImplicitModulePaths from its own packaged +ExternalModules location before applying its limitation set. + +All hosted/default processor environments use `InitialSessionState.CreateDefault2` +without eagerly importing DSC. Configure discovery and installation paths +before `ValidateRunspace` imports or installs required modules, regardless of +token context. Preserve the selected execution policy. + +Do not automatically copy or trust modules from the ordinary directory. +Reacquire or explicitly provision required modules in the protected location. +Preserve authoritative in-process roots and existing explicit API inputs. +This change isolates WinGet's ordinary module directory; it does not claim that +every PowerShell search path or administrator-selected custom path is trusted. + +### Recovery and defaults + +Ordinary and protected settings files share the same parsing and JSON-object +root validation. Stream failures and read exceptions record source-specific +warnings for either file type and trigger the applicable backup/default path. + +| Protected-file condition | Result | +|---|---| +| Neither primary nor backup exists | Normal absence; continue with eligible ordinary values/defaults. | +| Primary loads successfully | Use primary; do not recover individual invalid fields from backup. | +| Primary is absent, unreadable, or malformed; backup loads | Use protected backup and record a recovery warning. | +| Protected configuration cannot be read/parsed, has a non-object root, or otherwise cannot be loaded, and no usable backup exists | Warn and use compiled defaults without applying either file's settings; do not propagate load errors. | +| Individual eligible protected field is invalid | Warn and use its context-appropriate default; retain other valid fields. | + +Report source and setting path in diagnostics without unnecessarily exposing +setting values. Report access failures as warnings rather than silently treating +them as missing files. +For restricted full-token settings, no failure or recovery path reactivates an +ordinary-file value. + +The load-failure fallback matches the existing no-valid-JSON behavior: no +settings are added to the effective map. Keep ordinary-file source/edit +semantics in `GetType()` even when its values are not applied. + +Keep protected-file opening and management path discovery available for repair +when settings could not be loaded. Normal operations continue with compiled +defaults; discovering or opening a file for repair must not depend on its +contents being valid. + +### CLI management + +Add `winget settings --admin` to target the protected file. Plain +`winget settings` continues to target the ordinary file. + +An elevated protected open may create the directory/file and prepare its +backup, using the existing schema-linked settings template. An unelevated open +of an existing file launches the editor without creation or backup. Write +protection prevents saving changes without elevation; do not automatically +request UAC elevation or change ACLs. + +If an unelevated open finds no file, report its path and explain that elevated +creation is required. Do not launch an editor with an unsaved template. + +The hidden provisioning form `winget settings --admin --create` requires +elevation and creates the schema-linked primary only if absent. It does not open +an editor, replace an existing file, or prepare a backup. Require `--admin` when +`--create` is present. This supports headless callers such as PowerShell +protected-file management without making editor launch a provisioning step. + +Reject combinations of `--admin` with existing capability mutation arguments +such as `--enable` and `--disable`. Existing `settings set` and `settings reset` +continue to manage their existing admin capability settings; they do not +silently become JSON file editors. + +Extend `winget settings export` with an `adminSettingsFile` string alongside +`userSettingsFile`. Preserve the existing `adminSettings` object. Add the +property to the settings export schema without requiring it from older clients: + +```json +{ + "properties": { + "adminSettingsFile": { + "type": "string" + } + } +} +``` + +This is an additive change to the export schema, not a settings or manifest +schema change. The export remains a path/capability discovery operation, not +an export of raw file contents or resolved effective values. Its output must +remain valid JSON, including when a settings file requires repair. + +### Migration warnings + +When a full-token file-backed load ignores a restricted ordinary-file value +without a valid protected replacement, add a setting-specific migration warning. +Identify the key, explain default selection, and point to protected-file +management. Do not print the ignored value. + +A valid protected replacement or eligible explicit null suppresses this +migration warning. An invalid protected field does not suppress it merely by +being present. Missing ordinary fields, ordinary-token loads, and authoritative +in-process initialization do not generate migration warnings. + +Display migration warnings once on every affected elevated CLI invocation, +using a shared reporting point rather than changes to each consumer. Avoid +duplicates when opening settings. Keep the existing visibility of unrelated +settings warnings. Diagnostics must not contaminate structured JSON output. + +### COM API and package operations + +No IDL or public COM interface changes are required. +`PackageManagerSettings.SetUserSettings` retains its existing in-process, +one-time custom initialization semantics and bypasses both files. + +File-backed COM consumers use the same effective settings view. COM remains +non-interactive; settings load failures select compiled defaults and record +diagnostics rather than prompting, displaying CLI output, or propagating file +load errors. + +CLI install, upgrade, uninstall, download, and other settings consumers receive +the composed values without changes to their operational logic. Configuration +module default/discovery handling has the narrow exception described above. +Explicit CLI/API argument precedence, installer launching, and resume +serialization remain unchanged. The narrow `modulePath` distinction above is +the only remoting metadata change; other inputs and elevation flow are unchanged. + +This guarantee applies to values exposed by the view in its initialization +context. It does not redefine the authority of values already selected in a +medium process and subsequently forwarded to an elevated installer or worker, +apart from the specifically addressed implicit module-root selection. + +### PowerShell cmdlets + +Add an optional `-Admin` switch to `Get-WinGetUserSetting`, +`Test-WinGetUserSetting`, and `Set-WinGetUserSetting`. It selects the protected +path discovered through `adminSettingsFile`; the default remains the ordinary +file. + +Preserve raw-file behavior: Get returns the selected file as a hashtable, Test +returns a Boolean, and Set returns the written settings object. Do not return +the effective overlaid view. Preserve existing `-IgnoreNotSet` and `-Merge` +semantics; management merging is not the runtime overlay operation. +`-Merge` ignores input nulls; omit it to persist an explicit null. Without +`-Merge`, Set replaces the file's settings, so callers must include other values +they intend to retain. + +Get and Test may run unelevated. Set requires elevation when targeting the +protected file, including directory/file creation. Do not create backups on Set +or invoke automatic elevation. Pipeline behavior and existing confirmation +behavior remain unchanged; `-Force`, where supported by a calling workflow, +cannot bypass protected-write authorization. + +Package cmdlets such as Install/Update/Uninstall/Repair/Find/Show retain their +interfaces and receive applicable effective values through existing consumers. + +### WinGet Configuration and DSC + +Add an optional Boolean target selector, defaulting to false, to both +`WinGetUserSettings` in the PowerShell DSC module and the native +`UserSettingsFile` resource. Use `Admin` in PowerShell and `admin` in native +JSON input/output. Preserve existing default targeting and Full/Partial action +semantics. + +Get/Test/Export describe the selected raw file, not effective settings. Set of +the protected target requires elevation; Get/Test can run unelevated. Return +the selected target in resource state so subsequent operations use the same +file. Do not add an interactive prompt or automatic elevation. +PowerShell DSC partial updates inherit `-Merge` null handling; use `Full` to +persist a null there. Native partial updates retain explicit null values. + +For example, the native resource input can select the protected file: + +```json +{ + "admin": true, + "action": "Partial", + "settings": { + "downloadBehavior": { + "defaultDownloadDirectory": "C:\\ManagedDownloads" + } + } +} +``` + +The PowerShell DSC resource can express the same intent: + +```powershell +WinGetUserSettings ProtectedDownloads +{ + SID = '' + Admin = $true + Action = 'Partial' + Settings = @{ + downloadBehavior = @{ + defaultDownloadDirectory = 'C:\ManagedDownloads' + } + } +} +``` + +No package manifest fields are added, so a package manifest YAML example is +not applicable. Configuration workers consume the existing settings view; +only implicit module-root resolution and explicit-default metadata receive the +narrow receiver-local changes described above. + +### Flow behavior matrix + +| Flow or mode | Behavior | +|---|---| +| Normal interactive operation | Resolve the view; display applicable migration warnings; retain existing workflow prompts. | +| `--no-vt` | Same resolution and plain-text warnings; no dependence on color, cursor movement, or terminal images. | +| `--disable-interactivity` or silent package operation | Same resolution, warnings, and failure rules; no new prompts or automatic consent. | +| `settings` | Open ordinary file; preserve existing backup behavior. | +| Elevated `settings --admin` | Open/create protected file and prepare eligible backup. | +| Elevated `settings --admin --create` | Create only if absent; no editor, replacement, or backup. | +| Unelevated `settings --admin --create` | Fail authorization without creating the file. | +| Unelevated `settings --admin`, file exists | Open without creation, backup, automatic elevation, or permission changes. | +| Unelevated `settings --admin`, file absent | Report path and need for elevated creation; do not launch editor. | +| `settings --admin`, file malformed | Remain available for repair; do not replace a good backup with the malformed primary. | +| `settings export`, file malformed | Still report paths/capabilities as JSON for repair tooling. | +| File-backed operation, unrecoverable protected load failure | Warn and use compiled defaults; apply neither file's settings. | +| Custom in-process initialization | Do not load either file; retain existing validation/policy semantics. | +| Eligible protected field explicitly null | Select built-in default, suppress ordinary fallback and migration warning; retain policy precedence. | +| COM | No prompts or CLI output; propagate failures through existing API handling. | +| PowerShell Get/Test with `-Admin` | Read/test protected raw file without elevation. | +| PowerShell Set with `-Admin` | Require elevation; preserve object output and existing merge behavior. | +| DSC protected target | Non-interactive raw-file operations; protected writes require elevation. | +| Settings disabled by policy | Preserve early exit; load neither settings file. | +| Ordinary-token configuration | Use ordinary module root/default; ignore the protected split entry. | +| Full-token configuration | Use protected module root or protected per-user default; do not automatically search/copy ordinary WinGet modules. | +| Configuration with explicit module-path default | Use receiver's built-in default, not its configured setting. | +| Configuration with omitted file-backed module path | Resolve effective settings locally in the receiving processor. | + +### Validation and cross-repository impact + +Use existing unit and integration test infrastructure to cover: + +- All three classifications and token contexts, with each source absent/present. +- Explicit false/zero/empty values, nested sibling preservation, and whole-array + replacement. +- Invalid eligible protected fields selecting context defaults rather than + ordinary fallback; ineligible split entries preserving ordinary selection. +- Eligible explicit protected null selecting defaults without validation or + migration warnings; ineligible split null leaving ordinary selection intact. +- Primary/backup recovery, defaults without either file's settings after any + unrecoverable protected load failure (including read/access errors), and + management repair access. +- Authoritative custom initialization and unchanged Group Policy behavior. +- Migration warning presence, suppression, visibility, and duplicate avoidance. +- Protected directory/file permissions, read-only opens, and elevated creation, + backup, and mutation. +- Additive export schema/property and PowerShell/DSC raw-file target selection. +- Explicit classification coverage for every canonical setting. +- Distinct protected/ordinary module defaults and protected default permissions. +- Exclusion of automatic/inherited ordinary WinGet module paths with the full + token, preservation of explicit authoritative roots, and no automatic module + copying/reuse. +- Receiver-local implicit settings roots, explicit default/scope/custom roots, + custom-content authority, and deferred DSC import for both processor types. + +No package manifest fields, publisher validation rules, or source protocol +changes are involved. Consequently, no changes are required in winget-create, +winget-cli-restsource, or winget-pkgs validation. Non-interactive package +validation remains unchanged. Client-side settings and management tests belong +in winget-cli; this feature does not depend on downstream publisher adoption. + +## UI/UX Design + +Add the `--admin` flag to settings help and describe the protected file as +administrator-writable, not as the existing admin capability settings. + +The following reflects the English resource message formatting; substitute the +actual key, path, and parser diagnostic and localize through the normal resource +pipeline. Warning streams control color without adding a literal `Warning:` prefix. + +An affected full-token invocation displays: + +```text +The setting '.downloadBehavior.defaultDownloadDirectory' from 'settings.json' is ignored when running with the elevated token. The default is used unless Group Policy supplies a value. Configure an administrator-approved value in admin-settings.json using 'winget settings --admin'. +``` + +An unelevated protected open with no existing file displays: + +```text +PS> winget settings --admin +The administrator settings file does not exist: C:\ProgramData\Microsoft\WinGet\\settings\pkg\\admin-settings.json. Run 'winget settings --admin' as administrator to create it. +``` + +Protected backup recovery displays through the existing settings-warning path: + +```text +The following failures were found validating the settings: +Loaded settings from backup file 'admin-settings.json.backup'. +``` + +Unrecoverable JSON parsing uses the existing parse/default warning path and +continues without applying either file's settings: + +```text +The following failures were found validating the settings: +Error parsing settings file 'admin-settings.json': + +Settings file 'admin-settings.json' couldn't load. Using default values. +``` + +Unrecoverable read/access failures also warn and continue with compiled defaults: + +```text +The following failures were found validating the settings: +Error reading settings file 'admin-settings.json'. +Settings file 'admin-settings.json' couldn't load. Using default values. +``` + +Recovery can also include primary-file read/parse warnings before its backup +notice. These non-migration warning examples describe settings opening. +Warning details identify the failing source. Normal operations and +repair/path-discovery operations remain available after fallback to defaults. + +`winget settings export` adds the path without changing existing fields: + +```json +{ + "adminSettings": {}, + "userSettingsFile": "C:\\Users\\Example\\AppData\\Local\\Packages\\Example\\LocalState\\settings.json", + "adminSettingsFile": "C:\\ProgramData\\Microsoft\\WinGet\\\\settings\\pkg\\\\admin-settings.json" +} +``` + +This example abbreviates the existing capability object; actual export retains +its existing entries. Warnings use diagnostic output, not the JSON output stream. + +## Capabilities + +### Accessibility + +Warnings and instructions are plain text and include the affected setting key. +They must not rely on color, VT support, images, or interactive selection. +Existing editor and assistive-technology behavior remains unchanged. + +### Security + +Restricted values from ordinary files, including their backups, are ineligible +when the existing full-token check applies. Protected values require an +administrator write, and all associated storage retains the existing protected +directory permissions. + +> [!IMPORTANT] +> A protected setting authenticates who configured its value; it does not make +> a referenced directory, module, or other payload trustworthy. This feature +> does not claim to resolve every filesystem or installer-specific attack. + +Preserve existing hash, certificate, archive scanning, policy, and capability +gates. Do not promote ordinary settings into the protected file automatically. +Authoritative in-process input and explicit options retain their existing trust +contracts; they are not reclassified as ordinary-file input. + +### Reliability + +Protected backups provide recovery without silently discarding administrator +overrides. Individual invalid values use defaults and warnings. Any +unrecoverable protected load failure warns and uses compiled defaults without +applying either file's settings. Settings load failures must never prevent +functionality. Built-in defaults must be secure; an unsafe default is a separate +issue, not a reason to require successfully loaded settings. +Management access remains available to diagnose and repair the configuration. + +### Compatibility + +No setting keys, manifest fields, COM interfaces, or existing default management +targets are removed. Clients without this feature ignore the new file and do +not enforce its restrictions; deployments must not rely on it for older clients. + +This intentionally changes full-token behavior for six ordinary-file settings. +Migration warnings and explicit protected configuration provide the transition; +there is no automatic trust promotion. Unrestricted settings retain ordinary +behavior unless explicitly overridden in the protected file. + +Module and default install roots are independently configurable for each token +context. Full-token configuration no longer automatically reuses the ordinary +WinGet module directory; administrators must acquire or explicitly provision +modules in the protected location. + +The additive export property and optional PowerShell/DSC selectors preserve +existing callers. Raw-file management continues to differ from the effective +settings view. + +### Performance, Power, and Efficiency + +Load the additional small local file once during existing process-static settings +initialization. Read its backup only for recovery, and write backups only during +eligible CLI opens. No polling is introduced. Module factory initialization also +selects the context-specific default and filters the ordinary WinGet module path. +Initial full-token configuration may need to download modules separately rather +than reuse ordinary modules. + +## Potential Issues + +- Users may initially see different elevated paths or portable deletion behavior + until an administrator configures the protected file. +- A malformed or unreadable protected file with no usable backup suppresses both + files' settings in favor of compiled defaults. Precise warnings and repair + access must remain available, without preventing normal operations. +- Shared startup code may currently read settings before identifying a repair + command. Implementation must preserve management access and avoid + contaminating structured output with warning text. +- Editor association can launch an existing process. Elevation does not guarantee + that every associated editor can save a protected file; do not relax ACLs or + automatically elevate an unrelated editor. +- A medium process can resolve values before elevation and forward them through + an existing protocol. Receiver-local implicit module roots address that + specific case, not every forwarded value or a broader provenance guarantee. +- Treating invalid protected values as absent would incorrectly restore ordinary + values. Preserve that distinction through composition and validation. +- Backing up a malformed primary could destroy recovery. Backup-on-open must use + known source/load state rather than copying unconditionally. +- Protected path and raw file discovery must remain correct across package + identities, runtime-state names, and account contexts. +- Shared portable roots rely on separate operational elevation protections. + The planned user-scope blocking/de-elevation work must not be mistaken for an + implemented guarantee of this overlay. +- Splitting module storage without removing the automatic ordinary WinGet search + fallback would leave the original cross-context module discovery issue. + +## Deprecation Path + +No settings, APIs, schema fields, or management operations are deprecated or +removed. The intentional restriction on six ordinary-file values is accompanied +by migration warnings and an administrator-configurable alternative, not a +phased schema deprecation. No fixed removal date or publisher migration is +involved. + +## Future considerations + +- Additional setting classifications, supported by concrete impact analysis. +- Effective-settings inspection with per-value provenance, separate from raw + file management. +- Further examination of values resolved before crossing an elevation boundary. + +These are out of scope. The initial implementation does not add a machine-wide +overlay, automatic migration, or a new custom-settings authority model. + +## Resources + +- Existing settings documentation: `doc\Settings.md`. +- Settings schema: `schemas\JSON\settings\settings.schema.0.2.json`. +- Export schema: `schemas\JSON\settings\settings.export.schema.0.1.json`. +- Setting definitions: `src\AppInstallerCommonCore\Public\winget\UserSettings.h`. +- Loading and policy-first validation: `src\AppInstallerCommonCore\UserSettings.cpp`. +- Protected paths: `src\AppInstallerCommonCore\Runtime.cpp`. +- Full-token check: `src\AppInstallerSharedLib\Runtime.cpp`. +- CLI management: `src\AppInstallerCLICore\Workflows\SettingsFlow.cpp`. +- Native module-path export: `src\WindowsPackageManager\main.cpp`. +- Module location forwarding: `src\AppInstallerCLICore\ConfigurationDynamicRuntimeFactory.cpp`. +- PowerShell management: `src\PowerShell\Microsoft.WinGet.Client.Engine\Commands\UserSettingsCommand.cs`. +- Native DSC resource: `src\AppInstallerCLICore\Commands\DscUserSettingsFileResource.cpp`. +- PowerShell DSC resource: `src\PowerShell\Microsoft.WinGet.DSC\Microsoft.WinGet.DSC.psm1`. +- Module default and search paths: `src\Microsoft.Management.Configuration.Processor\Public\PowerShellConfigurationSetProcessorFactory.cs`. +- Portable state and hash reads: `src\AppInstallerCLICore\PortableInstaller.cpp`. diff --git a/schemas/JSON/settings/settings.export.schema.0.1.json b/schemas/JSON/settings/settings.export.schema.0.1.json index 5154522fdf..9543e14349 100644 --- a/schemas/JSON/settings/settings.export.schema.0.1.json +++ b/schemas/JSON/settings/settings.export.schema.0.1.json @@ -47,6 +47,11 @@ "description": "Path for the winget's user settings file.", "type": "string", "maxLength": 32767 + }, + "AdminSettingsFile": { + "description": "Path for winget's protected per-user administrator settings file.", + "type": "string", + "maxLength": 32767 } }, "allOf": [ @@ -61,6 +66,12 @@ "userSettingsFile": { "$ref": "#/definitions/UserSettingsFile" } }, "additionalItems": true + }, + { + "properties": { + "adminSettingsFile": { "$ref": "#/definitions/AdminSettingsFile" } + }, + "additionalItems": true } ], "additionalProperties": true diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index c2271e0838..5a331508a5 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -9,7 +9,7 @@ "properties": { "autoUpdateIntervalInMinutes": { "description": "Number of minutes before source update", - "type": "integer", + "type": ["integer", "null"], "default": 5, "minimum": 0, "maximum": 43200 @@ -22,23 +22,24 @@ "properties": { "progressBar": { "description": "Progress bar display style", - "type": "string", + "type": ["string", "null"], "enum": [ "accent", "rainbow", "retro", "sixel", - "disabled" + "disabled", + null ] }, "anonymizeDisplayedPaths": { "description": "Replaces some known folder paths with their respective environment variable", - "type": "boolean", + "type": ["boolean", "null"], "default": true }, "enableSixels": { "description": "Enables output of sixel images in certain contexts", - "type": "boolean", + "type": ["boolean", "null"], "default": false } } @@ -49,18 +50,19 @@ "properties": { "level": { "description": "Preferred logging level", - "type": "string", + "type": ["string", "null"], "enum": [ "verbose", "info", "warning", "error", - "critical" + "critical", + null ] }, "channels": { "description": "The logging channels to enable", - "type": "array", + "type": ["array", "null"], "items": { "uniqueItems": true, "type": "string", @@ -83,12 +85,13 @@ }, "fileNameStrategy": { "description": "Controls the default naming of log files for installers that support it", - "type": "string", + "type": ["string", "null"], "enum": [ "manifest", "timestamp", "guid", - "shortguid" + "shortguid", + null ] }, "file": { @@ -97,25 +100,25 @@ "properties": { "ageLimitInDays": { "description": "The maximum age, in days, of a log file before it is deleted. Set to 0 to disable automatic deletion based on age.", - "type": "integer", + "type": ["integer", "null"], "default": 7, "minimum": 0 }, "totalSizeLimitInMB": { "description": "The maximum total size, in megabytes, of all log files. If the total size exceeds this limit, the oldest files will be deleted first. Set to 0 to disable this limit.", - "type": "integer", + "type": ["integer", "null"], "default": 128, "minimum": 0 }, "countLimit": { "description": "The maximum number of log files to retain. If the number of log files exceeds this limit, the oldest files will be deleted first. Set to 0 (the default) to disable this limit.", - "type": "integer", + "type": ["integer", "null"], "default": 0, "minimum": 0 }, "individualSizeLimitInMB": { "description": "The maximum size, in megabytes, of an individual log file. If a file would exceed this limit, new log lines will overwrite the file from the beginning. Set to 0 to disable this limit.", - "type": "integer", + "type": ["integer", "null"], "default": 16, "minimum": 0 } @@ -129,16 +132,17 @@ "properties": { "scope": { "description": "The scope of a package install", - "type": "string", + "type": ["string", "null"], "enum": [ "user", - "machine" + "machine", + null ], "default": "user" }, "locale": { "description": "The locales of a package install", - "type": "array", + "type": ["array", "null"], "items": { "type": "string", "pattern": "^([a-zA-Z]{2}|[iI]-[a-zA-Z]+|[xX]-[a-zA-Z]{1,8})(-[a-zA-Z]{1,8})*$", @@ -149,7 +153,7 @@ }, "architectures": { "description": "The architecture(s) to use for a package install", - "type": "array", + "type": ["array", "null"], "items": { "uniqueItems": true, "type": "string", @@ -166,7 +170,7 @@ }, "installerTypes": { "description": "The installerType(s) to use for a package install", - "type": "array", + "type": ["array", "null"], "items": { "uniqueItems": true, "type": "string", @@ -196,41 +200,42 @@ "requirements": { "$ref": "#/definitions/InstallPrefReq" }, "skipDependencies": { "description": "Controls whether package dependencies and Windows Features are skipped during installation", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "disableInstallNotes": { "description": "Controls whether installation notes are shown after a successful install", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "portablePackageUserRoot": { - "description": "The default root directory where packages are installed to under User scope. Applies to the portable installer type.", - "type": "string", + "description": "The default root directory where packages are installed to under User scope. Applies to the portable installer type. Values in the ordinary user settings file are ignored when WinGet runs with a non-default full token. Configure this setting in the protected admin-settings.json file using 'winget settings --admin'; its value is shared across token contexts.", + "type": ["string", "null"], "default": "%LOCALAPPDATA%/Microsoft/WinGet/Packages/" }, "portablePackageMachineRoot": { - "description": "The default root directory where packages are installed to under Machine scope. Applies to the portable installer type.", - "type": "string", + "description": "The default root directory where packages are installed to under Machine scope. Applies to the portable installer type. Machine-scope portable package changes require elevation. Values in the ordinary user settings file are ignored when WinGet runs with a non-default full token. Configure this setting in the protected admin-settings.json file using 'winget settings --admin'; its value is shared across token contexts.", + "type": ["string", "null"], "default": "%PROGRAMFILES%/WinGet/Packages/" }, "defaultInstallRoot": { - "description": "Default install location to use for packages that require it when not specified", - "type": "string", + "description": "Default install location to use for packages that require it when not specified. With a non-default full token, only values in the protected admin-settings.json file apply; configure them using 'winget settings --admin'. Without that full token, only values in the ordinary user settings file apply, allowing separate install roots.", + "type": ["string", "null"], "maxLength": 32767 }, "maxResumes": { "description": "The maximum number of resumes allowed for a single resume id. This is to prevent continuous reboots if an install that requires a reboot is not properly detected.", - "type": "integer", + "type": ["integer", "null"], "default": 3, "minimum": 1 }, "archiveExtractionMethod": { "description": "Controls the behavior how the installer extracts archives. The current two supported values are 'shellApi' and 'tar'. 'shellApi' uses the Windows Shell API to extract archives. 'tar' uses the tar command to extract archives.", - "type": "string", + "type": ["string", "null"], "enum": [ "shellApi", - "tar" + "tar", + null ], "default": "shellApi" } @@ -241,8 +246,8 @@ "type": "object", "properties": { "purgePortablePackage": { - "description": "Controls whether the default behavior for uninstall removes all files and directories relevant to this package. Only applies to the portable installerType.", - "type": "boolean", + "description": "Controls whether the default behavior for uninstall removes all files and directories relevant to this package. Only applies to the portable installerType. Values in the ordinary user settings file are ignored when WinGet runs with a non-default full token. Configure this setting in the protected admin-settings.json file using 'winget settings --admin'; its value is shared across token contexts.", + "type": ["boolean", "null"], "default": false } } @@ -252,8 +257,8 @@ "type": "object", "properties": { "defaultModuleRoot": { - "description": "The default root directory where PowerShell modules are installed to when applying a configuration.", - "type": "string", + "description": "The default root directory where PowerShell modules are installed to when applying a configuration. With a non-default full token, only values in the protected admin-settings.json file apply; configure them using 'winget settings --admin'. Without that full token, only values in the ordinary user settings file apply, allowing separate module roots. The default below is for ordinary-token use; the full-token default is a separate administrator-writable per-user module directory.", + "type": ["string", "null"], "maxLength": 32767, "default": "%LOCALAPPDATA%/Microsoft/WinGet/Configuration/Modules/" } @@ -264,8 +269,8 @@ "type": "object", "properties": { "defaultDownloadDirectory": { - "description": "The default directory where installers are downloaded to.", - "type": "string", + "description": "The default directory where installers are downloaded to. Values in the ordinary user settings file are ignored when WinGet runs with a non-default full token. Configure this setting in the protected admin-settings.json file using 'winget settings --admin'; its value is shared across token contexts.", + "type": ["string", "null"], "default": "%USERPROFILE%/Downloads/" } } @@ -276,7 +281,7 @@ "properties": { "disable": { "description": "Controls whether telemetry events are written", - "type": "boolean", + "type": ["boolean", "null"], "default": false } } @@ -287,17 +292,18 @@ "properties": { "downloader": { "description": "Control which download code is used for packages", - "type": "string", + "type": ["string", "null"], "enum": [ "default", "wininet", - "do" + "do", + null ], "default": "default" }, "doProgressTimeoutInSeconds": { "description": "Number of seconds to wait without progress before fallback", - "type": "integer", + "type": ["integer", "null"], "default": 60, "minimum": 1, "maximum": 600 @@ -310,7 +316,7 @@ "properties": { "disable": { "description": "Controls whether interactive prompts are shown by the Windows Package Manager client", - "type": "boolean", + "type": ["boolean", "null"], "default": false } } @@ -321,37 +327,37 @@ "properties": { "experimentalCMD": { "description": "Reference implementation for an experimental command", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "experimentalARG": { "description": "Reference implementation for an experimental argument", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "directMSI": { "description": "Enable use of MSI APIs rather than msiexec for MSI installs", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "fonts": { "description": "Enable support for managing fonts", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "interactivePackageSelection": { "description": "Enable interactive selection for ambiguous package matches", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "resume": { "description": "Enable support for some commands to resume", - "type": "boolean", + "type": ["boolean", "null"], "default": false }, "deltaIndex": { "description": "Enable acquiring source index updates as a delta against a baseline, rather than downloading the full index", - "type": "boolean", + "type": ["boolean", "null"], "default": false } } @@ -362,7 +368,7 @@ "properties": { "sortOrder": { "description": "Fields to sort output by, in priority order. An empty array results in default sorting.", - "type": "array", + "type": ["array", "null"], "items": { "type": "string", "enum": [ @@ -377,16 +383,17 @@ }, "sortDirection": { "description": "Sort direction for output ordering", - "type": "string", + "type": ["string", "null"], "enum": [ "ascending", - "descending" + "descending", + null ], "default": "ascending" }, "locale": { "description": "Overrides winget interface output language using a supported locale", - "type": "string", + "type": ["string", "null"], "enum": [ "en-US", "de-DE", @@ -398,7 +405,8 @@ "pt-BR", "ru-RU", "zh-CN", - "zh-TW" + "zh-TW", + null ] } } diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp index 1cd85d86ac..d57496cd8e 100644 --- a/src/AppInstallerCLICore/Argument.cpp +++ b/src/AppInstallerCLICore/Argument.cpp @@ -161,6 +161,10 @@ namespace AppInstaller::CLI return { type, "ignore-versions"_liv }; // Setting Command + case Execution::Args::Type::AdminSettingsFile: + return { type, "admin"_liv }; + case Execution::Args::Type::CreateAdminSettingsFile: + return { type, "create"_liv }; case Execution::Args::Type::AdminSettingEnable: return { type, "enable"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::EnableDisable }; case Execution::Args::Type::AdminSettingDisable: diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp index 6a4f58aaba..a5e7fba178 100644 --- a/src/AppInstallerCLICore/Command.cpp +++ b/src/AppInstallerCLICore/Command.cpp @@ -4,6 +4,7 @@ #include "Command.h" #include "Resources.h" #include "Sixel.h" +#include "Workflows/SettingsFlow.h" #include #include #include @@ -1161,10 +1162,14 @@ namespace AppInstaller::CLI { try { - if (!Settings::User().GetWarnings().empty() && - !WI_IsFlagSet(command->GetOutputFlags(), CommandOutputFlags::IgnoreSettingsWarnings)) + if (!WI_IsFlagSet(command->GetOutputFlags(), CommandOutputFlags::IgnoreSettingsWarnings)) { - context.Reporter.Warn() << Resource::String::SettingsWarnings << std::endl; + context << Workflow::ShowSettingsWarnings{ Workflow::SettingsWarningType::Migration }; + if (std::any_of(Settings::User().GetWarnings().begin(), Settings::User().GetWarnings().end(), + [](const auto& warning) { return !warning.IsMigrationWarning; })) + { + context.Reporter.Warn() << Resource::String::SettingsWarnings << std::endl; + } } command->Execute(context); diff --git a/src/AppInstallerCLICore/Commands/DscUserSettingsFileResource.cpp b/src/AppInstallerCLICore/Commands/DscUserSettingsFileResource.cpp index 1a0a9ba306..039554339f 100644 --- a/src/AppInstallerCLICore/Commands/DscUserSettingsFileResource.cpp +++ b/src/AppInstallerCLICore/Commands/DscUserSettingsFileResource.cpp @@ -5,6 +5,7 @@ #include "DscComposableObject.h" #include "Resources.h" #include "AppInstallerStrings.h" +#include using namespace AppInstaller::Utility::literals; using namespace AppInstaller::Settings; @@ -18,20 +19,17 @@ namespace AppInstaller::CLI { WINGET_DSC_DEFINE_COMPOSABLE_PROPERTY_FLAGS(SettingsProperty, Json::Value, Settings, "settings", DscComposablePropertyFlag::Required | DscComposablePropertyFlag::CopyToOutput, Resource::String::DscResourcePropertyDescriptionUserSettingsFileSettings); WINGET_DSC_DEFINE_COMPOSABLE_PROPERTY_ENUM(ActionProperty, std::string, Action, "action", Resource::String::DscResourcePropertyDescriptionUserSettingsFileAction, ({ ACTION_PARTIAL, ACTION_FULL }), ACTION_PARTIAL); + WINGET_DSC_DEFINE_COMPOSABLE_PROPERTY_DEFAULT(AdminProperty, bool, Admin, "admin", Resource::String::DscResourcePropertyDescriptionUserSettingsFileAdmin, false); - using UserSettingsFileResourceObject = DscComposableObject; + using UserSettingsFileResourceObject = DscComposableObject; struct UserSettingsFileFunctionData { - UserSettingsFileFunctionData() - : UserSettingsFileFunctionData(std::nullopt, true) - { - } - UserSettingsFileFunctionData(const std::optional& json, bool ignoreFieldRequirements = false) : Input(json, ignoreFieldRequirements), - _userSettingsPath(UserSettings::SettingsFilePath()) + _userSettingsPath(Input.Admin().value_or(false) ? UserSettings::AdminSettingsFilePath() : UserSettings::SettingsFilePath()) { + Output.Admin(Input.Admin().value_or(false)); } const UserSettingsFileResourceObject Input; @@ -82,12 +80,18 @@ namespace AppInstaller::CLI bool WriteOutput() { THROW_HR_IF(E_UNEXPECTED, !Output.Settings().has_value()); + Json::StreamWriterBuilder writer; + writer["indentation"] = " "; + auto content = Json::writeString(writer, *Output.Settings()); + if (Input.Admin().value_or(false)) + { + return Stream{ Stream::PrimaryAdminSettingsFile }.Set(content); + } + std::ofstream file(_userSettingsPath, std::ios::binary); if (file) { - Json::StreamWriterBuilder writer; - writer["indentation"] = " "; - file << Json::writeString(writer, *Output.Settings()); + file << content; return true; } @@ -207,6 +211,12 @@ namespace AppInstaller::CLI { UserSettingsFileFunctionData data{ json }; + if (data.Input.Admin().value_or(false)) + { + context << Workflow::EnsureRunningAsAdmin; + AICLI_RETURN_IF_TERMINATED(context); + } + data.Get(); // Capture the diff before updating the output @@ -243,7 +253,7 @@ namespace AppInstaller::CLI void DscUserSettingsFileResource::ResourceFunctionExport(Execution::Context& context) const { - UserSettingsFileFunctionData data; + UserSettingsFileFunctionData data{ GetJsonFromInput(context, false), true }; data.Get(); diff --git a/src/AppInstallerCLICore/Commands/SettingsCommand.cpp b/src/AppInstallerCLICore/Commands/SettingsCommand.cpp index 05889803a6..ffd36764d8 100644 --- a/src/AppInstallerCLICore/Commands/SettingsCommand.cpp +++ b/src/AppInstallerCLICore/Commands/SettingsCommand.cpp @@ -28,6 +28,8 @@ namespace AppInstaller::CLI std::vector SettingsCommand::GetArguments() const { return { + Argument{ Execution::Args::Type::AdminSettingsFile, Resource::String::AdminSettingsFileArgumentDescription, ArgumentType::Flag }, + Argument{ Execution::Args::Type::CreateAdminSettingsFile, Resource::String::AdminSettingsFileCreateArgumentDescription, ArgumentType::Flag, Argument::Visibility::Hidden }, Argument{ Execution::Args::Type::AdminSettingEnable, Resource::String::AdminSettingEnableDescription, ArgumentType::Standard, Argument::Visibility::Help }, Argument{ Execution::Args::Type::AdminSettingDisable, Resource::String::AdminSettingDisableDescription, ArgumentType::Standard, Argument::Visibility::Help }, }; @@ -50,6 +52,13 @@ namespace AppInstaller::CLI void SettingsCommand::ValidateArgumentsInternal(Execution::Args& execArgs) const { + if (execArgs.Contains(Execution::Args::Type::AdminSettingsFile) && + (execArgs.Contains(Execution::Args::Type::AdminSettingEnable) || execArgs.Contains(Execution::Args::Type::AdminSettingDisable))) + { + throw CommandException(Resource::String::MultipleExclusiveArgumentsProvided("admin|enable|disable"_liv)); + } + Argument::ValidateArgumentDependency(execArgs, Execution::Args::Type::CreateAdminSettingsFile, Execution::Args::Type::AdminSettingsFile); + // Get admin setting string for all available options except Unknown std::vector adminSettingList; for (auto setting : GetAllSequentialEnumValues(BoolAdminSetting::Unknown)) @@ -72,7 +81,11 @@ namespace AppInstaller::CLI void SettingsCommand::ExecuteInternal(Execution::Context& context) const { - if (context.Args.Contains(Execution::Args::Type::AdminSettingEnable)) + if (context.Args.Contains(Execution::Args::Type::CreateAdminSettingsFile)) + { + context << Workflow::EnsureRunningAsAdmin << Workflow::CreateAdminSettingsFile; + } + else if (context.Args.Contains(Execution::Args::Type::AdminSettingEnable)) { context << Workflow::EnsureRunningAsAdmin << diff --git a/src/AppInstallerCLICore/ConfigurationCommon.cpp b/src/AppInstallerCLICore/ConfigurationCommon.cpp index 21b4453a8a..d6847b3233 100644 --- a/src/AppInstallerCLICore/ConfigurationCommon.cpp +++ b/src/AppInstallerCLICore/ConfigurationCommon.cpp @@ -88,6 +88,12 @@ namespace AppInstaller::CLI void SetModulePath(Execution::Context& context, IConfigurationSetProcessorFactory const& factory) { + // Ignore settings as the server will retrieve the effective module path from the settings file if needed. + if (!context.Args.Contains(Execution::Args::Type::ConfigurationModulePath)) + { + return; + } + auto pwshFactory = factory.as(); auto modulePath = GetModulePathInfo(context.Args); diff --git a/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp b/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp index e98c4bb349..a52e094bea 100644 --- a/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp +++ b/src/AppInstallerCLICore/ConfigurationDynamicRuntimeFactory.cpp @@ -92,6 +92,12 @@ namespace AppInstaller::CLI::ConfigurationRemoting auto pwshFactory = m_defaultRemoteFactory.as(); pwshFactory.Location(value); m_location = value; + m_locationSet = true; + } + + bool HasExplicitModuleLocation() const + { + return m_locationSet; } winrt::hstring CustomLocation() const @@ -143,6 +149,7 @@ namespace AppInstaller::CLI::ConfigurationRemoting std::mutex m_diagnosticsMutex; DiagnosticLevel m_minimumLevel = DiagnosticLevel::Informational; SetProcessorFactory::PwshConfigurationProcessorLocation m_location = SetProcessorFactory::PwshConfigurationProcessorLocation::Default; + bool m_locationSet = false; winrt::hstring m_customLocation; ProcessorEngine m_processorEngine; std::map m_factoryMapValues; @@ -328,6 +335,10 @@ namespace AppInstaller::CLI::ConfigurationRemoting locationString = Utility::ConvertToUTF8(m_dynamicFactory->CustomLocation()); break; case SetProcessorFactory::PwshConfigurationProcessorLocation::Default: + if (m_dynamicFactory->HasExplicitModuleLocation()) + { + locationString = "Default"; + } break; } diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index 97d476b066..0a56f30812 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -97,6 +97,8 @@ namespace AppInstaller::CLI::Execution OSVersion, // Setting Command + AdminSettingsFile, + CreateAdminSettingsFile, AdminSettingEnable, AdminSettingDisable, SettingName, diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index f208024b3c..a570f66592 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -29,6 +29,9 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(AdminSettingEnabled); WINGET_DEFINE_RESOURCE_STRINGID(AdminSettingEnableDescription); WINGET_DEFINE_RESOURCE_STRINGID(AdminSettingHeader); + WINGET_DEFINE_RESOURCE_STRINGID(AdminSettingsFileArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(AdminSettingsFileCreateArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(AdminSettingsFileRequiresCreation); WINGET_DEFINE_RESOURCE_STRINGID(AllowRebootArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(ArchitectureArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(ArchiveFailedMalwareScan); @@ -253,6 +256,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(DscUserSettingsFileLongDescription); WINGET_DEFINE_RESOURCE_STRINGID(DscResourcePropertyDescriptionUserSettingsFileSettings); WINGET_DEFINE_RESOURCE_STRINGID(DscResourcePropertyDescriptionUserSettingsFileAction); + WINGET_DEFINE_RESOURCE_STRINGID(DscResourcePropertyDescriptionUserSettingsFileAdmin); WINGET_DEFINE_RESOURCE_STRINGID(DscResourcePropertyDescriptionSourceName); WINGET_DEFINE_RESOURCE_STRINGID(DscResourcePropertyDescriptionSourceArgument); WINGET_DEFINE_RESOURCE_STRINGID(DscResourcePropertyDescriptionSourceType); diff --git a/src/AppInstallerCLICore/Workflows/SettingsFlow.cpp b/src/AppInstallerCLICore/Workflows/SettingsFlow.cpp index 837bbf55d8..4b45accfe6 100644 --- a/src/AppInstallerCLICore/Workflows/SettingsFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/SettingsFlow.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace AppInstaller::CLI::Workflow { @@ -21,6 +22,7 @@ namespace AppInstaller::CLI::Workflow root["$schema"] = "https://aka.ms/winget-settings-export.schema.json"; root["adminSettings"] = Json::ValueType::objectValue; root["userSettingsFile"] = UserSettings::SettingsFilePath().u8string(); + root["adminSettingsFile"] = UserSettings::AdminSettingsFilePath().u8string(); } void AddAdminSetting(BoolAdminSetting setting) @@ -119,49 +121,43 @@ namespace AppInstaller::CLI::Workflow context.Reporter.Info() << Resource::String::ResetAllAdminSettingsSucceeded << std::endl; } - void OpenUserSetting(Execution::Context& context) + void ShowSettingsWarnings::operator()(Execution::Context& context) const { - // Show warnings only when the setting command is executed. - if (!User().GetWarnings().empty()) + const bool migrationOnly = m_type == SettingsWarningType::Migration; + bool headerShown = false; + for (const auto& warning : User().GetWarnings()) { - context.Reporter.Warn() << Resource::String::SettingLoadFailure << std::endl; - for (const auto& warning : User().GetWarnings()) + if (warning.IsMigrationWarning == migrationOnly) { - auto warn = context.Reporter.Warn(); - warn << warning.Message; - if (!warning.Path.empty()) + if (!headerShown && !migrationOnly) { - if (warning.IsFieldWarning) - { - warn << ' ' << Resource::String::SettingsWarningField(warning.Path); - } - else - { - warn << ' ' << warning.Path; - } + context.Reporter.Warn() << Resource::String::SettingLoadFailure << std::endl; + headerShown = true; } + context.Reporter.Warn() << warning.Message(warning.Path, warning.Data, warning.Source) << std::endl; + } + } + } - if (!warning.Data.empty()) - { - if (warning.IsFieldWarning) - { - warn << ' ' << Resource::String::SettingsWarningValue(warning.Data); - } - else - { - warn << - std::endl << - warning.Data; - } - } + void CreateAdminSettingsFile(Execution::Context&) + { + UserSettings::CreateAdminSettingsFile(); + } - warn << std::endl; - } + void OpenUserSetting(Execution::Context& context) + { + context << ShowSettingsWarnings{ SettingsWarningType::NonMigration }; + const bool admin = context.Args.Contains(Execution::Args::Type::AdminSettingsFile); + const auto filePath = admin ? UserSettings::AdminSettingsFilePath() : UserSettings::SettingsFilePath(); + if (admin && !Runtime::IsRunningAsAdminOrSystem() && !std::filesystem::exists(filePath)) + { + context.Reporter.Error() << Resource::String::AdminSettingsFileRequiresCreation(LocIndString{ filePath.u8string() }) << std::endl; + AICLI_TERMINATE_CONTEXT(E_ACCESSDENIED); } - User().PrepareToShellExecuteFile(); + User().PrepareToShellExecuteFile(admin); - auto filePathUTF16 = UserSettings::SettingsFilePath().wstring(); + auto filePathUTF16 = filePath.wstring(); // Some versions of windows will fail if no file extension association exists, other will pop up the dialog // to make the user pick their default. diff --git a/src/AppInstallerCLICore/Workflows/SettingsFlow.h b/src/AppInstallerCLICore/Workflows/SettingsFlow.h index fe1c705f0c..c9f97a999c 100644 --- a/src/AppInstallerCLICore/Workflows/SettingsFlow.h +++ b/src/AppInstallerCLICore/Workflows/SettingsFlow.h @@ -5,6 +5,27 @@ namespace AppInstaller::CLI::Workflow { + enum class SettingsWarningType + { + Migration, + NonMigration, + }; + + // Displays settings warnings of the selected type. + struct ShowSettingsWarnings : public WorkflowTask + { + explicit ShowSettingsWarnings(SettingsWarningType type) : + WorkflowTask("ShowSettingsWarnings"), m_type(type) {} + + void operator()(Execution::Context& context) const override; + + private: + SettingsWarningType m_type; + }; + + // Creates the protected settings file without opening it or creating a backup. + void CreateAdminSettingsFile(Execution::Context& context); + // Enables an admin setting. // Required Args: AdminSettingEnable // Inputs: None @@ -35,7 +56,7 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void ResetAllAdminSettings(Execution::Context& context); - // Opens the user settings. + // Opens the ordinary or protected per-user settings file. // Required Args: None // Inputs: None // Outputs: None diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 0b3dd44c37..a53a86c8c6 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -517,8 +517,8 @@ They can be configured through the settings file 'winget settings'. The following failures were found validating the settings: - Open settings in the default json text editor. If no editor is configured, opens settings in notepad. For available settings see https://aka.ms/winget-settings This command can also be used to set administrator settings by providing the --enable or --disable arguments - {Locked="--enable"} {Locked="--disable"} + Open settings in the default json text editor. If no editor is configured, opens settings in notepad. Use --admin to open the protected per-user administrator settings file; creating or changing this file requires administrator privileges. For available settings see https://aka.ms/winget-settings. This command can also be used to set administrator capability controls by providing the --enable or --disable arguments. + {Locked="--admin", "--enable", "--disable", "https://aka.ms/winget-settings"} The schema-compatible administrator settings file is distinct from administrator capability controls. Open settings or set administrator settings @@ -1001,19 +1001,37 @@ They can be configured through the settings file 'winget settings'. {Locked="{0}"} Warning message displayed when a user setting field has invalid syntax or semantics. {0} is a placeholder replaced by the setting field path. - Invalid field format. + Invalid field format. Source: {2}. Field: {0}. + {Locked="{0}", "{2}"} {0} is the setting path and {2} is its settings file or custom-content source. {1} is unused. - Invalid field value. + Invalid field value. Source: {2}. Field: {0}. Value: {1}. + {Locked="{0}", "{1}", "{2}"} {0} is the setting path, {1} is the invalid value, and {2} is its settings file or custom-content source. + + + The settings file '{0}' must contain a JSON object. + {Locked="{0}"} {0} is the settings file name. - Invalid setting from Group Policy. + Invalid setting from Group Policy. Field: {0}. Value: {1}. + {Locked="{0}", "{1}"} {0} is the setting path and {1} is the invalid policy value. + + + The setting '{0}' from '{2}' is ignored when running with the elevated token. The default is used unless Group Policy supplies a value. Configure an administrator-approved value in admin-settings.json using 'winget settings --admin'. + {Locked="{0}", "{2}", "admin-settings.json", "winget settings --admin"} {0} is the ignored setting path and {2} is its unprotected settings file name. {1} is unused. No eligible protected replacement was configured. - Loaded settings from backup file. + Loaded settings from backup file '{2}'. + {Locked="{2}"} {2} is the backup settings file name. {0} and {1} are unused. - Error parsing file: + Error parsing settings file '{0}': +{1} + {Locked="{0}", "{1}"} {0} is the settings file or custom-content source name and {1} is the parser diagnostic. + + + Error reading settings file '{0}'. {1} + {Locked="{0}", "{1}"} {0} is the settings file name and {1} is an optional read-error diagnostic. Value: {0} @@ -1287,6 +1305,18 @@ Do you agree to the terms? Enables the specific administrator setting + + Opens the per-user administrator settings file + Describes the --admin flag of winget settings, not the existing administrator capability controls. + + + Creates the settings file without opening it + Hidden headless provisioning flag used by PowerShell. Requires --admin and administrator privileges. + + + The administrator settings file does not exist: {0}. Run 'winget settings --admin' as administrator to create it. + {Locked="{0}", "winget settings --admin"} {0} is the full protected settings file path. Displayed when an unelevated open cannot create it. + Disables the specific administrator setting @@ -1698,7 +1728,8 @@ Please specify one of them using the --source option to proceed. Label displayed for the file containing the user settings. - Settings file couldn't load. Using default values. + Settings file '{2}' couldn't load. Using default values. + {Locked="{2}"} {2} is the settings file whose primary/backup load failed. {0} and {1} are unused. Select installed package scope filter (user or machine) @@ -3438,7 +3469,7 @@ Please specify one of them using the --source option to proceed. Manage user settings file - Manage the user settings of WinGet. + Manage the ordinary or protected per-user settings file of WinGet. {Locked="WinGet"} @@ -3447,6 +3478,10 @@ Please specify one of them using the --source option to proceed. The action used to apply the settings. + + Select the protected per-user administrator settings file instead of the ordinary user settings file. Set requires administrator privileges; Get, Test, and Export do not. This does not select administrator capability controls. + Describes the optional Boolean admin property on the native DSCv3 UserSettingsFile resource. The default is false. + Value is logged for correlation @@ -3690,4 +3725,7 @@ An unlocalized JSON fragment will follow on another line. The DSC processor path no longer refers to the file that was verified. An error message shown when the file that a configuration processor (DSC) executable path points to has been replaced or swapped after it was verified. - \ No newline at end of file + + The package installed for user scope cannot be uninstalled when running with administrator privileges. + + diff --git a/src/AppInstallerCLITests/Runtime.cpp b/src/AppInstallerCLITests/Runtime.cpp index 59bb50c22f..1cee8a1d67 100644 --- a/src/AppInstallerCLITests/Runtime.cpp +++ b/src/AppInstallerCLITests/Runtime.cpp @@ -289,6 +289,79 @@ TEST_CASE("ApplyACL_BothOwners", "[runtime]") } } +TEST_CASE("ConfigurationModuleDefaults_TokenContext", "[runtime]") +{ + const bool fullToken = GENERATE(false, true); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(fullToken); + TestUserSettings settings; + settings.Set(false); + + Runtime::TestHook_ClearPathOverrides(); + auto restorePaths = wil::scope_exit([]() { TestCommon::SetTestPathOverrides(); }); + const auto details = Runtime::GetPathDetailsFor(Runtime::PathName::ConfigurationModules, true); + REQUIRE_FALSE(details.Create); + REQUIRE(Runtime::GetPathTo(Runtime::PathName::DefaultConfigurationModules, true) == details.Path); + + if (fullToken) + { + const auto protectedPath = Runtime::GetPathDetailsFor(Runtime::PathName::SecureSettingsForRead, true).Path; + REQUIRE(details.Path == protectedPath / "Configuration\\Modules"); + REQUIRE(details.Owner == ACEPrincipal::Admins); + REQUIRE(details.ACL.at(ACEPrincipal::Admins) == ACEPermissions::All); + REQUIRE(details.ACL.at(ACEPrincipal::System) == ACEPermissions::All); + if (!IsRunningAsSystem()) + { + REQUIRE(details.ACL.at(ACEPrincipal::CurrentUser) == ACEPermissions::ReadExecute); + } + } + else + { + REQUIRE(details.Path == Filesystem::GetKnownFolderPath(FOLDERID_LocalAppData) / "Microsoft\\WinGet\\Configuration\\Modules"); + REQUIRE_FALSE(details.Owner.has_value()); + REQUIRE(details.ACL.empty()); + } + + settings.Set("C:\\Explicit\\Modules"); + const auto customDetails = Runtime::GetPathDetailsFor(Runtime::PathName::ConfigurationModules, true); + REQUIRE(customDetails.Path == "C:\\Explicit\\Modules"); + REQUIRE_FALSE(customDetails.Create); + REQUIRE(customDetails.ACL.empty()); + REQUIRE(Runtime::GetPathTo(Runtime::PathName::DefaultConfigurationModules, true) == details.Path); + settings.Set(true); + const auto expectedDisplayPath = fullToken ? details.Path : + std::filesystem::path{ "%LOCALAPPDATA%\\Microsoft\\WinGet\\Configuration\\Modules" }; + REQUIRE(Runtime::GetPathTo(Runtime::PathName::DefaultConfigurationModules, true) == expectedDisplayPath); +} + +TEST_CASE("ConfigurationModuleDefaults_ProtectedStorage", "[runtime]") +{ + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(true); + TestUserSettings settings; + settings.Set(false); + TempDirectory directory("ProtectedModuleDefault", false); + Runtime::TestHook_ClearPathOverrides(); + auto restorePaths = wil::scope_exit([]() { TestCommon::SetTestPathOverrides(); }); + auto parentDetails = Runtime::GetPathDetailsFor(Runtime::PathName::SecureSettingsForWrite, true); + parentDetails.Path = directory; + Runtime::TestHook_SetPathOverride(Runtime::PathName::SecureSettingsForWrite, parentDetails); + + const auto expectedPath = directory.GetPath() / "Configuration\\Modules"; + REQUIRE(Runtime::GetPathTo(Runtime::PathName::DefaultConfigurationModules, true) == expectedPath); + REQUIRE_FALSE(std::filesystem::exists(directory)); + + if (!IsRunningAsAdminOrSystem()) + { + WARN("Protected module directory creation requires administrator or SYSTEM privileges."); + return; + } + + REQUIRE(Runtime::GetPathTo(Runtime::PathName::DefaultConfigurationModules) == expectedPath); + REQUIRE(std::filesystem::is_directory(expectedPath)); + RequireAdminOwner(directory); + RequireAdminOwner(expectedPath); + REQUIRE_FALSE(parentDetails.ShouldApplyACL()); +} + TEST_CASE("ApplyACL_CurrentUserOwner_SystemAll", "[runtime]") { TempDirectory directory("UserAndSystem"); diff --git a/src/AppInstallerCLITests/Settings.cpp b/src/AppInstallerCLITests/Settings.cpp index 80e28cf025..adf9b2374e 100644 --- a/src/AppInstallerCLITests/Settings.cpp +++ b/src/AppInstallerCLITests/Settings.cpp @@ -2,15 +2,67 @@ // Licensed under the MIT License. #include "pch.h" #include "TestCommon.h" +#include "TestHooks.h" #include #include #include +#include using namespace AppInstaller::Runtime; using namespace AppInstaller::Settings; using namespace AppInstaller::Utility; +namespace +{ + struct AdminFileSettingsTest + { + TestCommon::TempDirectory Directory{ "AdminFileSettings", false }; + + AdminFileSettingsTest() : + m_originalRead(GetPathDetailsFor(PathName::SecureSettingsForRead)), + m_originalWrite(GetPathDetailsFor(PathName::SecureSettingsForWrite)) + { + AppInstaller::Filesystem::PathDetails read; + read.Path = Directory; + read.Create = false; + TestHook_SetPathOverride(PathName::SecureSettingsForRead, read); + + auto write = read; + write.Create = true; + write.SetOwner(AppInstaller::Filesystem::ACEPrincipal::Admins); + if (!IsRunningAsSystem()) + { + write.ACL[AppInstaller::Filesystem::ACEPrincipal::CurrentUser] = AppInstaller::Filesystem::ACEPermissions::ReadExecute; + } + write.ACL[AppInstaller::Filesystem::ACEPrincipal::System] = AppInstaller::Filesystem::ACEPermissions::All; + TestHook_SetPathOverride(PathName::SecureSettingsForWrite, write); + } + + ~AdminFileSettingsTest() + { + TestHook_SetPathOverride(PathName::SecureSettingsForRead, m_originalRead); + TestHook_SetPathOverride(PathName::SecureSettingsForWrite, m_originalWrite); + } + + private: + AppInstaller::Filesystem::PathDetails m_originalRead; + AppInstaller::Filesystem::PathDetails m_originalWrite; + }; + + wil::unique_handle CreateNonAdminToken() + { + wil::unique_handle processToken; + THROW_IF_WIN32_BOOL_FALSE(OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY, &processToken)); + + auto adminSID = wil::make_static_sid(SECURITY_NT_AUTHORITY, SECURITY_BUILTIN_DOMAIN_RID, DOMAIN_ALIAS_RID_ADMINS); + SID_AND_ATTRIBUTES disabledGroup{ adminSID.get(), 0 }; + wil::unique_handle restrictedToken; + THROW_IF_WIN32_BOOL_FALSE(CreateRestrictedToken(processToken.get(), DISABLE_MAX_PRIVILEGE, 1, &disabledGroup, 0, nullptr, 0, nullptr, &restrictedToken)); + return restrictedToken; + } +} + TEST_CASE("ReadEmptySetting", "[settings]") { StreamDefinition name{ Type::Standard, "nonexistentsetting" }; @@ -94,6 +146,231 @@ TEST_CASE("ReadEmptySecureSetting", "[settings]") REQUIRE(!result); } +TEST_CASE("AdminFileSettings_ReadDoesNotCreateDirectory", "[settings]") +{ + AdminFileSettingsTest test; + if (!IsRunningAsAdminOrSystem()) + { + TestCommon::UserSettingsTest settings{ "{}" }; + REQUIRE_NOTHROW(settings.PrepareToShellExecuteFile(true)); + REQUIRE_THROWS_HR(UserSettings::CreateAdminSettingsFile(), E_ACCESSDENIED); + REQUIRE_FALSE(std::filesystem::exists(test.Directory)); + } + + for (const auto& definition : { Stream::PrimaryAdminSettingsFile, Stream::BackupAdminSettingsFile }) + { + Stream stream{ definition }; + REQUIRE(stream.GetPath() == test.Directory.GetPath() / definition.Name); + REQUIRE_FALSE(stream.Get()); + REQUIRE_FALSE(std::filesystem::exists(test.Directory)); + + if (IsRunningAsAdminOrSystem()) + { + stream.Remove(); + REQUIRE_FALSE(std::filesystem::exists(test.Directory)); + } + } +} + +TEST_CASE("AdminFileSettings_PlainFileStorage", "[settings]") +{ + if (!IsRunningAsAdminOrSystem()) + { + WARN("Test requires admin privilege. Skipped."); + return; + } + + AdminFileSettingsTest test; + const std::string value = "{\"value\":\"" + std::string(9000, 'x') + "\"}"; + const std::string editedValue = "{\"value\":\"edited\"}"; + + for (const auto& definition : { Stream::PrimaryAdminSettingsFile, Stream::BackupAdminSettingsFile }) + { + Stream stream{ definition }; + REQUIRE(stream.Set(value)); + + auto result = stream.Get(); + REQUIRE(result); + REQUIRE(ReadEntireStream(*result) == value); + result.reset(); + + { + std::ifstream file{ stream.GetPath(), std::ios::binary }; + REQUIRE(ReadEntireStream(file) == value); + } + + { + std::ofstream file{ stream.GetPath(), std::ios::binary | std::ios::trunc }; + file << editedValue; + REQUIRE(file.good()); + } + + result = stream.Get(); + REQUIRE(result); + REQUIRE(ReadEntireStream(*result) == editedValue); + result.reset(); + } + + REQUIRE_FALSE(GetPathDetailsFor(PathName::SecureSettingsForWrite).ShouldApplyACL()); + REQUIRE(std::distance(std::filesystem::directory_iterator(test.Directory), std::filesystem::directory_iterator{}) == 2); + + Stream{ Stream::PrimaryAdminSettingsFile }.Remove(); + REQUIRE_FALSE(Stream{ Stream::PrimaryAdminSettingsFile }.Get()); + REQUIRE(Stream{ Stream::BackupAdminSettingsFile }.Get()); + + Stream{ Stream::BackupAdminSettingsFile }.Remove(); + REQUIRE_FALSE(Stream{ Stream::BackupAdminSettingsFile }.Get()); +} + +TEST_CASE("AdminFileSettings_MutationsRequireAdmin", "[settings]") +{ + if (IsRunningAsSystem()) + { + WARN("Test requires a non-SYSTEM user. Skipped."); + return; + } + + AdminFileSettingsTest test; + std::filesystem::create_directories(test.Directory); + Stream primary{ Stream::PrimaryAdminSettingsFile }; + const std::string value = "{\"value\":\"original\"}"; + { + std::ofstream file{ primary.GetPath(), std::ios::binary }; + file << value; + REQUIRE(file.good()); + } + + auto token = CreateNonAdminToken(); + THROW_IF_WIN32_BOOL_FALSE(ImpersonateLoggedOnUser(token.get())); + auto revert = wil::scope_exit([]() { FAIL_FAST_IF_WIN32_BOOL_FALSE(RevertToSelf()); }); + REQUIRE_FALSE(IsRunningAsAdminOrSystem()); + + REQUIRE_THROWS_HR(UserSettings::CreateAdminSettingsFile(), E_ACCESSDENIED); + TestCommon::UserSettingsTest settings{ "{}" }; + REQUIRE_NOTHROW(settings.PrepareToShellExecuteFile(true)); + + for (const auto& definition : { Stream::PrimaryAdminSettingsFile, Stream::BackupAdminSettingsFile }) + { + Stream stream{ definition }; + REQUIRE_THROWS_HR(stream.Set("{}"), E_ACCESSDENIED); + REQUIRE_THROWS_HR(stream.Remove(), E_ACCESSDENIED); + } + + auto result = primary.Get(); + REQUIRE(result); + REQUIRE(ReadEntireStream(*result) == value); + REQUIRE_FALSE(Stream{ Stream::BackupAdminSettingsFile }.Get()); +} + +TEST_CASE("AdminFileSettings_ManagementCreation", "[settings]") +{ + if (!IsRunningAsAdminOrSystem()) + { + WARN("Test requires admin privilege. Skipped."); + return; + } + + AdminFileSettingsTest test; + const bool headless = GENERATE(false, true); + TestCommon::UserSettingsTest settings{ "{}" }; + if (headless) + { + UserSettings::CreateAdminSettingsFile(); + } + else + { + settings.PrepareToShellExecuteFile(true); + } + + auto content = Stream{ Stream::PrimaryAdminSettingsFile }.Get(); + REQUIRE(content); + REQUIRE(ReadEntireStream(*content).find("https://aka.ms/winget-settings.schema.json") != std::string::npos); + content.reset(); + REQUIRE_FALSE(Stream{ Stream::BackupAdminSettingsFile }.Get()); + + const std::string original = R"({ "visual": { "progressBar": "rainbow" } })"; + REQUIRE(Stream{ Stream::PrimaryAdminSettingsFile }.Set(original)); + UserSettings::CreateAdminSettingsFile(); + content = Stream{ Stream::PrimaryAdminSettingsFile }.Get(); + REQUIRE(content); + REQUIRE(ReadEntireStream(*content) == original); + REQUIRE_FALSE(Stream{ Stream::BackupAdminSettingsFile }.Get()); +} + +TEST_CASE("AdminFileSettings_ManagementBackup", "[settings]") +{ + if (!IsRunningAsAdminOrSystem()) + { + WARN("Test requires admin privilege. Skipped."); + return; + } + + AdminFileSettingsTest test; + const std::string primary = GENERATE(std::string{ "{}" }, std::string{ "{" }, std::string{ "[]" }); + const bool repairAfterLoad = GENERATE(false, true); + CAPTURE(primary, repairAfterLoad); + const std::string backup = R"({ "visual": { "progressBar": "rainbow" } })"; + REQUIRE(Stream{ Stream::PrimaryAdminSettingsFile }.Set(primary)); + REQUIRE(Stream{ Stream::BackupAdminSettingsFile }.Set(backup)); + + TestCommon::UserSettingsTest settings; + if (repairAfterLoad) + { + REQUIRE(Stream{ Stream::PrimaryAdminSettingsFile }.Set("{}")); + } + settings.PrepareToShellExecuteFile(true); + + auto content = Stream{ Stream::PrimaryAdminSettingsFile }.Get(); + REQUIRE(content); + REQUIRE(ReadEntireStream(*content) == (repairAfterLoad ? "{}" : primary)); + content = Stream{ Stream::BackupAdminSettingsFile }.Get(); + REQUIRE(content); + REQUIRE(ReadEntireStream(*content) == (primary == "{}" ? primary : backup)); +} + +TEST_CASE("AdminFileSettings_NonAdminFileAccess", "[settings]") +{ + if (!IsRunningAsAdmin() || IsRunningAsSystem()) + { + WARN("Test requires an elevated non-SYSTEM user. Skipped."); + return; + } + + AdminFileSettingsTest test; + const std::string value = "{\"value\":\"protected\"}"; + REQUIRE(Stream{ Stream::PrimaryAdminSettingsFile }.Set(value)); + REQUIRE(Stream{ Stream::BackupAdminSettingsFile }.Set(value)); + + auto token = CreateNonAdminToken(); + THROW_IF_WIN32_BOOL_FALSE(ImpersonateLoggedOnUser(token.get())); + auto revert = wil::scope_exit([]() { FAIL_FAST_IF_WIN32_BOOL_FALSE(RevertToSelf()); }); + REQUIRE_FALSE(IsRunningAsAdminOrSystem()); + + for (const auto& definition : { Stream::PrimaryAdminSettingsFile, Stream::BackupAdminSettingsFile }) + { + Stream stream{ definition }; + auto result = stream.Get(); + REQUIRE(result); + REQUIRE(ReadEntireStream(*result) == value); + result.reset(); + + for (DWORD access : { DWORD{ GENERIC_WRITE }, DWORD{ DELETE }, DWORD{ WRITE_DAC }, DWORD{ WRITE_OWNER } }) + { + wil::unique_hfile file{ CreateFileW(stream.GetPath().c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr) }; + DWORD error = GetLastError(); + REQUIRE_FALSE(file); + REQUIRE(error == ERROR_ACCESS_DENIED); + } + } + + auto newFilePath = test.Directory.GetPath() / L"new.json"; + wil::unique_hfile file{ CreateFileW(newFilePath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr) }; + DWORD error = GetLastError(); + REQUIRE_FALSE(file); + REQUIRE(error == ERROR_ACCESS_DENIED); +} + TEST_CASE("SetAndReadSecureSetting", "[settings]") { StreamDefinition name{ Type::Secure, "secure_testsettingname" }; diff --git a/src/AppInstallerCLITests/TestSettings.cpp b/src/AppInstallerCLITests/TestSettings.cpp index f7a96efa12..57eb27c409 100644 --- a/src/AppInstallerCLITests/TestSettings.cpp +++ b/src/AppInstallerCLITests/TestSettings.cpp @@ -13,16 +13,14 @@ namespace TestCommon { void DeleteUserSettingsFilesInternal() { - auto settingsPath = UserSettings::SettingsFilePath(); - if (std::filesystem::exists(settingsPath)) + for (const auto& stream : { Stream::PrimaryUserSettings, Stream::BackupUserSettings, + Stream::PrimaryAdminSettingsFile, Stream::BackupAdminSettingsFile }) { - std::filesystem::remove(settingsPath); - } - - auto settingsBackupPath = GetPathTo(Stream::BackupUserSettings); - if (std::filesystem::exists(settingsBackupPath)) - { - std::filesystem::remove(settingsBackupPath); + auto path = GetPathTo(stream); + if (std::filesystem::exists(path)) + { + std::filesystem::remove(path); + } } } } diff --git a/src/AppInstallerCLITests/TestSettings.h b/src/AppInstallerCLITests/TestSettings.h index 3ac150db05..330c582f1f 100644 --- a/src/AppInstallerCLITests/TestSettings.h +++ b/src/AppInstallerCLITests/TestSettings.h @@ -38,7 +38,7 @@ namespace TestCommon void RemoveSetting(const AppInstaller::Settings::StreamDefinition& stream); std::filesystem::path GetPathTo(const AppInstaller::Settings::StreamDefinition& stream); - // This type removes the settings file on creation and destruction to ensure that a test that modifies them can do so cleanly. + // Removes ordinary and protected settings files under the test path overrides on creation and destruction. struct UserSettingsFileGuard { UserSettingsFileGuard(); @@ -49,6 +49,7 @@ namespace TestCommon struct UserSettingsTest : AppInstaller::Settings::UserSettings { + UserSettingsTest(const std::optional& content = std::nullopt) : UserSettings(content) {} }; struct GroupPolicyTestOverride : AppInstaller::Settings::GroupPolicy diff --git a/src/AppInstallerCLITests/UserSettings.cpp b/src/AppInstallerCLITests/UserSettings.cpp index 8f9e31e150..2f22a26ea5 100644 --- a/src/AppInstallerCLITests/UserSettings.cpp +++ b/src/AppInstallerCLITests/UserSettings.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "TestCommon.h" #include "TestSettings.h" +#include "TestHooks.h" #include #include #include "AppInstallerLogging.h" @@ -27,6 +28,509 @@ namespace static constexpr std::string_view s_badJson = "{"; static constexpr std::string_view s_settings = "settings.json"sv; static constexpr std::string_view s_settingsBackup = "settings.json.backup"sv; + + void SetAdminSettingsFile(const StreamDefinition& stream, std::string_view content) + { + REQUIRE(stream.Type == Type::AdminFile); + const auto path = GetPathTo(stream); + std::filesystem::create_directories(path.parent_path()); + + // Resolver tests provision files directly under the test path overrides, without requiring elevation. + std::ofstream file{ path, std::ios::binary | std::ios::trunc }; + REQUIRE(file.good()); + file << content; + file.close(); + REQUIRE(!file.fail()); + } + + size_t MigrationWarningCount(const UserSettings& settings) + { + const auto& warnings = settings.GetWarnings(); + return static_cast(std::count_if(warnings.begin(), warnings.end(), + [](const UserSettings::Warning& warning) { return warning.IsMigrationWarning; })); + } + + wil::unique_hfile LockSettingsFile(const StreamDefinition& stream) + { + const auto path = GetPathTo(stream); + wil::unique_hfile file{ CreateFileW(path.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr) }; + REQUIRE(file); + return file; + } +} + +TEST_CASE("UserSettingsOverlay_TreatmentMatrix", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + const bool fullToken = GENERATE(false, true); + const bool userPresent = GENERATE(false, true); + const bool adminPresent = GENERATE(false, true); + const bool userBackup = GENERATE(false, true); + CAPTURE(fullToken, userPresent, adminPresent, userBackup); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(fullToken); + + if (userPresent) + { + constexpr std::string_view user = R"({ + "visual": { "progressBar": "rainbow" }, + "installBehavior": { + "portablePackageUserRoot": "C:\\User", + "portablePackageMachineRoot": "C:\\User", + "defaultInstallRoot": "C:\\User" + }, + "configureBehavior": { "defaultModuleRoot": "C:\\User" }, + "downloadBehavior": { "defaultDownloadDirectory": "C:\\User" }, + "uninstallBehavior": { "purgePortablePackage": true } + })"; + if (userBackup) + { + SetSetting(Stream::PrimaryUserSettings, s_badJson); + } + SetSetting(userBackup ? Stream::BackupUserSettings : Stream::PrimaryUserSettings, user); + } + + if (adminPresent) + { + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, R"({ + "visual": { "progressBar": "retro" }, + "installBehavior": { + "portablePackageUserRoot": "C:\\Admin", + "portablePackageMachineRoot": "C:\\Admin", + "defaultInstallRoot": "C:\\Admin" + }, + "configureBehavior": { "defaultModuleRoot": "C:\\Admin" }, + "downloadBehavior": { "defaultDownloadDirectory": "C:\\Admin" }, + "uninstallBehavior": { "purgePortablePackage": false } + })"); + } + + UserSettingsTest settings; + const std::filesystem::path sharedRoot = adminPresent ? LR"(C:\Admin)" : (userPresent && !fullToken ? LR"(C:\User)" : L""); + const std::filesystem::path splitRoot = fullToken ? + (adminPresent ? LR"(C:\Admin)" : L"") : (userPresent ? LR"(C:\User)" : L""); + REQUIRE(settings.Get() == + (adminPresent ? VisualStyle::Retro : (userPresent ? VisualStyle::Rainbow : VisualStyle::Accent))); + REQUIRE(settings.Get() == sharedRoot); + REQUIRE(settings.Get() == sharedRoot); + REQUIRE(settings.Get() == sharedRoot); + REQUIRE(settings.Get() == splitRoot); + REQUIRE(settings.Get() == splitRoot); + REQUIRE(settings.Get() == (!adminPresent && userPresent && !fullToken)); + REQUIRE(settings.GetType() == (userPresent ? + (userBackup ? UserSettingsType::Backup : UserSettingsType::Standard) : UserSettingsType::Default)); + REQUIRE(MigrationWarningCount(settings) == (fullToken && userPresent && !adminPresent ? 6u : 0u)); + for (const auto& warning : settings.GetWarnings()) + { + if (warning.IsMigrationWarning) + { + REQUIRE(warning.Message == AppInstaller::StringResource::String::SettingsWarningIgnoredWithFullToken); + REQUIRE(warning.Source == (userBackup ? Stream::BackupUserSettings.Name : Stream::PrimaryUserSettings.Name)); + REQUIRE(warning.Data.empty()); + } + } +} + +TEST_CASE("UserSettingsOverlay_ExplicitValuesAndSiblings", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + SetSetting(Stream::PrimaryUserSettings, R"({ + "visual": { "anonymizeDisplayedPaths": true }, + "source": { "autoUpdateIntervalInMinutes": 23 }, + "installBehavior": { + "preferences": { "architectures": ["x86"], "locale": ["en-US", "fr-FR"], "scope": "machine" }, + "requirements": { "locale": ["en-US", "fr-FR"] } + } + })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, R"({ + "visual": { "anonymizeDisplayedPaths": false }, + "source": { "autoUpdateIntervalInMinutes": 0 }, + "installBehavior": { + "preferences": { "architectures": [] }, + "requirements": { "locale": ["de-DE"] } + } + })"); + + UserSettingsTest settings; + REQUIRE_FALSE(settings.Get()); + REQUIRE(settings.Get() == 0min); + REQUIRE(settings.Get().empty()); + REQUIRE(settings.Get() == std::vector{ "en-US", "fr-FR" }); + REQUIRE(settings.Get() == std::vector{ "de-DE" }); + REQUIRE(settings.Get() == AppInstaller::Manifest::ScopeEnum::Machine); + REQUIRE(settings.GetWarnings().empty()); +} + +TEST_CASE("UserSettingsOverlay_InvalidAdminValueBlocksUserFallback", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + const bool fullToken = GENERATE(false, true); + const std::string value = GENERATE("17"s, R"("relative")"s); + CAPTURE(fullToken, value); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(fullToken); + SetSetting(Stream::PrimaryUserSettings, R"({ + "downloadBehavior": { "defaultDownloadDirectory": "C:\\User" } + })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, + R"({ "downloadBehavior": { "defaultDownloadDirectory": )" + value + " } }"); + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, R"({ + "downloadBehavior": { "defaultDownloadDirectory": "C:\\Backup" } + })"); + + UserSettingsTest settings; + REQUIRE(settings.Get().empty()); + const auto& warnings = settings.GetWarnings(); + REQUIRE(warnings.size() == (fullToken ? 2u : 1u)); + REQUIRE(warnings[0].Source == Stream::PrimaryAdminSettingsFile.Name); + REQUIRE(warnings[0].Path == ".downloadBehavior.defaultDownloadDirectory"); + REQUIRE(warnings[0].Message == (value == R"("relative")" ? + AppInstaller::StringResource::String::SettingsWarningInvalidFieldValue : + AppInstaller::StringResource::String::SettingsWarningInvalidFieldFormat)); + REQUIRE(MigrationWarningCount(settings) == (fullToken ? 1u : 0u)); +} + +TEST_CASE("UserSettingsOverlay_InvalidUnrestrictedAdminValueBlocksUserFallback", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + SetSetting(Stream::PrimaryUserSettings, R"({ "visual": { "progressBar": "rainbow" } })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, R"({ "visual": { "progressBar": "invalid" } })"); + + UserSettingsTest settings; + REQUIRE(settings.Get() == VisualStyle::Accent); + REQUIRE(settings.GetWarnings().size() == 1); + REQUIRE(settings.GetWarnings()[0].Source == Stream::PrimaryAdminSettingsFile.Name); + REQUIRE(MigrationWarningCount(settings) == 0); +} + +TEST_CASE("UserSettingsOverlay_AdminNullSelectsDefault", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + const bool fullToken = GENERATE(false, true); + CAPTURE(fullToken); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(fullToken); + SetSetting(Stream::PrimaryUserSettings, R"({ + "downloadBehavior": { "defaultDownloadDirectory": "C:\\User" }, + "installBehavior": { "defaultInstallRoot": "C:\\User" } + })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, R"({ + "downloadBehavior": { "defaultDownloadDirectory": null }, + "installBehavior": { "defaultInstallRoot": null } + })"); + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, R"({ + "downloadBehavior": { "defaultDownloadDirectory": "C:\\Backup" }, + "installBehavior": { "defaultInstallRoot": "C:\\Backup" } + })"); + + UserSettingsTest settings; + REQUIRE(settings.Get().empty()); + const std::filesystem::path expectedRoot = fullToken ? L"" : LR"(C:\User)"; + REQUIRE(settings.Get() == expectedRoot); + REQUIRE(settings.GetWarnings().empty()); +} + +TEST_CASE("UserSettingsOverlay_ResolveMissingAndNull", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const std::string preferences = GENERATE("{}"s, "null"s, "[]"s, R"({ "scope": null })"s); + CAPTURE(preferences); + const bool presentNull = preferences == R"({ "scope": null })"; + SetSetting(Stream::PrimaryUserSettings, R"({ + "installBehavior": { "preferences": { "scope": "machine" } } + })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, + R"({ "installBehavior": { "preferences": )" + preferences + " } }"); + + UserSettingsTest settings; + REQUIRE(settings.Get() == (presentNull ? + AppInstaller::Manifest::ScopeEnum::User : AppInstaller::Manifest::ScopeEnum::Machine)); + REQUIRE(settings.GetWarnings().empty()); +} + +TEST_CASE("UserSettingsOverlay_IgnoredNullIsPresent", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + const bool fullToken = GENERATE(false, true); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(fullToken); + SetSetting(Stream::PrimaryUserSettings, R"({ + "downloadBehavior": { "defaultDownloadDirectory": null } + })"); + + UserSettingsTest settings; + REQUIRE(settings.Get().empty()); + REQUIRE(settings.GetWarnings().size() == (fullToken ? 1u : 0u)); + REQUIRE(MigrationWarningCount(settings) == (fullToken ? 1u : 0u)); + if (fullToken) + { + REQUIRE(settings.GetWarnings()[0].Path == ".downloadBehavior.defaultDownloadDirectory"); + REQUIRE(settings.GetWarnings()[0].Source == Stream::PrimaryUserSettings.Name); + } +} + +TEST_CASE("UserSettingsOverlay_IneligibleSplitFields", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + const bool fullToken = GENERATE(false, true); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(fullToken); + SetSetting(Stream::PrimaryUserSettings, R"({ + "installBehavior": { "defaultInstallRoot": "C:\\User" }, + "configureBehavior": { "defaultModuleRoot": "C:\\User" } + })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, R"({ + "installBehavior": { "defaultInstallRoot": 17 }, + "configureBehavior": { "defaultModuleRoot": null } + })"); + + UserSettingsTest settings; + const std::filesystem::path expectedRoot = fullToken ? L"" : LR"(C:\User)"; + REQUIRE(settings.Get() == expectedRoot); + REQUIRE(settings.Get() == expectedRoot); + REQUIRE(settings.GetWarnings().size() == (fullToken ? 2u : 0u)); + REQUIRE(MigrationWarningCount(settings) == (fullToken ? 1u : 0u)); +} + +TEST_CASE("UserSettingsOverlay_ProtectedFileRecovery", "[settings]") +{ + enum class FileState { Missing, Valid, Malformed, InvalidRoot }; + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const auto primary = GENERATE(FileState::Missing, FileState::Valid, FileState::Malformed, FileState::InvalidRoot); + const auto backup = GENERATE(FileState::Missing, FileState::Valid, FileState::Malformed, FileState::InvalidRoot); + CAPTURE(primary, backup); + SetSetting(Stream::PrimaryUserSettings, R"({ + "visual": { "progressBar": "rainbow" }, + "source": { "autoUpdateIntervalInMinutes": 23 } + })"); + + if (primary != FileState::Missing) + { + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, primary == FileState::Valid ? + R"({ "visual": { "progressBar": "retro" } })" : (primary == FileState::Malformed ? "{" : "[]")); + } + if (backup != FileState::Missing) + { + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, backup == FileState::Valid ? + R"({ "visual": { "progressBar": "rainbow" } })" : (backup == FileState::Malformed ? "{" : "null")); + } + + const bool parseFailure = primary != FileState::Valid && backup != FileState::Valid && + (primary != FileState::Missing || backup != FileState::Missing); + UserSettingsTest settings; + const bool recovered = primary != FileState::Valid && backup == FileState::Valid; + REQUIRE(settings.GetType() == UserSettingsType::Standard); + REQUIRE(settings.Get() == + (parseFailure ? VisualStyle::Accent : (primary == FileState::Valid ? VisualStyle::Retro : VisualStyle::Rainbow))); + REQUIRE(settings.Get() == (parseFailure ? 15min : 23min)); + REQUIRE(MigrationWarningCount(settings) == 0); + if (parseFailure) + { + const auto& warning = settings.GetWarnings().back(); + REQUIRE(warning.Message == AppInstaller::StringResource::String::SettingsWarningUsingDefault); + REQUIRE(warning.Source == (primary != FileState::Missing ? + Stream::PrimaryAdminSettingsFile.Name : Stream::BackupAdminSettingsFile.Name)); + } + else if (recovered) + { + const auto& warning = settings.GetWarnings().back(); + REQUIRE(warning.Message == AppInstaller::StringResource::String::SettingsWarningLoadedBackupSettings); + REQUIRE(warning.Source == Stream::BackupAdminSettingsFile.Name); + REQUIRE(warning.Path == Stream::BackupAdminSettingsFile.Name); + REQUIRE_FALSE(warning.IsFieldWarning); + } + else + { + REQUIRE(settings.GetWarnings().empty()); + } +} + +TEST_CASE("UserSettingsOverlay_UserFileRequiresObject", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const std::string root = GENERATE("null"s, "[]"s); + const bool validBackup = GENERATE(false, true); + CAPTURE(root, validBackup); + SetSetting(Stream::PrimaryUserSettings, root); + if (validBackup) + { + SetSetting(Stream::BackupUserSettings, R"({ "visual": { "progressBar": "rainbow" } })"); + } + + UserSettingsTest settings; + REQUIRE(settings.GetType() == (validBackup ? UserSettingsType::Backup : UserSettingsType::Default)); + REQUIRE(settings.Get() == (validBackup ? VisualStyle::Rainbow : VisualStyle::Accent)); + const auto& warnings = settings.GetWarnings(); + REQUIRE(warnings.size() == 2); + REQUIRE(warnings[0].Message == AppInstaller::StringResource::String::SettingsWarningInvalidFileFormat); + REQUIRE(warnings[0].Source == Stream::PrimaryUserSettings.Name); + REQUIRE(warnings.back().Message == (validBackup ? + AppInstaller::StringResource::String::SettingsWarningLoadedBackupSettings : + AppInstaller::StringResource::String::SettingsWarningUsingDefault)); +} + +TEST_CASE("UserSettingsOverlay_ProtectedReadFailure", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const bool lockPrimary = GENERATE(false, true); + const bool validBackup = GENERATE(false, true); + SetSetting(Stream::PrimaryUserSettings, R"({ + "visual": { "progressBar": "retro" }, + "source": { "autoUpdateIntervalInMinutes": 23 } + })"); + const auto& lockedStream = lockPrimary ? Stream::PrimaryAdminSettingsFile : Stream::BackupAdminSettingsFile; + SetAdminSettingsFile(lockedStream, s_goodJson); + if (lockPrimary && validBackup) + { + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, R"({ "visual": { "progressBar": "rainbow" } })"); + } + + auto lockedFile = LockSettingsFile(lockedStream); + UserSettingsTest settings; + REQUIRE(settings.GetType() == UserSettingsType::Standard); + REQUIRE(settings.GetWarnings().size() == 2); + REQUIRE(settings.GetWarnings()[0].Message == AppInstaller::StringResource::String::SettingsWarningReadError); + REQUIRE(settings.GetWarnings()[0].Source == lockedStream.Name); + if (lockPrimary && validBackup) + { + REQUIRE(settings.Get() == VisualStyle::Rainbow); + REQUIRE(settings.Get() == 23min); + REQUIRE(settings.GetWarnings()[1].Message == AppInstaller::StringResource::String::SettingsWarningLoadedBackupSettings); + } + else + { + REQUIRE(settings.Get() == VisualStyle::Accent); + REQUIRE(settings.Get() == 15min); + REQUIRE(settings.GetWarnings()[1].Message == AppInstaller::StringResource::String::SettingsWarningUsingDefault); + } +} + +TEST_CASE("UserSettingsOverlay_UserReadFailure", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const bool readableBackup = GENERATE(false, true); + SetSetting(Stream::PrimaryUserSettings, R"({ "visual": { "progressBar": "rainbow" } })"); + SetSetting(Stream::BackupUserSettings, R"({ "visual": { "progressBar": "retro" } })"); + auto lockedPrimary = LockSettingsFile(Stream::PrimaryUserSettings); + wil::unique_hfile lockedBackup; + if (!readableBackup) + { + lockedBackup = LockSettingsFile(Stream::BackupUserSettings); + } + + UserSettingsTest settings; + REQUIRE(settings.GetType() == (readableBackup ? UserSettingsType::Backup : UserSettingsType::Default)); + REQUIRE(settings.Get() == (readableBackup ? VisualStyle::Retro : VisualStyle::Accent)); + const auto& warnings = settings.GetWarnings(); + REQUIRE(warnings.size() == (readableBackup ? 2u : 3u)); + REQUIRE(warnings[0].Message == AppInstaller::StringResource::String::SettingsWarningReadError); + REQUIRE(warnings[0].Source == Stream::PrimaryUserSettings.Name); + if (!readableBackup) + { + REQUIRE(warnings[1].Message == AppInstaller::StringResource::String::SettingsWarningReadError); + REQUIRE(warnings[1].Source == Stream::BackupUserSettings.Name); + } + REQUIRE(warnings.back().Message == (readableBackup ? + AppInstaller::StringResource::String::SettingsWarningLoadedBackupSettings : + AppInstaller::StringResource::String::SettingsWarningUsingDefault)); + REQUIRE(warnings.back().Source == (readableBackup ? Stream::BackupUserSettings.Name : Stream::PrimaryUserSettings.Name)); +} + +TEST_CASE("UserSettingsOverlay_CustomContentBypassesFiles", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const bool validContent = GENERATE(false, true); + SetSetting(Stream::PrimaryUserSettings, R"({ "visual": { "progressBar": "retro" } })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, s_badJson); + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, s_badJson); + + UserSettingsTest settings{ validContent ? R"({ + "visual": { "progressBar": "rainbow" }, + "configureBehavior": { "defaultModuleRoot": "C:\\Custom" }, + "downloadBehavior": { "defaultDownloadDirectory": "C:\\Custom" } + })"s : "{"s }; + REQUIRE(settings.GetType() == (validContent ? UserSettingsType::Custom : UserSettingsType::Default)); + REQUIRE(settings.Get() == (validContent ? VisualStyle::Rainbow : VisualStyle::Accent)); + const std::filesystem::path expectedRoot = validContent ? LR"(C:\Custom)" : L""; + REQUIRE(settings.Get() == expectedRoot); + REQUIRE(settings.Get() == expectedRoot); + REQUIRE(settings.GetWarnings().size() == (validContent ? 0u : 1u)); + REQUIRE(MigrationWarningCount(settings) == 0); +} + +TEST_CASE("UserSettingsOverlay_GroupPolicyPrecedence", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const bool custom = GENERATE(false, true); + GroupPolicyTestOverride policies; + policies.SetValue(300); + SetSetting(Stream::PrimaryUserSettings, R"({ "source": { "autoUpdateIntervalInMinutes": 5 } })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, R"({ "source": { "autoUpdateIntervalInMinutes": "invalid" } })"); + + UserSettingsTest settings{ custom ? std::optional{ R"({ "source": { "autoUpdateIntervalInMinutes": 7 } })" } : std::nullopt }; + REQUIRE(settings.Get() == 300min); + REQUIRE(settings.GetWarnings().empty()); +} + +TEST_CASE("UserSettingsOverlay_GroupPolicyWithProtectedLoadFailure", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const bool unreadablePrimary = GENERATE(false, true); + const bool unreadableBackup = GENERATE(false, true); + CAPTURE(unreadablePrimary, unreadableBackup); + GroupPolicyTestOverride policies; + policies.SetValue(300); + SetSetting(Stream::PrimaryUserSettings, R"({ + "visual": { "progressBar": "rainbow" }, + "source": { "autoUpdateIntervalInMinutes": 23 }, + "downloadBehavior": { "defaultDownloadDirectory": "C:\\User" } + })"); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, unreadablePrimary ? s_goodJson : s_badJson); + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, unreadableBackup ? s_goodJson : s_badJson); + wil::unique_hfile lockedPrimary; + wil::unique_hfile lockedBackup; + if (unreadablePrimary) + { + lockedPrimary = LockSettingsFile(Stream::PrimaryAdminSettingsFile); + } + if (unreadableBackup) + { + lockedBackup = LockSettingsFile(Stream::BackupAdminSettingsFile); + } + + UserSettingsTest settings; + REQUIRE(settings.GetType() == UserSettingsType::Standard); + REQUIRE(settings.Get() == 300min); + REQUIRE(settings.Get() == VisualStyle::Accent); + REQUIRE(settings.Get().empty()); + REQUIRE(MigrationWarningCount(settings) == 0); + const auto& warnings = settings.GetWarnings(); + REQUIRE(warnings.size() == 3); + REQUIRE(warnings[0].Source == Stream::PrimaryAdminSettingsFile.Name); + REQUIRE(warnings[1].Source == Stream::BackupAdminSettingsFile.Name); + REQUIRE(warnings.back().Message == AppInstaller::StringResource::String::SettingsWarningUsingDefault); +} + +TEST_CASE("UserSettingsOverlay_DisabledSettingsBypassFilesAndCustomContent", "[settings]") +{ + auto files = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(GENERATE(false, true)); + const bool custom = GENERATE(false, true); + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::Settings, PolicyState::Disabled); + SetSetting(Stream::PrimaryUserSettings, s_badJson); + SetAdminSettingsFile(Stream::PrimaryAdminSettingsFile, s_badJson); + SetAdminSettingsFile(Stream::BackupAdminSettingsFile, s_badJson); + + UserSettingsTest settings{ custom ? std::optional{ "{" } : std::nullopt }; + REQUIRE(settings.GetType() == UserSettingsType::Default); + REQUIRE(settings.Get() == VisualStyle::Accent); + REQUIRE(settings.GetWarnings().empty()); } TEST_CASE("UserSettingsFilePaths", "[settings]") @@ -34,6 +538,7 @@ TEST_CASE("UserSettingsFilePaths", "[settings]") auto settingsPath = UserSettings::SettingsFilePath(); auto expectedPath = GetPathTo(PathName::UserFileSettings) / "settings.json"; REQUIRE(settingsPath == expectedPath); + REQUIRE(UserSettings::AdminSettingsFilePath() == GetPathTo(Stream::PrimaryAdminSettingsFile)); } TEST_CASE("UserSettingsType", "[settings]") @@ -524,6 +1029,7 @@ TEST_CASE("SettingsExperimentalCmd", "[settings]") TEST_CASE("SettingsPortablePackageUserRoot", "[settings]") { auto again = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(false); SECTION("Relative path") { @@ -552,6 +1058,7 @@ TEST_CASE("SettingsPortablePackageUserRoot", "[settings]") TEST_CASE("SettingsPortablePackageMachineRoot", "[settings]") { auto again = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(false); SECTION("Relative path") { @@ -580,6 +1087,7 @@ TEST_CASE("SettingsPortablePackageMachineRoot", "[settings]") TEST_CASE("SettingsDownloadDefaultDirectory", "[settings]") { auto again = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(false); SECTION("Valid path") { @@ -595,6 +1103,7 @@ TEST_CASE("SettingsDownloadDefaultDirectory", "[settings]") TEST_CASE("SettingsConfigureDefaultModuleRoot", "[settings]") { auto again = DeleteUserSettingsFiles(); + TestHook::SetIsRunningWithNonDefaultFullToken_Override tokenOverride(false); SECTION("Valid path") { diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp index 2f56924ed1..1ecea920ad 100644 --- a/src/AppInstallerCLITests/WorkFlow.cpp +++ b/src/AppInstallerCLITests/WorkFlow.cpp @@ -3,12 +3,17 @@ #include "pch.h" #include "WorkflowCommon.h" #include "TestSettings.h" +#include #include +#include #include #include +#include #include #include #include +#include +#include using namespace TestCommon; using namespace AppInstaller::CLI; @@ -18,6 +23,100 @@ using namespace AppInstaller::Manifest; using namespace AppInstaller::Repository; using namespace AppInstaller::Settings; using namespace AppInstaller::Utility; +using namespace AppInstaller::Utility::literals; + +namespace +{ + namespace ConfigurationAPI = winrt::Microsoft::Management::Configuration; + namespace ProcessorFactory = ConfigurationAPI::SetProcessorFactory; + + struct ModulePathTestSettings : TestUserSettings + { + ModulePathTestSettings(UserSettingsType type) { m_type = type; } + }; + + struct ModulePathTestFactory : winrt::implements + { + ConfigurationAPI::IConfigurationSetProcessor CreateSetProcessor(const ConfigurationAPI::ConfigurationSet&) { THROW_HR(E_NOTIMPL); } + winrt::event_token Diagnostics(const winrt::Windows::Foundation::EventHandler&) { THROW_HR(E_NOTIMPL); } + void Diagnostics(const winrt::event_token&) noexcept {} + ConfigurationAPI::DiagnosticLevel MinimumLevel() { THROW_HR(E_NOTIMPL); } + void MinimumLevel(ConfigurationAPI::DiagnosticLevel) { THROW_HR(E_NOTIMPL); } + winrt::Windows::Foundation::Collections::IVectorView AdditionalModulePaths() const { THROW_HR(E_NOTIMPL); } + void AdditionalModulePaths(const winrt::Windows::Foundation::Collections::IVectorView&) { THROW_HR(E_NOTIMPL); } + ProcessorFactory::PwshConfigurationProcessorPolicy Policy() const { THROW_HR(E_NOTIMPL); } + void Policy(ProcessorFactory::PwshConfigurationProcessorPolicy) { THROW_HR(E_NOTIMPL); } + ProcessorFactory::PwshConfigurationProcessorLocation Location() const { return LocationValue; } + void Location(ProcessorFactory::PwshConfigurationProcessorLocation value) { LocationValue = value; LocationSet = true; } + winrt::hstring CustomLocation() const { return CustomLocationValue; } + void CustomLocation(winrt::hstring value) { CustomLocationValue = value; } + + ProcessorFactory::PwshConfigurationProcessorLocation LocationValue = ProcessorFactory::PwshConfigurationProcessorLocation::Default; + bool LocationSet = false; + winrt::hstring CustomLocationValue; + }; +} + +TEST_CASE("ConfigurationModulePath_FileSettingsResolvedInReceiver", "[configuration][workflow]") +{ + ModulePathTestSettings settings(GENERATE(UserSettingsType::Default, UserSettingsType::Standard, UserSettingsType::Backup)); + settings.Set("C:\\MediumSettings\\Modules"); + std::ostringstream output; + TestContext context{ output, std::cin }; + auto factory = winrt::make_self(); + + Configuration::SetModulePath(context, factory.as()); + + REQUIRE_FALSE(factory->LocationSet); + REQUIRE(factory->CustomLocationValue.empty()); +} + +TEST_CASE("ConfigurationModulePath_ExplicitSelection", "[configuration][workflow]") +{ + ModulePathTestSettings settings(GENERATE(UserSettingsType::Standard, UserSettingsType::Custom)); + settings.Set("C:\\Settings\\Modules"); + const std::string argument = GENERATE("default", "currentuser", "allusers", "C:\\Explicit\\Modules"); + std::ostringstream output; + TestContext context{ output, std::cin }; + context.Args.AddArg(Args::Type::ConfigurationModulePath, argument); + auto factory = winrt::make_self(); + + Configuration::SetModulePath(context, factory.as()); + + REQUIRE(factory->LocationSet); + if (argument == "C:\\Explicit\\Modules") + { + REQUIRE(factory->LocationValue == ProcessorFactory::PwshConfigurationProcessorLocation::Custom); + REQUIRE(factory->CustomLocationValue == L"C:\\Explicit\\Modules"); + } + else + { + const auto expected = argument == "currentuser" ? ProcessorFactory::PwshConfigurationProcessorLocation::CurrentUser : + argument == "allusers" ? ProcessorFactory::PwshConfigurationProcessorLocation::AllUsers : + ProcessorFactory::PwshConfigurationProcessorLocation::Default; + REQUIRE(factory->LocationValue == expected); + REQUIRE(factory->CustomLocationValue.empty()); + } +} + +TEST_CASE("ConfigurationModulePath_CustomContentNotForwarded", "[configuration][workflow]") +{ + ModulePathTestSettings settings(UserSettingsType::Custom); + const bool configured = GENERATE(false, true); + if (configured) + { + settings.Set("C:\\Authoritative\\Modules"); + } + std::ostringstream output; + TestContext context{ output, std::cin }; + auto factory = winrt::make_self(); + + Configuration::SetModulePath(context, factory.as()); + + REQUIRE_FALSE(factory->LocationSet); + REQUIRE(factory->CustomLocationValue.empty()); +} void VerifyMotw(const std::filesystem::path& testFile, DWORD zone) { @@ -212,6 +311,7 @@ TEST_CASE("Export_Settings", "[Settings][workflow]") auto userSettingsFileValue = std::string(json["userSettingsFile"].asCString()); REQUIRE(userSettingsFileValue.find("settings.json") != std::string::npos); + REQUIRE(json["adminSettingsFile"].asString() == UserSettings::AdminSettingsFilePath().u8string()); } { @@ -235,5 +335,265 @@ TEST_CASE("Export_Settings", "[Settings][workflow]") auto userSettingsFileValue = std::string(json["userSettingsFile"].asCString()); REQUIRE(userSettingsFileValue.find("settings.json") != std::string::npos); + REQUIRE(json["adminSettingsFile"].asString() == UserSettings::AdminSettingsFilePath().u8string()); + } +} + +TEST_CASE("Settings_AdminFileArguments", "[Settings][workflow]") +{ + SettingsCommand command({}); + Args args; + args.AddArg(Args::Type::AdminSettingsFile); + REQUIRE_NOTHROW(command.ValidateArguments(args)); + args.AddArg(Args::Type::CreateAdminSettingsFile); + REQUIRE_NOTHROW(command.ValidateArguments(args)); + + SECTION("Enable is not a file operation") + { + args.AddArg(Args::Type::AdminSettingEnable, "LocalManifestFiles"sv); + REQUIRE_THROWS(command.ValidateArguments(args)); + } + SECTION("Disable is not a file operation") + { + args.AddArg(Args::Type::AdminSettingDisable, "LocalManifestFiles"sv); + REQUIRE_THROWS(command.ValidateArguments(args)); + } + SECTION("Create requires admin target") + { + Args createOnly; + createOnly.AddArg(Args::Type::CreateAdminSettingsFile); + REQUIRE_THROWS(command.ValidateArguments(createOnly)); + } + SECTION("Create does not open the editor") + { + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Args::Type::AdminSettingsFile); + context.Args.AddArg(Args::Type::CreateAdminSettingsFile); + context.Override({ EnsureRunningAsAdmin, [](TestContext&) {}, 1 }); + context.Override({ CreateAdminSettingsFile, [](TestContext&) {}, 1 }); + command.Execute(context); + REQUIRE_FALSE(context.IsTerminated()); + } +} + +TEST_CASE("Settings_WarningDisplay", "[Settings][workflow]") +{ + struct SettingsWithWarnings : TestUserSettings + { + SettingsWithWarnings() { m_warnings.clear(); } + using UserSettings::m_warnings; + } settings; + UserSettings::Warning field{ AppInstaller::StringResource::String::SettingsWarningInvalidFieldValue, ".visual.progressBar", "invalid" }; + field.Source = "admin-settings.json"_lis; + settings.m_warnings.push_back(field); + UserSettings::Warning migration{ AppInstaller::StringResource::String::SettingsWarningIgnoredWithFullToken, ".downloadBehavior.defaultDownloadDirectory" }; + migration.Source = "settings.json"_lis; + migration.IsMigrationWarning = true; + settings.m_warnings.push_back(migration); + + const auto warningType = GENERATE(SettingsWarningType::Migration, SettingsWarningType::NonMigration); + const bool migrationOnly = warningType == SettingsWarningType::Migration; + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context << ShowSettingsWarnings{ warningType }; + const auto& expected = migrationOnly ? migration : field; + const auto& absent = migrationOnly ? field : migration; + REQUIRE(output.str().find(expected.Message(expected.Path, expected.Data, expected.Source).get()) != std::string::npos); + REQUIRE(output.str().find(absent.Path.get()) == std::string::npos); + REQUIRE(output.str().find("{0}") == std::string::npos); + REQUIRE(output.str().find("{1}") == std::string::npos); + REQUIRE(output.str().find("{2}") == std::string::npos); + + SECTION("Shared command reporting does not duplicate migration warnings") + { + std::ostringstream commandOutput; + TestContext commandContext{ commandOutput, std::cin }; + auto commandThreadGlobals = commandContext.SetForCurrentThread(); + commandContext.Override({ OpenUserSetting, [](TestContext& ctx) { ctx << ShowSettingsWarnings{ SettingsWarningType::NonMigration }; }, 1 }); + SettingsCommand command({}); + ExecuteWithoutLoggingSuccess(commandContext, &command); + REQUIRE_FALSE(commandContext.IsTerminated()); + const auto text = migration.Message(migration.Path, migration.Data, migration.Source); + const auto position = commandOutput.str().find(text.get()); + REQUIRE(position != std::string::npos); + REQUIRE(commandOutput.str().find(text.get(), position + text.get().size()) == std::string::npos); + REQUIRE(commandOutput.str().find(field.Path.get()) != std::string::npos); + } + + SECTION("Machine-readable export suppresses warnings") + { + std::ostringstream exportOutput; + TestContext exportContext{ exportOutput, std::cin }; + auto exportThreadGlobals = exportContext.SetForCurrentThread(); + SettingsExportCommand command({}); + ExecuteWithoutLoggingSuccess(exportContext, &command); + REQUIRE_FALSE(exportContext.IsTerminated()); + auto json = ConvertToJson(exportOutput.str()); + REQUIRE(json["adminSettingsFile"].asString() == UserSettings::AdminSettingsFilePath().u8string()); + REQUIRE(exportOutput.str().find(migration.Path.get()) == std::string::npos); + } +} + +TEST_CASE("Settings_AdminFileMissingReadOnly", "[Settings][workflow]") +{ + if (AppInstaller::Runtime::IsRunningAsAdminOrSystem()) + { + WARN("Test requires a non-administrator token. Skipped."); + return; } + + auto files = DeleteUserSettingsFiles(); + TestUserSettings settings; + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Args::Type::AdminSettingsFile); + OpenUserSetting(context); + + REQUIRE_TERMINATED_WITH(context, E_ACCESSDENIED); + REQUIRE(output.str().find(UserSettings::AdminSettingsFilePath().u8string()) != std::string::npos); + REQUIRE_FALSE(std::filesystem::exists(UserSettings::AdminSettingsFilePath())); + REQUIRE_FALSE(std::filesystem::exists(GetPathTo(Stream::BackupAdminSettingsFile))); +} + +TEST_CASE("Settings_DscUserSettingsFileTarget", "[Settings][workflow]") +{ + auto files = DeleteUserSettingsFiles(); + const bool admin = GENERATE(false, true); + const std::string action = GENERATE("Partial", "Full"); + CAPTURE(admin, action); + auto ordinary = ConvertToJson(R"({ "file": "ordinary", "retained": true, "array": [1, 2] })"); + auto protectedSettings = ConvertToJson(R"({ "file": "protected", "retained": true, "array": [1, 2] })"); + SetSetting(Stream::PrimaryUserSettings, Json::writeString(Json::StreamWriterBuilder{}, ordinary)); + { + const auto path = UserSettings::AdminSettingsFilePath(); + std::filesystem::create_directories(path.parent_path()); + std::ofstream file{ path, std::ios::binary }; + file << Json::writeString(Json::StreamWriterBuilder{}, protectedSettings); + file.close(); + REQUIRE_FALSE(file.fail()); + } + + Json::Value input{ Json::ValueType::objectValue }; + if (admin) + { + input["admin"] = true; + } + auto invoke = [&](Args::Type function) + { + std::istringstream inputStream{ Json::writeString(Json::StreamWriterBuilder{}, input) }; + std::ostringstream output; + TestContext context{ output, inputStream }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(function); + DscUserSettingsFileResource command({}); + command.Execute(context); + INFO(output.str()); + REQUIRE(context.GetTerminationHR() == S_OK); + + std::vector result; + std::istringstream outputStream{ output.str() }; + for (std::string line; std::getline(outputStream, line);) + { + result.emplace_back(ConvertToJson(line)); + } + REQUIRE_FALSE(result.empty()); + return result; + }; + + auto expected = admin ? protectedSettings : ordinary; + for (auto function : { Args::Type::DscResourceFunctionGet, Args::Type::DscResourceFunctionExport }) + { + auto result = invoke(function); + REQUIRE(result.size() == 1); + REQUIRE(result[0]["admin"].isBool()); + REQUIRE(result[0]["admin"].asBool() == admin); + REQUIRE(result[0]["settings"] == expected); + } + + auto schema = invoke(Args::Type::DscResourceFunctionSchema)[0]; + REQUIRE(schema["properties"]["admin"]["type"].asString() == "boolean"); + REQUIRE(schema["properties"]["admin"]["default"] == Json::Value{ false }); + + input["action"] = action; + input["settings"] = expected; + auto result = invoke(Args::Type::DscResourceFunctionTest); + REQUIRE(result.size() == 2); + REQUIRE(result[0]["admin"].asBool() == admin); + REQUIRE(result[0]["_inDesiredState"].asBool()); + REQUIRE(result[1].empty()); + + input["settings"] = ConvertToJson(R"({ "array": [3], "downloadBehavior": { "defaultDownloadDirectory": null } })"); + result = invoke(Args::Type::DscResourceFunctionTest); + REQUIRE(result.size() == 2); + REQUIRE_FALSE(result[0]["_inDesiredState"].asBool()); + REQUIRE(result[1][0].asString() == "settings"); + REQUIRE_FALSE(Stream{ Stream::BackupAdminSettingsFile }.Get()); + + if (admin && !AppInstaller::Runtime::IsRunningAsAdminOrSystem()) + { + WARN("Protected Set requires admin privilege. Read-only operations were checked."); + return; + } + + const bool createFile = GENERATE(false, true); + CAPTURE(createFile); + if (createFile) + { + const auto path = admin ? UserSettings::AdminSettingsFilePath() : UserSettings::SettingsFilePath(); + REQUIRE(std::filesystem::remove(path)); + result = invoke(Args::Type::DscResourceFunctionGet); + REQUIRE(result[0]["settings"] == Json::Value{ Json::ValueType::objectValue }); + REQUIRE_FALSE(std::filesystem::exists(path)); + } + + if (createFile || action == "Full") + { + expected = input["settings"]; + } + else + { + expected["array"] = input["settings"]["array"]; + expected["downloadBehavior"] = input["settings"]["downloadBehavior"]; + } + result = invoke(Args::Type::DscResourceFunctionSet); + REQUIRE(result.size() == 2); + REQUIRE(result[0]["admin"].asBool() == admin); + REQUIRE(result[0]["settings"] == expected); + REQUIRE(result[1][0].asString() == "settings"); + result = invoke(Args::Type::DscResourceFunctionTest); + REQUIRE(result.size() == 2); + REQUIRE(result[0]["_inDesiredState"].asBool()); + REQUIRE(result[1].empty()); + REQUIRE_FALSE(Stream{ Stream::BackupAdminSettingsFile }.Get()); + + auto selected = Stream{ admin ? Stream::PrimaryAdminSettingsFile : Stream::PrimaryUserSettings }.Get(); + REQUIRE(selected); + REQUIRE(ConvertToJson(ReadEntireStream(*selected)) == expected); + auto unselected = Stream{ admin ? Stream::PrimaryUserSettings : Stream::PrimaryAdminSettingsFile }.Get(); + REQUIRE(unselected); + REQUIRE(ConvertToJson(ReadEntireStream(*unselected)) == (admin ? ordinary : protectedSettings)); +} + +TEST_CASE("Settings_DscUserSettingsFileAdminRequiresElevation", "[Settings][workflow]") +{ + auto files = DeleteUserSettingsFiles(); + std::istringstream input{ R"({ "admin": true, "settings": {} })" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Args::Type::DscResourceFunctionSet); + context.Override({ EnsureRunningAsAdmin, [](TestContext& ctx) + { + AICLI_TERMINATE_CONTEXT_ARGS(ctx,APPINSTALLER_CLI_ERROR_COMMAND_REQUIRES_ADMIN,); + }, 1 }); + DscUserSettingsFileResource command({}); + command.Execute(context); + + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_COMMAND_REQUIRES_ADMIN); + REQUIRE_FALSE(std::filesystem::exists(UserSettings::AdminSettingsFilePath())); + REQUIRE_FALSE(std::filesystem::exists(UserSettings::SettingsFilePath())); } diff --git a/src/AppInstallerCommonCore/Public/AppInstallerRuntime.h b/src/AppInstallerCommonCore/Public/AppInstallerRuntime.h index bc6f54a7a3..e3fe2960c3 100644 --- a/src/AppInstallerCommonCore/Public/AppInstallerRuntime.h +++ b/src/AppInstallerCommonCore/Public/AppInstallerRuntime.h @@ -66,6 +66,8 @@ namespace AppInstaller::Runtime FontsMachineInstallLocation, // The location that standard type settings are stored in files. StandardFileSettings, + // The context-specific configuration module default, without consulting user settings. + DefaultConfigurationModules, // Always one more than the last path; for being able to iterate paths in tests. Max }; diff --git a/src/AppInstallerCommonCore/Public/winget/Settings.h b/src/AppInstallerCommonCore/Public/winget/Settings.h index b14bab0d80..32c1fa7c07 100644 --- a/src/AppInstallerCommonCore/Public/winget/Settings.h +++ b/src/AppInstallerCommonCore/Public/winget/Settings.h @@ -46,6 +46,8 @@ namespace AppInstaller::Settings Encrypted, // A setting stream has should be stored in a file, removing the limitations of the Standard type. StandardFile, + // An editable file in the protected settings directory; only admins can modify it. + AdminFile, }; // Converts the Type enum to a string. @@ -80,6 +82,10 @@ namespace AppInstaller::Settings constexpr static StreamDefinition BackupUserSettings{ Type::UserFile, "settings.json.backup"sv }; // The admin settings. constexpr static StreamDefinition AdminSettings{ Type::Secure, "admin_settings"sv }; + // The schema-compatible settings file in the protected directory. + constexpr static StreamDefinition PrimaryAdminSettingsFile{ Type::AdminFile, "admin-settings.json"sv }; + // The backup of the schema-compatible settings file in the protected directory. + constexpr static StreamDefinition BackupAdminSettingsFile{ Type::AdminFile, "admin-settings.json.backup"sv }; // The REST information cache. constexpr static StreamDefinition RestInformationCache{ Type::Encrypted, "rest_information"sv }; diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 64e1d6a459..7006be0f23 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -156,18 +156,25 @@ namespace AppInstaller::Settings namespace details { + enum class SettingTreatment + { + Unrestricted, + RestrictedShared, + RestrictedSplit, + }; + template struct SettingMapping { // json_t - type the setting in json. // value_t - the type of this setting. // DefaultValue - the value_t default value when setting is absent or semantically wrong. - // Path - json path to the property. See Json::Path in json.h for syntax. So far, this is sufficient - // but since is "brief" and "untested" we might implement our own if needed. + // Path - Dot-separated object member path, beginning with '.'. // Validate - Function that does semantic validation. + // Treatment - File-source eligibility with and without the non-default full token. }; -#define SETTINGMAPPING_SPECIALIZATION_POLICY(_setting_, _json_, _value_, _default_, _path_, _valuePolicy_) \ +#define SETTINGMAPPING_SPECIALIZATION_POLICY(_setting_, _json_, _value_, _default_, _path_, _treatment_, _valuePolicy_) \ template <> \ struct SettingMapping<_setting_> \ { \ @@ -175,77 +182,78 @@ namespace AppInstaller::Settings using value_t = _value_; \ inline static const value_t DefaultValue = _default_; \ static constexpr std::string_view Path = _path_; \ + static constexpr SettingTreatment Treatment = SettingTreatment::_treatment_; \ static std::optional Validate(const json_t& value); \ static constexpr ValuePolicy Policy = _valuePolicy_; \ using policy_t = GroupPolicy::ValueType; \ static_assert(Policy == ValuePolicy::None || std::is_same::value); \ } -#define SETTINGMAPPING_SPECIALIZATION(_setting_, _json_, _value_, _default_, _path_) \ - SETTINGMAPPING_SPECIALIZATION_POLICY(_setting_, _json_, _value_, _default_, _path_, ValuePolicy::None) +#define SETTINGMAPPING_SPECIALIZATION(_setting_, _json_, _value_, _default_, _path_, _treatment_) \ + SETTINGMAPPING_SPECIALIZATION_POLICY(_setting_, _json_, _value_, _default_, _path_, _treatment_, ValuePolicy::None) // Visual - SETTINGMAPPING_SPECIALIZATION(Setting::ProgressBarVisualStyle, std::string, VisualStyle, VisualStyle::Accent, ".visual.progressBar"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::AnonymizePathForDisplay, bool, bool, true, ".visual.anonymizeDisplayedPaths"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EnableSixelDisplay, bool, bool, false, ".visual.enableSixels"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::ProgressBarVisualStyle, std::string, VisualStyle, VisualStyle::Accent, ".visual.progressBar"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::AnonymizePathForDisplay, bool, bool, true, ".visual.anonymizeDisplayedPaths"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EnableSixelDisplay, bool, bool, false, ".visual.enableSixels"sv, Unrestricted); // Source - SETTINGMAPPING_SPECIALIZATION_POLICY(Setting::AutoUpdateTimeInMinutes, uint32_t, std::chrono::minutes, 15min, ".source.autoUpdateIntervalInMinutes"sv, ValuePolicy::SourceAutoUpdateIntervalInMinutes); + SETTINGMAPPING_SPECIALIZATION_POLICY(Setting::AutoUpdateTimeInMinutes, uint32_t, std::chrono::minutes, 15min, ".source.autoUpdateIntervalInMinutes"sv, Unrestricted, ValuePolicy::SourceAutoUpdateIntervalInMinutes); // Experimental - SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalCmd, bool, bool, false, ".experimentalFeatures.experimentalCmd"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalArg, bool, bool, false, ".experimentalFeatures.experimentalArg"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFResume, bool, bool, false, ".experimentalFeatures.resume"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFFonts, bool, bool, false, ".experimentalFeatures.fonts"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFInteractivePackageSelection, bool, bool, false, ".experimentalFeatures.interactivePackageSelection"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFDeltaIndex, bool, bool, false, ".experimentalFeatures.deltaIndex"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalCmd, bool, bool, false, ".experimentalFeatures.experimentalCmd"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalArg, bool, bool, false, ".experimentalFeatures.experimentalArg"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EFResume, bool, bool, false, ".experimentalFeatures.resume"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EFFonts, bool, bool, false, ".experimentalFeatures.fonts"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EFInteractivePackageSelection, bool, bool, false, ".experimentalFeatures.interactivePackageSelection"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::EFDeltaIndex, bool, bool, false, ".experimentalFeatures.deltaIndex"sv, Unrestricted); // Telemetry - SETTINGMAPPING_SPECIALIZATION(Setting::TelemetryDisable, bool, bool, false, ".telemetry.disable"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::TelemetryDisable, bool, bool, false, ".telemetry.disable"sv, Unrestricted); // Install behavior - SETTINGMAPPING_SPECIALIZATION(Setting::InstallArchitecturePreference, std::vector, std::vector, {}, ".installBehavior.preferences.architectures"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallArchitectureRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.architectures"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopePreference, std::string, Manifest::ScopeEnum, Manifest::ScopeEnum::User, ".installBehavior.preferences.scope"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopeRequirement, std::string, Manifest::ScopeEnum, Manifest::ScopeEnum::Unknown, ".installBehavior.requirements.scope"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocalePreference, std::vector, std::vector, {}, ".installBehavior.preferences.locale"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocaleRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.locale"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallerTypePreference, std::vector, std::vector, {}, ".installBehavior.preferences.installerTypes"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallerTypeRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.installerTypes"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallSkipDependencies, bool, bool, false, ".installBehavior.skipDependencies"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::ArchiveExtractionMethod, std::string, Archive::ExtractionMethod, Archive::ExtractionMethod::ShellApi, ".installBehavior.archiveExtractionMethod"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::DisableInstallNotes, bool, bool, false, ".installBehavior.disableInstallNotes"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::PortablePackageUserRoot, std::string, std::filesystem::path, {}, ".installBehavior.portablePackageUserRoot"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::PortablePackageMachineRoot, std::string, std::filesystem::path, {}, ".installBehavior.portablePackageMachineRoot"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::InstallDefaultRoot, std::string, std::filesystem::path, {}, ".installBehavior.defaultInstallRoot"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::MaxResumes, uint32_t, int, 3, ".installBehavior.maxResumes"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallArchitecturePreference, std::vector, std::vector, {}, ".installBehavior.preferences.architectures"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallArchitectureRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.architectures"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopePreference, std::string, Manifest::ScopeEnum, Manifest::ScopeEnum::User, ".installBehavior.preferences.scope"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopeRequirement, std::string, Manifest::ScopeEnum, Manifest::ScopeEnum::Unknown, ".installBehavior.requirements.scope"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocalePreference, std::vector, std::vector, {}, ".installBehavior.preferences.locale"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocaleRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.locale"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallerTypePreference, std::vector, std::vector, {}, ".installBehavior.preferences.installerTypes"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallerTypeRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.installerTypes"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallSkipDependencies, bool, bool, false, ".installBehavior.skipDependencies"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::ArchiveExtractionMethod, std::string, Archive::ExtractionMethod, Archive::ExtractionMethod::ShellApi, ".installBehavior.archiveExtractionMethod"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::DisableInstallNotes, bool, bool, false, ".installBehavior.disableInstallNotes"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::PortablePackageUserRoot, std::string, std::filesystem::path, {}, ".installBehavior.portablePackageUserRoot"sv, RestrictedShared); + SETTINGMAPPING_SPECIALIZATION(Setting::PortablePackageMachineRoot, std::string, std::filesystem::path, {}, ".installBehavior.portablePackageMachineRoot"sv, RestrictedShared); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallDefaultRoot, std::string, std::filesystem::path, {}, ".installBehavior.defaultInstallRoot"sv, RestrictedSplit); + SETTINGMAPPING_SPECIALIZATION(Setting::MaxResumes, uint32_t, int, 3, ".installBehavior.maxResumes"sv, Unrestricted); // Uninstall behavior - SETTINGMAPPING_SPECIALIZATION(Setting::UninstallPurgePortablePackage, bool, bool, false, ".uninstallBehavior.purgePortablePackage"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::UninstallPurgePortablePackage, bool, bool, false, ".uninstallBehavior.purgePortablePackage"sv, RestrictedShared); // Download behavior - SETTINGMAPPING_SPECIALIZATION(Setting::DownloadDefaultDirectory, std::string, std::filesystem::path, {}, ".downloadBehavior.defaultDownloadDirectory"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::DownloadDefaultDirectory, std::string, std::filesystem::path, {}, ".downloadBehavior.defaultDownloadDirectory"sv, RestrictedShared); // Configure behavior - SETTINGMAPPING_SPECIALIZATION(Setting::ConfigureDefaultModuleRoot, std::string, std::filesystem::path, {}, ".configureBehavior.defaultModuleRoot"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::ConfigureDefaultModuleRoot, std::string, std::filesystem::path, {}, ".configureBehavior.defaultModuleRoot"sv, RestrictedSplit); // Network - SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDownloader, std::string, InstallerDownloader, InstallerDownloader::Default, ".network.downloader"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDOProgressTimeoutInSeconds, uint32_t, std::chrono::seconds, 60s, ".network.doProgressTimeoutInSeconds"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::NetworkWingetAlternateSourceURL, bool, bool, true, ".network.enableWingetAlternateSourceURL"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDownloader, std::string, InstallerDownloader, InstallerDownloader::Default, ".network.downloader"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDOProgressTimeoutInSeconds, uint32_t, std::chrono::seconds, 60s, ".network.doProgressTimeoutInSeconds"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::NetworkWingetAlternateSourceURL, bool, bool, true, ".network.enableWingetAlternateSourceURL"sv, Unrestricted); #ifndef AICLI_DISABLE_TEST_HOOKS // Debug - SETTINGMAPPING_SPECIALIZATION(Setting::EnableSelfInitiatedMinidump, bool, bool, false, ".debugging.enableSelfInitiatedMinidump"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::KeepAllLogFiles, bool, bool, false, ".debugging.keepAllLogFiles"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EnableSelfInitiatedMinidump, bool, bool, false, ".debugging.enableSelfInitiatedMinidump"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::KeepAllLogFiles, bool, bool, false, ".debugging.keepAllLogFiles"sv, Unrestricted); #endif // Logging - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingLevelPreference, std::string, Logging::Level, Logging::Level::Info, ".logging.level"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingChannelPreference, std::vector, Logging::Channel, Logging::Channel::Defaults, ".logging.channels"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileNameStrategy, std::string, Logging::LogNameStrategy, Logging::LogNameStrategy::Manifest, ".logging.fileNameStrategy"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileAgeLimitInDays, uint32_t, std::chrono::hours, (7 * 24h), ".logging.file.ageLimitInDays"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileTotalSizeLimitInMB, uint32_t, uint32_t, 128, ".logging.file.totalSizeLimitInMB"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileIndividualSizeLimitInMB, uint32_t, uint32_t, 16, ".logging.file.individualSizeLimitInMB"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileCountLimit, uint32_t, uint32_t, 0, ".logging.file.countLimit"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingLevelPreference, std::string, Logging::Level, Logging::Level::Info, ".logging.level"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingChannelPreference, std::vector, Logging::Channel, Logging::Channel::Defaults, ".logging.channels"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileNameStrategy, std::string, Logging::LogNameStrategy, Logging::LogNameStrategy::Manifest, ".logging.fileNameStrategy"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileAgeLimitInDays, uint32_t, std::chrono::hours, (7 * 24h), ".logging.file.ageLimitInDays"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileTotalSizeLimitInMB, uint32_t, uint32_t, 128, ".logging.file.totalSizeLimitInMB"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileIndividualSizeLimitInMB, uint32_t, uint32_t, 16, ".logging.file.individualSizeLimitInMB"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileCountLimit, uint32_t, uint32_t, 0, ".logging.file.countLimit"sv, Unrestricted); // Interactivity - SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv, Unrestricted); // Output behavior - SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortOrder, std::vector, std::vector, std::vector{}, ".output.sortOrder"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortDirection, std::string, SortDirection, SortDirection::Ascending, ".output.sortDirection"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::OutputLocale, std::string, std::string, std::string{}, ".output.locale"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortOrder, std::vector, std::vector, std::vector{}, ".output.sortOrder"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortDirection, std::string, SortDirection, SortDirection::Ascending, ".output.sortDirection"sv, Unrestricted); + SETTINGMAPPING_SPECIALIZATION(Setting::OutputLocale, std::string, std::string, std::string{}, ".output.locale"sv, Unrestricted); // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template @@ -264,6 +272,7 @@ namespace AppInstaller::Settings // Jsoncpp doesn't provide line number and column for an individual Json::Value node. struct Warning { + // Message placeholders are {0} = Path, {1} = Data, and {2} = Source. Warning(StringResource::StringId message) : Message(message) {} Warning(StringResource::StringId message, std::string_view settingPath) : Message(message), Path(settingPath) {} Warning(StringResource::StringId message, std::string_view settingPath, std::string_view settingValue, bool isField = true) : @@ -272,12 +281,16 @@ namespace AppInstaller::Settings StringResource::StringId Message; Utility::LocIndString Path; Utility::LocIndString Data; + Utility::LocIndString Source; bool IsFieldWarning = true; + bool IsMigrationWarning = false; }; static UserSettings const& Instance(const std::optional& content = std::nullopt); static std::filesystem::path SettingsFilePath(bool forDisplay = false); + static std::filesystem::path AdminSettingsFilePath(); + static void CreateAdminSettingsFile(); UserSettings(const UserSettings&) = delete; UserSettings& operator=(const UserSettings&) = delete; @@ -288,7 +301,7 @@ namespace AppInstaller::Settings UserSettingsType GetType() const { return m_type; } std::vector const& GetWarnings() const { return m_warnings; } - void PrepareToShellExecuteFile() const; + void PrepareToShellExecuteFile(bool admin = false) const; // Gets setting value, if its not in the map it returns the default value. template @@ -305,6 +318,7 @@ namespace AppInstaller::Settings protected: UserSettingsType m_type = UserSettingsType::Default; + UserSettingsType m_adminType = UserSettingsType::Default; std::vector m_warnings; std::map m_settings; diff --git a/src/AppInstallerCommonCore/Runtime.cpp b/src/AppInstallerCommonCore/Runtime.cpp index 56f3feb1f6..5e676ea6a2 100644 --- a/src/AppInstallerCommonCore/Runtime.cpp +++ b/src/AppInstallerCommonCore/Runtime.cpp @@ -248,10 +248,27 @@ namespace AppInstaller::Runtime result.Path = Settings::User().Get(); if (result.Path.empty()) { - result.Path = GetKnownFolderPath(FOLDERID_LocalAppData); - result.Path /= s_SecureSettings_Base; + result = GetPathDetailsFor(PathName::DefaultConfigurationModules, forDisplay); + } + mayBeInProfilePath = true; + break; + case PathName::DefaultConfigurationModules: + if (IsRunningWithNonDefaultFullToken()) + { + result = GetPathDetailsFor(PathName::SecureSettingsForWrite, forDisplay); + result.Create = !forDisplay; + if (!forDisplay) + { + // Protect the parent before creating modules beneath it. + PathDetails parent = result; + InitializeAndGetPathTo(std::move(parent)); + } result.Path /= s_ConfigurationModulesDirectory; } + else + { + result.Path = GetKnownFolderPath(FOLDERID_LocalAppData) / s_SecureSettings_Base / s_ConfigurationModulesDirectory; + } mayBeInProfilePath = true; break; default: @@ -336,6 +353,7 @@ namespace AppInstaller::Runtime case PathName::FontsUserInstallLocation: case PathName::FontsMachineInstallLocation: case PathName::ConfigurationModules: + case PathName::DefaultConfigurationModules: result = GetPathDetailsCommon(path, forDisplay); break; case PathName::SelfPackageRoot: @@ -458,6 +476,7 @@ namespace AppInstaller::Runtime case PathName::FontsUserInstallLocation: case PathName::FontsMachineInstallLocation: case PathName::ConfigurationModules: + case PathName::DefaultConfigurationModules: result = GetPathDetailsCommon(path, forDisplay); break; case PathName::SelfPackageRoot: diff --git a/src/AppInstallerCommonCore/Settings.cpp b/src/AppInstallerCommonCore/Settings.cpp index 593b8b4388..e9c9a48c2f 100644 --- a/src/AppInstallerCommonCore/Settings.cpp +++ b/src/AppInstallerCommonCore/Settings.cpp @@ -142,6 +142,25 @@ namespace AppInstaller::Settings std::filesystem::path m_settingFile; }; + struct AdminFileSettingsContainer : public FileSettingsContainer + { + AdminFileSettingsContainer(const std::filesystem::path& name) : + FileSettingsContainer(GetPathTo(PathName::SecureSettingsForRead), name) {} + + bool Set(std::string_view value) override + { + THROW_HR_IF(E_ACCESSDENIED, !IsRunningAsAdminOrSystem()); + GetPathTo(PathName::SecureSettingsForWrite); + return FileSettingsContainer::Set(value); + } + + void Remove() override + { + THROW_HR_IF(E_ACCESSDENIED, !IsRunningAsAdminOrSystem()); + FileSettingsContainer::Remove(); + } + }; + // A settings container that manages safely writing to its value with exchange semantics. // Only allows Set to succeed if the hash value of the setting is the same as the last time it was read. struct ExchangeSettingsContainer : public details::ISettingsContainer @@ -466,6 +485,9 @@ namespace AppInstaller::Settings // User file settings are not typically modified by us, so there is no need for exchange return std::make_unique(GetPathTo(PathName::UserFileSettings), name); + case Type::AdminFile: + return std::make_unique(name); + case Type::Secure: // Secure settings add hash verification on reads on top of exchange semantics return std::make_unique(GetRawSettingsContainer(Type::Standard, name), name); @@ -503,6 +525,8 @@ namespace AppInstaller::Settings return "Encrypted"sv; case Type::StandardFile: return "StandardFile"sv; + case Type::AdminFile: + return "AdminFile"sv; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index 7c4399838f..503f9e2be9 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -89,11 +89,18 @@ namespace AppInstaller::Settings AICLI_LOG(Core, Error, << "Error parsing " << settingName << ": " << error); warnings.emplace_back(StringResource::String::SettingsWarningParseError, settingName, error, false); + warnings.back().Source = LocIndString{ settingName }; return {}; } - std::optional ParseFile(const StreamDefinition& setting, std::vector& warnings) + struct ParsedSettingsFile + { + std::optional Root; + bool Failed = false; + }; + + ParsedSettingsFile ParseFile(const StreamDefinition& setting, std::vector& warnings) { try { @@ -101,21 +108,138 @@ namespace AppInstaller::Settings if (stream) { std::string settingsContentStr = Utility::ReadEntireStream(*stream); - return ParseSettingsContent(settingsContentStr, setting.Name, warnings); + if (stream->fail()) + { + AICLI_LOG(Core, Error, << "Failed to read " << setting.Name << "; Stream failure."); + warnings.emplace_back(StringResource::String::SettingsWarningReadError, setting.Name, ""sv, false); + warnings.back().Source = LocIndString{ setting.Name }; + return { {}, true }; + } + auto root = ParseSettingsContent(settingsContentStr, setting.Name, warnings); + if (!root) + { + return { {}, true }; + } + + if (!root->isObject()) + { + AICLI_LOG(Core, Error, << "Settings file must contain a JSON object: " << setting.Name); + warnings.emplace_back(StringResource::String::SettingsWarningInvalidFileFormat, setting.Name, ""sv, false); + warnings.back().Source = LocIndString{ setting.Name }; + return { {}, true }; + } + + return { std::move(root), false }; } } catch (const std::exception& e) { AICLI_LOG(Core, Error, << "Failed to read " << setting.Name << "; Reason: " << e.what()); + warnings.emplace_back(StringResource::String::SettingsWarningReadError, setting.Name, e.what(), false); + warnings.back().Source = LocIndString{ setting.Name }; + return { {}, true }; } catch (...) { AICLI_LOG(Core, Error, << "Failed to read " << setting.Name << "; Reason unknown."); + warnings.emplace_back(StringResource::String::SettingsWarningReadError, setting.Name, ""sv, false); + warnings.back().Source = LocIndString{ setting.Name }; + return { {}, true }; } return {}; } + struct SettingsSourceInfo + { + Json::Value Root; + std::string_view Name; + UserSettingsType Type = UserSettingsType::Default; + bool Failed = false; + + void LoadSettingsSource( + const StreamDefinition& primary, + const StreamDefinition& backup, + std::vector& warnings) + { + Name = primary.Name; + + auto primarySettings = ParseFile(primary, warnings); + if (primarySettings.Root.has_value()) + { + Root = std::move(primarySettings.Root.value()); + Type = UserSettingsType::Standard; + AICLI_LOG(Core, Info, << "Settings loaded from " << Name); + } + + if (Root.isNull()) + { + auto backupSettings = ParseFile(backup, warnings); + if (backupSettings.Root.has_value()) + { + Root = std::move(backupSettings.Root.value()); + Name = backup.Name; + Type = UserSettingsType::Backup; + AICLI_LOG(Core, Info, << "Settings loaded from " << Name); + if (backup.Type == Type::AdminFile) + { + warnings.emplace_back(StringResource::String::SettingsWarningLoadedBackupSettings, Name, ""sv, false); + } + else + { + warnings.emplace_back(StringResource::String::SettingsWarningLoadedBackupSettings); + } + warnings.back().Source = LocIndString{ Name }; + } + else if (primarySettings.Failed || primarySettings.Root.has_value() || backupSettings.Failed) + { + Failed = true; + warnings.emplace_back(StringResource::String::SettingsWarningUsingDefault); + warnings.back().Source = LocIndString{ primarySettings.Failed || primarySettings.Root.has_value() ? + primary.Name : backup.Name }; + } + } + } + }; + + struct SettingsSources + { + SettingsSourceInfo User; + SettingsSourceInfo Admin; + bool FileBacked = false; + bool FullToken = false; + }; + + const Json::Value* ResolveSetting(const Json::Value& root, std::string_view path) + { + const Json::Value* current = &root; + while (!path.empty()) + { + if (!current->isObject()) + { + return nullptr; + } + + path.remove_prefix(1); + const auto separator = path.find('.'); + const auto member = path.substr(0, separator); + + current = current->find(member.data(), member.data() + member.size()); + if (!current) + { + return nullptr; + } + + if (separator == std::string_view::npos) + { + break; + } + path.remove_prefix(separator); + } + + return current; + } + template std::optional::json_t> GetValueFromPolicy() { @@ -124,12 +248,11 @@ namespace AppInstaller::Settings template void Validate( - Json::Value& root, + const SettingsSources& sources, std::map& settings, std::vector& warnings) { - // jsoncpp doesn't support std::string_view yet. - auto path = std::string(details::SettingMapping::Path); + constexpr auto path = details::SettingMapping::Path; // Settings set by Group Policy override anything else. See if there is one. auto policyValue = GetValueFromPolicy(); @@ -156,13 +279,31 @@ namespace AppInstaller::Settings return; } - const Json::Path jsonPath(path); - Json::Value result = jsonPath.resolve(root); - if (!result.isNull()) + constexpr auto treatment = details::SettingMapping::Treatment; + const bool ignoreUser = sources.FileBacked && sources.FullToken && treatment != details::SettingTreatment::Unrestricted; + const Json::Value* result = nullptr; + if (sources.FileBacked && (sources.FullToken || treatment != details::SettingTreatment::RestrictedSplit)) + { + result = ResolveSetting(sources.Admin.Root, path); + } + const bool useAdmin = result != nullptr; + if (!useAdmin && !ignoreUser) + { + result = ResolveSetting(sources.User.Root, path); + } + const auto sourceName = useAdmin ? sources.Admin.Name : sources.User.Name; + bool validAdminValue = false; + + if (result != nullptr) { - auto jsonValue = GetValue::json_t>(result); + auto jsonValue = GetValue::json_t>(*result); - if (jsonValue.has_value()) + if (result->isNull()) + { + validAdminValue = useAdmin; + AICLI_LOG(Core, Verbose, << "Explicit null value. Source: " << sourceName << " Field: " << path << " Using default"); + } + else if (jsonValue.has_value()) { auto validatedValue = details::SettingMapping::Validate(jsonValue.value()); @@ -171,36 +312,47 @@ namespace AppInstaller::Settings // Finally add it to the map settings[S].emplace( std::forward::value_t>(validatedValue.value())); - AICLI_LOG(Core, Verbose, << "Valid setting. Field: " << path << " Value: " << GetValueString(jsonValue.value())); + validAdminValue = useAdmin; + AICLI_LOG(Core, Verbose, << "Valid setting. Source: " << sourceName << " Field: " << path << " Value: " << GetValueString(jsonValue.value())); } else { auto valueAsString = GetValueString(jsonValue.value()); - AICLI_LOG(Core, Error, << "Invalid field value. Field: " << path << " Value: " << valueAsString); + AICLI_LOG(Core, Error, << "Invalid field value. Source: " << sourceName << " Field: " << path << " Value: " << valueAsString); warnings.emplace_back(StringResource::String::SettingsWarningInvalidFieldValue, path, valueAsString); + warnings.back().Source = LocIndString{ sourceName }; } } else { - AICLI_LOG(Core, Error, << "Invalid field format. Field: " << path << " Using default"); + AICLI_LOG(Core, Error, << "Invalid field format. Source: " << sourceName << " Field: " << path << " Using default"); warnings.emplace_back(StringResource::String::SettingsWarningInvalidFieldFormat, path); + warnings.back().Source = LocIndString{ sourceName }; } } else { AICLI_LOG(Core, Verbose, << "Setting " << path << " not found. Using default"); } + + if (ignoreUser && !validAdminValue && ResolveSetting(sources.User.Root, path) != nullptr) + { + AICLI_LOG(Core, Warning, << "Ignoring setting from " << sources.User.Name << " with the full token. Field: " << path); + warnings.emplace_back(StringResource::String::SettingsWarningIgnoredWithFullToken, path); + warnings.back().Source = LocIndString{ sources.User.Name }; + warnings.back().IsMigrationWarning = true; + } } template void ValidateAll( - Json::Value& root, + const SettingsSources& sources, std::map& settings, std::vector& warnings, std::index_sequence) { // Use folding to call each setting validate function. - (FoldHelper{}, ..., Validate(S)>(root, settings, warnings)); + (FoldHelper{}, ..., Validate(S)>(sources, settings, warnings)); } std::optional ValidatePathValue(std::string_view value) @@ -595,14 +747,14 @@ namespace AppInstaller::Settings return *s_UserSettings_Override; } #endif - if (!s_userSettingsInitialized) + static UserSettings userSettings = [&content]() { s_userSettingsInInitialization = true; - } + auto resetInitialization = wil::scope_exit([]() { s_userSettingsInInitialization = false; }); + return UserSettings(content); + }(); - static UserSettings userSettings(content); s_userSettingsInitialized = true; - s_userSettingsInInitialization = false; return userSettings; } @@ -640,9 +792,9 @@ namespace AppInstaller::Settings UserSettings::UserSettings(const std::optional& content) : m_type(UserSettingsType::Default) { - Json::Value settingsRoot = Json::Value::nullSingleton(); + SettingsSources sources; - // Settings can be loaded from settings.json or settings.json.backup files. + // Select the ordinary source independently of the protected overlay. // 0 - Use default (empty) settings if disabled by group policy. // if // 1 - Use passed in settings content if available. @@ -660,51 +812,35 @@ namespace AppInstaller::Settings if (content.has_value()) { + sources.User.Name = "CustomSettings"sv; auto settingsJson = ParseSettingsContent(content.value(), "CustomSettings", m_warnings); if (settingsJson.has_value()) { AICLI_LOG(Core, Info, << "Settings loaded from custom settings"); - m_type = UserSettingsType::Custom; - settingsRoot = settingsJson.value(); + sources.User.Type = UserSettingsType::Custom; + sources.User.Root = std::move(settingsJson.value()); } } else { - auto settingsJson = ParseFile(Stream::PrimaryUserSettings, m_warnings); - if (settingsJson.has_value()) - { - AICLI_LOG(Core, Info, << "Settings loaded from " << Stream::PrimaryUserSettings.Name); - m_type = UserSettingsType::Standard; - settingsRoot = settingsJson.value(); - } + sources.FileBacked = true; + sources.FullToken = IsRunningWithNonDefaultFullToken(); + sources.User.LoadSettingsSource(Stream::PrimaryUserSettings, Stream::BackupUserSettings, m_warnings); + sources.Admin.LoadSettingsSource(Stream::PrimaryAdminSettingsFile, Stream::BackupAdminSettingsFile, m_warnings); + } - // Settings didn't parse or doesn't exist, try with backup. - if (settingsRoot.isNull()) - { - auto settingsBackupJson = ParseFile(Stream::BackupUserSettings, m_warnings); - if (settingsBackupJson.has_value()) - { - AICLI_LOG(Core, Info, << "Settings loaded from " << Stream::BackupUserSettings.Name); - m_warnings.emplace_back(StringResource::String::SettingsWarningLoadedBackupSettings); - m_type = UserSettingsType::Backup; - settingsRoot = settingsBackupJson.value(); - } - else - { - // Settings and back up didn't parse or exist. If they exist then warn the user. - auto settingsPath = Stream{ Stream::PrimaryUserSettings }.GetPath(); - auto backupPath = Stream{ Stream::BackupUserSettings }.GetPath(); - if (std::filesystem::exists(settingsPath) || std::filesystem::exists(backupPath)) - { - m_warnings.emplace_back(StringResource::String::SettingsWarningUsingDefault); - } - } - } + m_type = sources.User.Type; + m_adminType = sources.Admin.Type; + if (sources.Admin.Failed) + { + AICLI_LOG(Core, Warning, << "Protected settings could not be loaded. Ignoring file-backed settings and using policy or default values."); + ValidateAll(SettingsSources{}, m_settings, m_warnings, std::make_index_sequence(Setting::Max)>()); + return; } - if (!settingsRoot.isNull()) + if (!sources.User.Root.isNull() || !sources.Admin.Root.isNull()) { - ValidateAll(settingsRoot, m_settings, m_warnings, std::make_index_sequence(Setting::Max)>()); + ValidateAll(sources, m_settings, m_warnings, std::make_index_sequence(Setting::Max)>()); } else { @@ -712,28 +848,61 @@ namespace AppInstaller::Settings } } - void UserSettings::PrepareToShellExecuteFile() const + void UserSettings::CreateAdminSettingsFile() { - UserSettingsType userSettingType = GetType(); - - if (userSettingType == UserSettingsType::Default) + THROW_HR_IF(E_ACCESSDENIED, !IsRunningAsAdminOrSystem()); + Stream primarySettings{ Stream::PrimaryAdminSettingsFile }; + if (!std::filesystem::exists(primarySettings.GetPath())) { - Stream primarySettings{ Stream::PrimaryUserSettings }; + THROW_HR_IF(E_UNEXPECTED, !primarySettings.Set(s_SettingEmpty)); + AICLI_LOG(Core, Info, << "Created new protected settings file"); + } + } - // Create settings file if it doesn't exist. - if (!std::filesystem::exists(primarySettings.GetPath())) + void UserSettings::PrepareToShellExecuteFile(bool admin) const + { + if (admin) + { + if (IsRunningAsAdminOrSystem()) { - std::ignore = primarySettings.Set(s_SettingEmpty); - AICLI_LOG(Core, Info, << "Created new settings file"); + if (m_adminType == UserSettingsType::Standard) + { + auto stream = Stream{ Stream::PrimaryAdminSettingsFile }.Get(); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND), !stream); + auto content = Utility::ReadEntireStream(*stream); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_READ_FAULT), stream->fail()); + THROW_HR_IF(E_UNEXPECTED, !Stream{ Stream::BackupAdminSettingsFile }.Set(content)); + AICLI_LOG(Core, Info, << "Copied protected settings to backup file"); + } + else + { + CreateAdminSettingsFile(); + } } } - else if (userSettingType == UserSettingsType::Standard) + else { - // Settings file was loaded correctly, create backup. - auto from = SettingsFilePath(); - auto to = Stream{ Stream::BackupUserSettings }.GetPath(); - std::filesystem::copy_file(from, to, std::filesystem::copy_options::overwrite_existing); - AICLI_LOG(Core, Info, << "Copied settings to backup file"); + UserSettingsType userSettingType = GetType(); + + if (userSettingType == UserSettingsType::Default) + { + Stream primarySettings{ Stream::PrimaryUserSettings }; + + // Create settings file if it doesn't exist. + if (!std::filesystem::exists(primarySettings.GetPath())) + { + std::ignore = primarySettings.Set(s_SettingEmpty); + AICLI_LOG(Core, Info, << "Created new settings file"); + } + } + else if (userSettingType == UserSettingsType::Standard) + { + // Settings file was loaded correctly, create backup. + auto from = SettingsFilePath(); + auto to = Stream{ Stream::BackupUserSettings }.GetPath(); + std::filesystem::copy_file(from, to, std::filesystem::copy_options::overwrite_existing); + AICLI_LOG(Core, Info, << "Copied settings to backup file"); + } } } @@ -748,4 +917,9 @@ namespace AppInstaller::Settings return path; } + + std::filesystem::path UserSettings::AdminSettingsFilePath() + { + return Stream{ Stream::PrimaryAdminSettingsFile }.GetPath(); + } } diff --git a/src/AppInstallerCommonCore/pch.h b/src/AppInstallerCommonCore/pch.h index f9c8fdfe25..0546e6190a 100644 --- a/src/AppInstallerCommonCore/pch.h +++ b/src/AppInstallerCommonCore/pch.h @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include diff --git a/src/AppInstallerSharedLib/Public/winget/Resources.h b/src/AppInstallerSharedLib/Public/winget/Resources.h index 800ce9b399..411f73337b 100644 --- a/src/AppInstallerSharedLib/Public/winget/Resources.h +++ b/src/AppInstallerSharedLib/Public/winget/Resources.h @@ -66,9 +66,12 @@ namespace AppInstaller WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningInvalidFieldFormat); WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningInvalidFieldValue); + WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningInvalidFileFormat); WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningInvalidValueFromPolicy); + WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningIgnoredWithFullToken); WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningLoadedBackupSettings); WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningParseError); + WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningReadError); WINGET_DEFINE_RESOURCE_STRINGID(SettingsWarningUsingDefault); WINGET_DEFINE_RESOURCE_STRINGID(UnknownErrorCode); diff --git a/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/HostedEnvironment.cs b/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/HostedEnvironment.cs index c56b6242d0..f261bb7da3 100644 --- a/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/HostedEnvironment.cs +++ b/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/HostedEnvironment.cs @@ -78,9 +78,7 @@ public void ValidateRunspace() throw new NotSupportedException("Only PowerShell Core is supported."); } - // If opening a runspace has failures, like one of the modules in ImportPSModule is not found, it won't throw but - // write to the error output. This is not a fatal error, since we install PSDesiredStateConfiguration - // module if not found, so unless there's a real reason keep it in verbose. + // Report nonfatal runspace initialization errors before validating required modules. var errors = this.GetVariable(Variables.Error); if (errors.Count > 0) { @@ -491,11 +489,33 @@ public void AppendPSModulePaths(IReadOnlyList paths) /// public void CleanupPSModulePath(string path) { - string newModulePath = this.GetVariable(Variables.PSModulePath) - .Replace($"{path};", null) - .Replace($";{path}", null); + string normalizedPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var remainingPaths = new List(); + foreach (string entry in this.GetVariable(Variables.PSModulePath).Split(';')) + { + if (entry.Length > 0 && string.IsNullOrWhiteSpace(entry)) + { + this.OnDiagnostics(DiagnosticLevel.Warning, "Ignoring whitespace-only PSModulePath entry."); + continue; + } + + try + { + if (entry.Length == 0 || !string.Equals( + Path.GetFullPath(entry).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + normalizedPath, + StringComparison.OrdinalIgnoreCase)) + { + remainingPaths.Add(entry); + } + } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) + { + this.OnDiagnostics(DiagnosticLevel.Warning, $"Ignoring invalid PSModulePath entry '{entry}': {ex.Message}"); + } + } - this.SetPSModulePath(newModulePath); + this.SetPSModulePath(string.Join(";", remainingPaths)); } /// diff --git a/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/ProcessorEnvironmentFactory.cs b/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/ProcessorEnvironmentFactory.cs index af7b80b48d..eb970a745a 100644 --- a/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/ProcessorEnvironmentFactory.cs +++ b/src/Microsoft.Management.Configuration.Processor/PowerShell/ProcessorEnvironments/ProcessorEnvironmentFactory.cs @@ -7,12 +7,10 @@ namespace Microsoft.Management.Configuration.Processor.PowerShell.ProcessorEnvironments { using System; - using System.Collections.Generic; using System.Management.Automation.Runspaces; using Microsoft.Management.Configuration.Processor.PowerShell.DscModules; using Microsoft.Management.Configuration.Processor.PowerShell.Runspaces; using Microsoft.PowerShell; - using Microsoft.PowerShell.Commands; /// /// Factory class to create a processor environment. @@ -61,12 +59,8 @@ public IProcessorEnvironment CreateEnvironment( if (this.type == PowerShellConfigurationProcessorType.Hosted || this.type == PowerShellConfigurationProcessorType.Default) { - var initialSessionState = this.CreateInitialSessionState( - executionPolicy, - new List - { - dscModule.ModuleSpecification, - }); + var initialSessionState = InitialSessionState.CreateDefault2(); + initialSessionState.ExecutionPolicy = executionPolicy; var runspace = RunspaceFactory.CreateRunspace(initialSessionState); runspace.Open(); @@ -80,19 +74,6 @@ public IProcessorEnvironment CreateEnvironment( throw new ArgumentException(this.type.ToString()); } - private InitialSessionState CreateInitialSessionState(ExecutionPolicy policy, IReadOnlyList modules) - { - InitialSessionState initialSessionState = InitialSessionState.CreateDefault(); - - // If this call fails importing the module, it won't throw but write to the error output. DSCModule is - // in charge of verifying that it got loaded correctly and if not, to install it. - initialSessionState.ImportPSModule(modules); - - initialSessionState.ExecutionPolicy = policy; - - return initialSessionState; - } - private ExecutionPolicy GetExecutionPolicy(PowerShellConfigurationProcessorPolicy policy) { return policy switch diff --git a/src/Microsoft.Management.Configuration.Processor/Public/PowerShellConfigurationSetProcessorFactory.cs b/src/Microsoft.Management.Configuration.Processor/Public/PowerShellConfigurationSetProcessorFactory.cs index c76d7f03bb..df3800c7f3 100644 --- a/src/Microsoft.Management.Configuration.Processor/Public/PowerShellConfigurationSetProcessorFactory.cs +++ b/src/Microsoft.Management.Configuration.Processor/Public/PowerShellConfigurationSetProcessorFactory.cs @@ -9,6 +9,7 @@ namespace Microsoft.Management.Configuration.Processor using System; using System.Collections.Generic; using System.IO; + using System.Runtime.InteropServices; using System.Text; using Microsoft.Management.Configuration; using Microsoft.Management.Configuration.Processor.Factory; @@ -33,6 +34,7 @@ sealed partial class PowerShellConfigurationSetProcessorFactory : ConfigurationS private IReadOnlyList? implicitModulePaths; private PowerShellConfigurationProcessorPolicy policy = PowerShellConfigurationProcessorPolicy.Default; private PowerShellConfigurationProcessorLocation location = PowerShellConfigurationProcessorLocation.Default; + private bool useSettingsForModulePath = true; private string? customLocation; /// @@ -198,6 +200,7 @@ public PowerShellConfigurationProcessorLocation Location } this.location = value; + this.useSettingsForModulePath = false; } } @@ -232,16 +235,27 @@ public string? CustomLocation } /// - /// Gets the winget module path. + /// Gets the unsecured winget module path, including for inherited-path cleanup. /// - /// The winget module path. - internal static string GetWinGetModulePath() + /// The unsecured winget module path. + internal static string GetUnsecuredDefaultWinGetModulePath() { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Microsoft\WinGet\Configuration\Modules"); } + /// + /// Gets the default winget module path. + /// + /// Whether to allow settings override. + /// The default winget module path. + internal static (string, bool) GetDefaultWinGetModulePath(bool allowSettingsOverride = false) + { + WindowsPackageManagerConfigurationGetModulePath(allowSettingsOverride, out bool fullToken, out string defaultModulePath); + return (defaultModulePath, fullToken); + } + /// /// Sends diagnostic if appropriate for PowerShell streams. /// @@ -281,26 +295,36 @@ internal void OnDiagnostics(DiagnosticLevel level, System.Management.Automation. } } - /// - protected override IConfigurationSetProcessor CreateSetProcessorInternal(ConfigurationSet? set, bool isLimitMode) + /// + /// Configures discovery before importing modules, preserving explicit caller paths. + /// + /// The processor environment. + /// Whether the native runtime selected the full-token context. + /// The context-specific native default module path. + /// The locally resolved effective root, or the built-in default for explicit selections. + internal void ConfigureModulePaths(IProcessorEnvironment processorEnvironment, bool fullToken, string defaultModulePath, string effectiveModulePath) { - var envFactory = new ProcessorEnvironmentFactory(this.ProcessorType); - var processorEnvironment = envFactory.CreateEnvironment( - this, - this.Policy); + // If the native runtime selected the full-token context, we need to clean up inherited paths to remove the unsecured default winget module path. + if (fullToken) + { + processorEnvironment.CleanupPSModulePath(GetUnsecuredDefaultWinGetModulePath()); + } if (this.AdditionalModulePaths is not null) { processorEnvironment.PrependPSModulePaths(this.AdditionalModulePaths); } - // Always add the winget path. - var wingetModulePath = GetWinGetModulePath(); - processorEnvironment.PrependPSModulePath(wingetModulePath); + // Always add the context-specific winget path. + processorEnvironment.PrependPSModulePath(defaultModulePath); if (this.Location == PowerShellConfigurationProcessorLocation.WinGetModulePath) { - this.OnDiagnostics(DiagnosticLevel.Verbose, "Using winget module path"); - processorEnvironment.SetLocation(PowerShellConfigurationProcessorLocation.Custom, wingetModulePath); + this.OnDiagnostics(DiagnosticLevel.Verbose, "Using locally resolved winget module path"); + processorEnvironment.SetLocation(PowerShellConfigurationProcessorLocation.Custom, effectiveModulePath); + if (effectiveModulePath != defaultModulePath) + { + processorEnvironment.PrependPSModulePath(effectiveModulePath); + } } else if (this.Location == PowerShellConfigurationProcessorLocation.Custom) { @@ -316,6 +340,24 @@ protected override IConfigurationSetProcessor CreateSetProcessorInternal(Configu { processorEnvironment.SetLocation(this.Location, null); } + } + + /// + protected override IConfigurationSetProcessor CreateSetProcessorInternal(ConfigurationSet? set, bool isLimitMode) + { + (string defaultModulePath, bool fullToken) = GetDefaultWinGetModulePath(false); + string settingsModulePath = defaultModulePath; + if (this.useSettingsForModulePath) + { + (settingsModulePath, fullToken) = GetDefaultWinGetModulePath(true); + } + + var envFactory = new ProcessorEnvironmentFactory(this.ProcessorType); + var processorEnvironment = envFactory.CreateEnvironment( + this, + this.Policy); + + this.ConfigureModulePaths(processorEnvironment, fullToken, defaultModulePath, settingsModulePath); this.OnDiagnostics(DiagnosticLevel.Verbose, $" Effective module path:\n{processorEnvironment.GetVariable(Variables.PSModulePath)}"); @@ -323,5 +365,11 @@ protected override IConfigurationSetProcessor CreateSetProcessorInternal(Configu return new PowerShellConfigurationSetProcessor(processorEnvironment, set, isLimitMode) { SetProcessorFactory = this }; } + + [DllImport("WindowsPackageManager.dll", ExactSpelling = true, PreserveSig = false)] + private static extern void WindowsPackageManagerConfigurationGetModulePath( + [MarshalAs(UnmanagedType.Bool)] bool useSettings, + [MarshalAs(UnmanagedType.Bool)] out bool fullToken, + [MarshalAs(UnmanagedType.BStr)] out string path); } } diff --git a/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs b/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs index f9e2a15bee..c3d73d8b51 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs @@ -11,6 +11,7 @@ namespace Microsoft.Management.Configuration.UnitTests.Fixtures using System.IO; using System.Reflection; using Microsoft.Management.Configuration.Processor; + using Microsoft.Management.Configuration.Processor.PowerShell.DscModules; using Microsoft.Management.Configuration.Processor.PowerShell.ProcessorEnvironments; using WinRT; using Xunit.Abstractions; @@ -117,6 +118,10 @@ internal IProcessorEnvironment PrepareTestProcessorEnvironment(bool validate = f { processorEnv.ValidateRunspace(); } + else + { + processorEnv.ImportModule(new DscModuleV2().ModuleSpecification); + } return processorEnv; } diff --git a/src/Microsoft.Management.Configuration.UnitTests/Microsoft.Management.Configuration.UnitTests.csproj b/src/Microsoft.Management.Configuration.UnitTests/Microsoft.Management.Configuration.UnitTests.csproj index 093d95c049..44c00e2091 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Microsoft.Management.Configuration.UnitTests.csproj +++ b/src/Microsoft.Management.Configuration.UnitTests/Microsoft.Management.Configuration.UnitTests.csproj @@ -49,6 +49,16 @@ True + + False + + + + + + WindowsPackageManager.dll + PreserveNewest + diff --git a/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorFactoryTests.cs b/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorFactoryTests.cs index c09384f770..3445bdc367 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorFactoryTests.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorFactoryTests.cs @@ -6,7 +6,9 @@ namespace Microsoft.Management.Configuration.UnitTests.Tests { + using System; using System.Collections.Generic; + using System.IO; using Microsoft.Management.Configuration.Processor; using Microsoft.Management.Configuration.Processor.PowerShell.Set; using Microsoft.Management.Configuration.UnitTests.Fixtures; @@ -43,7 +45,10 @@ public ConfigurationProcessorFactoryTests(UnitTestFixture fixture, ITestOutputHe [Fact] public void CreateSetProcessor_Test() { - var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory(); + var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory + { + ImplicitModulePaths = new List { this.fixture.ExternalModulesPath }, + }; var properties = configurationProcessorFactory.As(); properties.ProcessorType = PowerShellConfigurationProcessorType.Hosted; @@ -64,7 +69,10 @@ public void CreateSetProcessor_Test() [Fact] public void CreateSetProcessor_Properties_PsModulePath() { - var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory(); + var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory + { + ImplicitModulePaths = new List { this.fixture.ExternalModulesPath }, + }; var properties = configurationProcessorFactory.As(); properties.ProcessorType = PowerShellConfigurationProcessorType.Hosted; @@ -87,7 +95,7 @@ public void CreateSetProcessor_Properties_PsModulePath() } /// - /// Make sure the winget path is always added to PSModulePath. + /// Make sure the context-specific winget path is always added to PSModulePath. /// /// Location. [Theory] @@ -97,7 +105,10 @@ public void CreateSetProcessor_Properties_PsModulePath() [InlineData(PowerShellConfigurationProcessorLocation.Custom)] public void CreateSetProcessor_WinGetPath(PowerShellConfigurationProcessorLocation location) { - var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory(); + var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory + { + ImplicitModulePaths = new List { this.fixture.ExternalModulesPath }, + }; var properties = configurationProcessorFactory.As(); properties.Location = location; @@ -113,7 +124,136 @@ public void CreateSetProcessor_WinGetPath(PowerShellConfigurationProcessorLocati Assert.NotNull(setProcessor); var modulePath = setProcessor.ProcessorEnvironment.GetVariable(Variables.PSModulePath); - Assert.Contains($"{PowerShellConfigurationSetProcessorFactory.GetWinGetModulePath()};", modulePath); + Assert.Contains($"{PowerShellConfigurationSetProcessorFactory.GetDefaultWinGetModulePath().Item1};", modulePath); + } + + /// + /// Full-token discovery excludes inherited ordinary paths, while ordinary discovery remains unchanged. + /// + /// Whether the factory is configuring a full-token environment. + /// Whether an explicit custom location is selected. + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ConfigureModulePaths_TokenContext(bool fullToken, bool custom) + { + string ordinaryPath = PowerShellConfigurationSetProcessorFactory.GetUnsecuredDefaultWinGetModulePath(); + string defaultPath = fullToken ? @"C:\Protected\Modules" : ordinaryPath; + var factory = new PowerShellConfigurationSetProcessorFactory + { + AdditionalModulePaths = new List { @"C:\Explicit\Additional" }, + ImplicitModulePaths = new List { @"C:\Explicit\Implicit" }, + }; + if (custom) + { + factory.Location = PowerShellConfigurationProcessorLocation.Custom; + factory.CustomLocation = @"C:\Explicit\Custom"; + } + + var environment = this.fixture.PrepareTestProcessorEnvironment(); + environment.SetPSModulePaths(new List + { + @"C:\Other\Modules", + ordinaryPath.ToUpperInvariant() + @"\", + Path.Combine(ordinaryPath, "."), + ordinaryPath.Replace('\\', '/') + "/", + ordinaryPath + @"\Child", + ordinaryPath + "Sibling", + }); + + factory.ConfigureModulePaths(environment, fullToken, defaultPath, defaultPath); + + string[] paths = environment.GetVariable(Variables.PSModulePath).Split(';'); + Assert.Contains(defaultPath, paths); + Assert.Contains(@"C:\Other\Modules", paths); + Assert.Contains(@"C:\Explicit\Additional", paths); + Assert.Contains(@"C:\Explicit\Implicit", paths); + Assert.Contains(ordinaryPath + @"\Child", paths); + Assert.Contains(ordinaryPath + "Sibling", paths); + if (fullToken) + { + Assert.DoesNotContain(paths, path => string.Equals( + Path.GetFullPath(path).TrimEnd('\\', '/'), + ordinaryPath, + StringComparison.OrdinalIgnoreCase)); + } + else + { + Assert.Contains(ordinaryPath.ToUpperInvariant() + @"\", paths); + } + + if (custom) + { + Assert.Equal(factory.CustomLocation, paths[0]); + } + } + + /// + /// An explicitly supplied ordinary directory remains authoritative even with a full token. + /// + /// Whether the ordinary directory is an explicit custom location. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ConfigureModulePaths_ExplicitOrdinaryPath(bool custom) + { + string ordinaryPath = PowerShellConfigurationSetProcessorFactory.GetUnsecuredDefaultWinGetModulePath(); + var factory = new PowerShellConfigurationSetProcessorFactory(); + if (custom) + { + factory.Location = PowerShellConfigurationProcessorLocation.Custom; + factory.CustomLocation = ordinaryPath; + } + else + { + factory.AdditionalModulePaths = new List { ordinaryPath }; + } + + var environment = this.fixture.PrepareTestProcessorEnvironment(); + environment.SetPSModulePath(ordinaryPath); + factory.ConfigureModulePaths(environment, true, @"C:\Protected\Modules", @"C:\Protected\Modules"); + + string[] paths = environment.GetVariable(Variables.PSModulePath).Split(';'); + Assert.Contains(ordinaryPath, paths); + Assert.Contains(@"C:\Protected\Modules", paths); + } + + /// + /// The locally resolved setting supplies the implicit root without changing explicit locations. + /// + /// The explicit selection, or null for an implicit settings-based root. + [Theory] + [InlineData(null)] + [InlineData(PowerShellConfigurationProcessorLocation.WinGetModulePath)] + [InlineData(PowerShellConfigurationProcessorLocation.Custom)] + public void ConfigureModulePaths_LocallyResolvedRoot(PowerShellConfigurationProcessorLocation? explicitLocation) + { + var factory = new PowerShellConfigurationSetProcessorFactory(); + if (explicitLocation.HasValue) + { + factory.Location = explicitLocation.Value; + if (explicitLocation == PowerShellConfigurationProcessorLocation.Custom) + { + factory.CustomLocation = @"C:\Explicit\Modules"; + } + } + + var environment = this.fixture.PrepareTestProcessorEnvironment(); + environment.SetPSModulePath(@"C:\Other\Modules"); + string effectiveModulePath = explicitLocation.HasValue ? @"C:\Protected\Default" : @"C:\Protected\Configured"; + factory.ConfigureModulePaths(environment, true, @"C:\Protected\Default", effectiveModulePath); + + string[] paths = environment.GetVariable(Variables.PSModulePath).Split(';'); + Assert.Contains(@"C:\Protected\Default", paths); + string expectedRoot = !explicitLocation.HasValue ? @"C:\Protected\Configured" : + explicitLocation == PowerShellConfigurationProcessorLocation.Custom ? @"C:\Explicit\Modules" : @"C:\Protected\Default"; + Assert.Equal(expectedRoot, paths[0]); + if (explicitLocation.HasValue) + { + Assert.DoesNotContain(@"C:\Protected\Configured", paths); + } } /// @@ -122,7 +262,10 @@ public void CreateSetProcessor_WinGetPath(PowerShellConfigurationProcessorLocati [Fact] public void CreateSetProcessor_CustomLocation() { - var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory(); + var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory + { + ImplicitModulePaths = new List { this.fixture.ExternalModulesPath }, + }; var properties = configurationProcessorFactory.As(); properties.Location = PowerShellConfigurationProcessorLocation.Custom; @@ -143,7 +286,10 @@ public void CreateSetProcessor_CustomLocation() [Fact] public void CreateSetProcessor_LimitMode() { - var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory(); + var configurationProcessorFactory = new PowerShellConfigurationSetProcessorFactory + { + ImplicitModulePaths = new List { this.fixture.ExternalModulesPath }, + }; var configurationSet = new ConfigurationSet(); configurationProcessorFactory.LimitationSet = configurationSet; diff --git a/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorTelemetryTests.cs b/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorTelemetryTests.cs index 56cebee8d7..70679f151f 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorTelemetryTests.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Tests/ConfigurationProcessorTelemetryTests.cs @@ -17,10 +17,10 @@ namespace Microsoft.Management.Configuration.UnitTests.Tests using Microsoft.Management.Configuration.UnitTests.Helpers; using Microsoft.VisualBasic; using Microsoft.VisualStudio.TestPlatform.ObjectModel; - using TelemetryEvent = Microsoft.Management.Configuration.UnitTests.Helpers.TelemetryEvent; using Xunit; using Xunit.Abstractions; using static System.Collections.Specialized.BitVector32; + using TelemetryEvent = Microsoft.Management.Configuration.UnitTests.Helpers.TelemetryEvent; /// /// Unit tests for running test on the processor. diff --git a/src/Microsoft.Management.Configuration.UnitTests/Tests/ProcessorEnvironmentTests.cs b/src/Microsoft.Management.Configuration.UnitTests/Tests/ProcessorEnvironmentTests.cs index 0c6ab1e8c3..eb1a15765f 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Tests/ProcessorEnvironmentTests.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Tests/ProcessorEnvironmentTests.cs @@ -7,6 +7,9 @@ namespace Microsoft.Management.Configuration.UnitTests.Tests { using System.Collections.Generic; + using Microsoft.Management.Configuration.Processor; + using Microsoft.Management.Configuration.Processor.PowerShell.DscModules; + using Microsoft.Management.Configuration.Processor.PowerShell.ProcessorEnvironments; using Microsoft.Management.Configuration.UnitTests.Fixtures; using Microsoft.Management.Configuration.UnitTests.Helpers; using Moq; @@ -35,6 +38,27 @@ public ProcessorEnvironmentTests(UnitTestFixture fixture, ITestOutputHelper log) this.log = log; } + /// + /// DSC import waits until paths are configured and the runspace is validated. + /// + /// The processor type. + [Theory] + [InlineData(PowerShellConfigurationProcessorType.Default)] + [InlineData(PowerShellConfigurationProcessorType.Hosted)] + public void CreateEnvironment_DefersDscImport(PowerShellConfigurationProcessorType type) + { + var environment = new ProcessorEnvironmentFactory(type).CreateEnvironment( + null, + PowerShellConfigurationProcessorPolicy.Unrestricted); + using var runspace = environment.Runspace; + var specification = new DscModuleV2().ModuleSpecification; + + Assert.Null(environment.GetImportedModule(specification)); + environment.PrependPSModulePath(this.fixture.ExternalModulesPath); + environment.ValidateRunspace(); + Assert.NotNull(environment.GetImportedModule(specification)); + } + /// /// Test SetVariable and SetVariable methods. /// @@ -247,5 +271,27 @@ public void HostedEnvironment_CleanupPSModulePath() psModulePath = processorEnv.GetVariable(Variables.PSModulePath); Assert.Equal(psModulePathExpected, psModulePath); } + + /// + /// Cleanup removes equivalent complete entries, including a sole entry, without removing siblings. + /// + /// The inherited module path. + /// The remaining module path. + [Theory] + [InlineData(@"C:\Modules", "")] + [InlineData(@"c:/MODULES/", "")] + [InlineData(@"C:\Modules\.;C:\Other;C:\Modules\", @"C:\Other")] + [InlineData(@"C:\ModulesChild;C:\Modules\Child;C:\Modules", @"C:\ModulesChild;C:\Modules\Child")] + [InlineData(@";C:\Modules;", ";")] + [InlineData(@"C:\Other; ;C:\Modules", @"C:\Other")] + [InlineData(" \t;C:\\Modules;C:\\Other", @"C:\Other")] + [InlineData(@"; ;C:\Modules;", ";")] + public void HostedEnvironment_CleanupPSModulePath_WholeEntries(string input, string expected) + { + var environment = this.fixture.PrepareTestProcessorEnvironment(); + environment.SetPSModulePath(input); + environment.CleanupPSModulePath(@"C:\Modules"); + Assert.Equal(expected, environment.GetVariable(Variables.PSModulePath)); + } } } diff --git a/src/Microsoft.Management.Deployment.InProc/dllmain.cpp b/src/Microsoft.Management.Deployment.InProc/dllmain.cpp index 3217ae9039..8a718a4e04 100644 --- a/src/Microsoft.Management.Deployment.InProc/dllmain.cpp +++ b/src/Microsoft.Management.Deployment.InProc/dllmain.cpp @@ -28,10 +28,8 @@ EXTERN_C BOOL WINAPI DllMain( return TRUE; } -STDAPI DllGetClassObject( - REFCLSID rclsid, - REFIID riid, - LPVOID* ppv) +_Check_return_ +STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID FAR* ppv) { RETURN_HR_IF_NULL(E_POINTER, ppv); *ppv = nullptr; @@ -39,6 +37,7 @@ STDAPI DllGetClassObject( RETURN_HR(WindowsPackageManagerInProcModuleGetClassObject(rclsid, riid, ppv)); } +__control_entrypoint(DllExport) STDAPI DllCanUnloadNow() { return WindowsPackageManagerInProcModuleTerminate() ? S_OK : S_FALSE; diff --git a/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetSetting.md b/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetSetting.md index 5f333f9b0d..db1f1dcbf9 100644 --- a/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetSetting.md +++ b/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetSetting.md @@ -24,9 +24,14 @@ This command gets the WinGet configuration settings. The settings include: - the schema - administrative settings - the location of the user settings file +- the location of the protected per-user administrator settings file (`adminSettingsFile`) For more information about WinGet settings, see [WinGet CLI Settings](https://aka.ms/winget-settings). +`adminSettings` contains the existing capability controls. It is separate from the +schema-compatible JSON file identified by `adminSettingsFile`. Path discovery does not +create the protected file and remains available when its contents cannot be loaded. + ## EXAMPLES ### Example 1 - Display the WinGet configuration settings diff --git a/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetUserSetting.md b/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetUserSetting.md index 6cb4ade6d3..7107d635ae 100644 --- a/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetUserSetting.md +++ b/src/PowerShell/Help/Microsoft.WinGet.Client/Get-WinGetUserSetting.md @@ -15,7 +15,7 @@ Gets user settings for WinGet. ## SYNTAX ``` -Get-WinGetUserSetting [] +Get-WinGetUserSetting [-Admin] [] ``` ## DESCRIPTION @@ -24,6 +24,10 @@ This command displays the WinGet settings for the current user. The settings are `$env:LOCALAPPDATA\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json`. This file only exists if you have changed a user setting, for example, using the `Set-WinGetUserSetting` command. +Use `-Admin` to read the protected per-user administrator settings file instead. +This returns the raw selected file, not the effective overlay or administrator capability controls. +Reading does not require elevation and does not create the file. + ## EXAMPLES ### Example 1: Get the WinGet settings for the current user @@ -34,6 +38,23 @@ Get-WinGetUserSetting ## PARAMETERS +### -Admin + +Reads the protected administrator settings file. Its path is available as +`(Get-WinGetSetting).adminSettingsFile`. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, diff --git a/src/PowerShell/Help/Microsoft.WinGet.Client/Set-WinGetUserSetting.md b/src/PowerShell/Help/Microsoft.WinGet.Client/Set-WinGetUserSetting.md index cba5723f38..ba3cd1924c 100644 --- a/src/PowerShell/Help/Microsoft.WinGet.Client/Set-WinGetUserSetting.md +++ b/src/PowerShell/Help/Microsoft.WinGet.Client/Set-WinGetUserSetting.md @@ -1,7 +1,7 @@ --- external help file: Microsoft.WinGet.Client.Cmdlets.dll-Help.xml Module Name: Microsoft.WinGet.Client -ms.date: 08/01/2024 +ms.date: 11/03/2026 online version: schema: 2.0.0 title: Set-WinGetUserSetting @@ -15,7 +15,7 @@ Sets configuration settings of the WinGet client for the current user. ## SYNTAX ``` -Set-WinGetUserSetting -UserSettings [-Merge] [] +Set-WinGetUserSetting -UserSettings [-Merge] [-Admin] [] ``` ## DESCRIPTION @@ -26,6 +26,15 @@ settings file doesn't exist until you set a specific value. The file is stored i For more information about WinGet settings, see [WinGet CLI Settings](https://aka.ms/winget-settings). +Use `-Admin` to write the protected per-user administrator settings file instead. +This requires elevation and creates the protected file if needed. The command modifies the raw +selected file, not the effective overlay or administrator capability controls. It does not create +a backup. Existing `-Merge` behavior is unchanged, including ignoring input nulls during merge; +omit `-Merge` to write an explicit null that selects a built-in default when the protected +value is eligible for the current token context. +An eligible protected null prevents ordinary-file fallback without a validation or migration +warning. Existing Group Policy handling still takes precedence. + ## EXAMPLES ### Example 1: Set progress bar theme @@ -65,8 +74,41 @@ Set-WinGetUserSetting -UserSettings @{ } ``` +### Example 4: Select the built-in download directory with protected settings + +Run from an elevated PowerShell session: + +```powershell +Set-WinGetUserSetting -Admin -UserSettings @{ + downloadBehavior = @{ + defaultDownloadDirectory = $null + } +} +``` + +Replaces the protected file's settings with an explicit default selection. The ordinary file +cannot override this download directory in either token context. Include any other protected +settings you want to retain; do not use `-Merge` to persist an input null. + ## PARAMETERS +### -Admin + +Writes the protected administrator settings file. Requires administrator privileges. +Its path is available as `(Get-WinGetSetting).adminSettingsFile`. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + ### -Merge By default, the command overwrites the current setting with the values provided. Use this parameter diff --git a/src/PowerShell/Help/Microsoft.WinGet.Client/Test-WinGetUserSetting.md b/src/PowerShell/Help/Microsoft.WinGet.Client/Test-WinGetUserSetting.md index b90bebaf3f..5b67ed9b0c 100644 --- a/src/PowerShell/Help/Microsoft.WinGet.Client/Test-WinGetUserSetting.md +++ b/src/PowerShell/Help/Microsoft.WinGet.Client/Test-WinGetUserSetting.md @@ -15,13 +15,17 @@ Tests the current state of WinGet user settings. ## SYNTAX ``` -Test-WinGetUserSetting -UserSettings [-IgnoreNotSet] [] +Test-WinGetUserSetting -UserSettings [-IgnoreNotSet] [-Admin] [] ``` ## DESCRIPTION This command tests the current state of WinGet user settings against a provided set of values. +Use `-Admin` to compare against the raw protected per-user administrator settings file instead +of the ordinary file. This does not compare the effective overlay or administrator capability +controls. Testing does not require elevation or create files. + ## EXAMPLES ### Example 1: Test for exact match @@ -55,6 +59,23 @@ WinGet settings in the comparison. ## PARAMETERS +### -Admin + +Tests the protected administrator settings file. Its path is available as +`(Get-WinGetSetting).adminSettingsFile`. + +```yaml +Type: System.Management.Automation.SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + ### -IgnoreNotSet When you use the **IgnoreNotSet** parameter, the command only tests the provide values and doesn't diff --git a/src/PowerShell/Help/Microsoft.WinGet.DSC/WinGetUserSettings.md b/src/PowerShell/Help/Microsoft.WinGet.DSC/WinGetUserSettings.md index 4d1894e07d..33a9e06782 100644 --- a/src/PowerShell/Help/Microsoft.WinGet.DSC/WinGetUserSettings.md +++ b/src/PowerShell/Help/Microsoft.WinGet.DSC/WinGetUserSettings.md @@ -1,7 +1,7 @@ --- external help file: Microsoft.WinGet.DSC.psm1-Help.xml Module Name: Microsoft.WinGet.DSC -ms.date: 08/28/2024 +ms.date: 11/03/2026 online version: schema: 2.0.0 title: WinGetUserSettings @@ -16,6 +16,16 @@ Configures WinGet user settings. Allows part or all of the user settings to be configured or retrieved. +By default this resource manages the ordinary user settings file. Set `Admin` to `$true` +to manage the protected per-user administrator settings file. Get and Test do not require +elevation; Set requires elevation and creates the protected file if needed. This manages +raw file contents, not the effective overlay or the capability controls managed by +`WinGetAdminSettings`. Partial updates retain the existing merge behavior, which ignores +input nulls; use `Full` to write an explicit null. +An eligible protected null selects the built-in default without a validation or migration +warning and prevents ordinary-file fallback. Protected module-root and default-install-root +values apply only with the non-default full token. Existing Group Policy handling takes precedence. + ## PARAMETERS **Parameter**|**Attribute**|**DataType**|**Description**|**Allowed Values** @@ -23,3 +33,4 @@ Allows part or all of the user settings to be configured or retrieved. `SID`|Key|String|The SID of the user to target, or an empty string to target the current user. **Only the empty string is currently supported.**|See [security identifiers](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers) `Settings`|Mandatory|Hashtable|The user settings as a hashtable.|See [the schema](https://github.com/microsoft/winget-cli/blob/master/schemas/JSON/settings/settings.schema.0.2.json) `Action`|Optional|WinGetAction|Determines whether to overwrite (`Full`) or merge (`Partial`) the value provided in `Settings` with the current user settings.|`Full` (default), `Partial` +`Admin`|Optional|Boolean|Selects the protected administrator settings file. Mutations require administrator privileges.|`$false` (default), `$true` diff --git a/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/GetUserSettingCmdlet.cs b/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/GetUserSettingCmdlet.cs index fe9dcdefef..3d499d9155 100644 --- a/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/GetUserSettingCmdlet.cs +++ b/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/GetUserSettingCmdlet.cs @@ -19,12 +19,18 @@ namespace Microsoft.WinGet.Client.Commands [OutputType(typeof(Hashtable))] public sealed class GetUserSettingCmdlet : PSCmdlet { + /// + /// Gets or sets a value indicating whether to target the protected administrator settings file. + /// + [Parameter(ValueFromPipelineByPropertyName = true)] + public SwitchParameter Admin { get; set; } + /// /// Gets the settings file contents. /// protected override void ProcessRecord() { - var command = new UserSettingsCommand(this); + var command = new UserSettingsCommand(this, this.Admin.ToBool()); command.Get(); } } diff --git a/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/SetUserSettingCmdlet.cs b/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/SetUserSettingCmdlet.cs index 12a0764dff..94197bb19a 100644 --- a/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/SetUserSettingCmdlet.cs +++ b/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/SetUserSettingCmdlet.cs @@ -20,6 +20,12 @@ namespace Microsoft.WinGet.Client.Commands [OutputType(typeof(Hashtable))] public sealed class SetUserSettingCmdlet : PSCmdlet { + /// + /// Gets or sets a value indicating whether to target the protected administrator settings file. + /// + [Parameter(ValueFromPipelineByPropertyName = true)] + public SwitchParameter Admin { get; set; } + /// /// Gets or sets the input user settings. /// @@ -39,7 +45,7 @@ public sealed class SetUserSettingCmdlet : PSCmdlet /// protected override void ProcessRecord() { - var command = new UserSettingsCommand(this); + var command = new UserSettingsCommand(this, this.Admin.ToBool()); command.Set(this.UserSettings, this.Merge.ToBool()); } } diff --git a/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/TestUserSettingCmdlet.cs b/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/TestUserSettingCmdlet.cs index 8a9bf4910f..9936e8f337 100644 --- a/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/TestUserSettingCmdlet.cs +++ b/src/PowerShell/Microsoft.WinGet.Client.Cmdlets/Cmdlets/TestUserSettingCmdlet.cs @@ -19,6 +19,12 @@ namespace Microsoft.WinGet.Client.Commands [OutputType(typeof(bool))] public sealed class TestUserSettingCmdlet : PSCmdlet { + /// + /// Gets or sets a value indicating whether to target the protected administrator settings file. + /// + [Parameter(ValueFromPipelineByPropertyName = true)] + public SwitchParameter Admin { get; set; } + /// /// Gets or sets the input user settings. /// @@ -38,7 +44,7 @@ public sealed class TestUserSettingCmdlet : PSCmdlet /// protected override void ProcessRecord() { - var command = new UserSettingsCommand(this); + var command = new UserSettingsCommand(this, this.Admin.ToBool()); command.Test(this.UserSettings, this.IgnoreNotSet.ToBool()); } } diff --git a/src/PowerShell/Microsoft.WinGet.Client.Engine/Commands/UserSettingsCommand.cs b/src/PowerShell/Microsoft.WinGet.Client.Engine/Commands/UserSettingsCommand.cs index 54fb476650..bce8e31b51 100644 --- a/src/PowerShell/Microsoft.WinGet.Client.Engine/Commands/UserSettingsCommand.cs +++ b/src/PowerShell/Microsoft.WinGet.Client.Engine/Commands/UserSettingsCommand.cs @@ -28,26 +28,39 @@ public sealed class UserSettingsCommand : BaseCommand private const string SchemaValue = "https://aka.ms/winget-settings.schema.json"; private static string? winGetSettingsFilePath; + private static string? winGetAdminSettingsFilePath; + + private readonly bool admin; + private readonly string settingsFilePath; /// /// Initializes a new instance of the class. /// /// PSCmdlet. - public UserSettingsCommand(PSCmdlet psCmdlet) + /// Target the protected administrator settings file. + public UserSettingsCommand(PSCmdlet psCmdlet, bool admin = false) : base(psCmdlet) { + this.admin = admin; + // Doing it in the static constructor will show the user running in system context: // The type initializer for 'Microsoft.WinGet.Client.Engine.Commands.UserSettingsCommand' threw an exception. // Here would be "The specified method is not supported." - if (winGetSettingsFilePath == null) + if (winGetSettingsFilePath == null || (admin && winGetAdminSettingsFilePath == null)) { var wingetCliWrapper = new WingetCLIWrapper(); var settingsResult = wingetCliWrapper.RunCommand(this, new WinGetCLICommandBuilder("settings").AppendSubCommand("export")); + settingsResult.VerifyExitCode(); - // Read the user settings file property. - var userSettingsFile = Utilities.ConvertToHashtable(settingsResult.StdOut)["userSettingsFile"] ?? throw new ArgumentNullException("userSettingsFile"); + var exportedSettings = Utilities.ConvertToHashtable(settingsResult.StdOut); + var userSettingsFile = exportedSettings["userSettingsFile"] ?? throw new ArgumentNullException("userSettingsFile"); winGetSettingsFilePath = (string)userSettingsFile; + winGetAdminSettingsFilePath = (string?)exportedSettings["adminSettingsFile"]; } + + this.settingsFilePath = admin ? + winGetAdminSettingsFilePath ?? throw new ArgumentNullException("adminSettingsFile") : + winGetSettingsFilePath!; } /// @@ -75,6 +88,11 @@ public void Test(Hashtable userSettings, bool ignoreNotSet) /// Merge the current user settings and the input settings. public void Set(Hashtable userSettings, bool merge) { + if (this.admin) + { + Utilities.VerifyAdmin(); + } + var newSettings = HashtableToJObject(userSettings); // Merge settings. @@ -102,8 +120,16 @@ public void Set(Hashtable userSettings, bool merge) // Write settings. var settingsJson = orderedSettings.ToString(Formatting.Indented); + if (this.admin && !File.Exists(this.settingsFilePath)) + { + var result = new WingetCLIWrapper().RunCommand( + this, + new WinGetCLICommandBuilder("settings").AppendSwitch("admin").AppendSwitch("create")); + result.VerifyExitCode(); + } + File.WriteAllText( - winGetSettingsFilePath!, + this.settingsFilePath, settingsJson); this.Write(StreamType.Object, Utilities.ConvertToHashtable(settingsJson)); @@ -116,8 +142,8 @@ private static JObject HashtableToJObject(Hashtable hashtable) private Hashtable GetLocalSettingsAsHashtable() { - var content = File.Exists(winGetSettingsFilePath) ? - File.ReadAllText(winGetSettingsFilePath) : + var content = File.Exists(this.settingsFilePath) ? + File.ReadAllText(this.settingsFilePath) : string.Empty; return Utilities.ConvertToHashtable(content); @@ -127,8 +153,8 @@ private JObject LocalSettingsFileToJObject() { try { - return File.Exists(winGetSettingsFilePath) ? - JObject.Parse(File.ReadAllText(winGetSettingsFilePath)) : + return File.Exists(this.settingsFilePath) ? + JObject.Parse(File.ReadAllText(this.settingsFilePath)) : new JObject(); } catch (JsonReaderException e) diff --git a/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Commands/ConfigurationCommand.cs b/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Commands/ConfigurationCommand.cs index cc9d79f62c..1f89f1d6ad 100644 --- a/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Commands/ConfigurationCommand.cs +++ b/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Commands/ConfigurationCommand.cs @@ -388,10 +388,13 @@ private IConfigurationSetProcessorFactory CreatePowerShellProcessorFactory(OpenC var properties = factory.As(); properties.Policy = openParams.Policy; properties.ProcessorType = PowerShellConfigurationProcessorType.Default; - properties.Location = openParams.Location; - if (properties.Location == PowerShellConfigurationProcessorLocation.Custom) + if (openParams.HasExplicitModulePath) { - properties.CustomLocation = openParams.CustomLocation; + properties.Location = openParams.Location; + if (properties.Location == PowerShellConfigurationProcessorLocation.Custom) + { + properties.CustomLocation = openParams.CustomLocation; + } } return factory; diff --git a/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Helpers/OpenConfigurationParameters.cs b/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Helpers/OpenConfigurationParameters.cs index 0611c7abf2..90c61fe53f 100644 --- a/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Helpers/OpenConfigurationParameters.cs +++ b/src/PowerShell/Microsoft.WinGet.Configuration.Engine/Helpers/OpenConfigurationParameters.cs @@ -92,6 +92,11 @@ public OpenConfigurationParameters( /// public PowerShellConfigurationProcessorLocation Location { get; private set; } + /// + /// Gets a value indicating whether the caller explicitly selected a module path. + /// + public bool HasExplicitModulePath { get; private set; } + /// /// Gets the custom location to install modules. /// @@ -159,8 +164,10 @@ private PowerShellConfigurationProcessorPolicy GetConfigurationProcessorPolicy(E private void InitializeModulePath(string modulePath) { + this.HasExplicitModulePath = !string.IsNullOrEmpty(modulePath); + // TODO: Create cmdlet that specify the global custom location for a PowerShell session. - if (!string.IsNullOrEmpty(modulePath)) + if (this.HasExplicitModulePath) { if (string.Compare(modulePath, Default, true) == 0) { diff --git a/src/PowerShell/Microsoft.WinGet.DSC/Microsoft.WinGet.DSC.psm1 b/src/PowerShell/Microsoft.WinGet.DSC/Microsoft.WinGet.DSC.psm1 index 6e5d411565..1c4be81c9d 100644 --- a/src/PowerShell/Microsoft.WinGet.DSC/Microsoft.WinGet.DSC.psm1 +++ b/src/PowerShell/Microsoft.WinGet.DSC/Microsoft.WinGet.DSC.psm1 @@ -74,13 +74,19 @@ class WinGetUserSettings [DscProperty()] [WinGetAction]$Action = [WinGetAction]::Full + # Target the protected administrator settings file instead of the ordinary file. + [DscProperty()] + [bool]$Admin = $false + # Gets the current UserSettings by looking at the settings.json file for the current user. [WinGetUserSettings] Get() { - $userSettings = Get-WinGetUserSetting + $userSettings = Get-WinGetUserSetting -Admin:$this.Admin $result = @{ SID = '' Settings = $userSettings + Admin = $this.Admin + Action = $this.Action } return $result } @@ -90,6 +96,7 @@ class WinGetUserSettings { $hashArgs = @{ UserSettings = $this.Settings + Admin = $this.Admin } if ($this.Action -eq [WinGetAction]::Partial) @@ -105,6 +112,7 @@ class WinGetUserSettings { $hashArgs = @{ UserSettings = $this.Settings + Admin = $this.Admin } if ($this.Action -eq [WinGetAction]::Partial) diff --git a/src/PowerShell/scripts/Initialize-LocalWinGetModules.ps1 b/src/PowerShell/scripts/Initialize-LocalWinGetModules.ps1 index 5112a385a9..4d060b36b9 100644 --- a/src/PowerShell/scripts/Initialize-LocalWinGetModules.ps1 +++ b/src/PowerShell/scripts/Initialize-LocalWinGetModules.ps1 @@ -223,6 +223,7 @@ if ($moduleToConfigure.HasFlag([ModuleType]::Configuration)) $module.PrepareScriptFiles() $additionalFiles = @( "Microsoft.Management.Configuration\Microsoft.Management.Configuration.dll" + "WindowsPackageManager\WindowsPackageManager.dll" ) $module.AddArchSpecificFiles($additionalFiles, "SharedDependencies", $BuildRoot, $Configuration) $additionalFiles = @( diff --git a/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 b/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 index 4dfca7902a..56e2113c81 100644 --- a/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 +++ b/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 @@ -913,6 +913,81 @@ Describe 'Set-WinGetUserSetting' { } } +Describe 'Protected user settings management' { + BeforeAll { + $adminSettingsFilePath = (Get-WinGetSetting).adminSettingsFile + $adminSettingsExisted = Test-Path -LiteralPath $adminSettingsFilePath + if ($adminSettingsExisted) { + $originalAdminSettings = [System.IO.File]::ReadAllBytes($adminSettingsFilePath) + } + $adminBackupPath = $adminSettingsFilePath + '.backup' + $adminBackupExisted = Test-Path -LiteralPath $adminBackupPath + if ($adminBackupExisted) { + $originalAdminBackup = [System.IO.File]::ReadAllBytes($adminBackupPath) + } + $ordinarySettingsBefore = [System.IO.File]::ReadAllBytes($settingsFilePath) + } + + It 'Creates and manages only the protected file without creating a backup' { + if (Test-Path -LiteralPath $adminSettingsFilePath) { + Remove-Item -LiteralPath $adminSettingsFilePath + } + if (Test-Path -LiteralPath $adminBackupPath) { + Remove-Item -LiteralPath $adminBackupPath + } + + (Get-WinGetUserSetting -Admin).Count | Should -Be 0 + Test-WinGetUserSetting -Admin -UserSettings @{} | Should -Be $true + Test-Path -LiteralPath $adminSettingsFilePath | Should -Be $false + + $desired = @{ visual = @{ progressBar = 'rainbow' }; downloadBehavior = @{ defaultDownloadDirectory = $null } } + $result = Set-WinGetUserSetting -Admin -UserSettings $desired + $result.visual.progressBar | Should -Be 'rainbow' + $result.downloadBehavior.ContainsKey('defaultDownloadDirectory') | Should -Be $true + $result.downloadBehavior.defaultDownloadDirectory | Should -BeNullOrEmpty + (Get-WinGetUserSetting -Admin).visual.progressBar | Should -Be 'rainbow' + Test-WinGetUserSetting -Admin -UserSettings $desired | Should -Be $true + Test-Path -LiteralPath $adminBackupPath | Should -Be $false + [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($settingsFilePath)) | + Should -Be ([Convert]::ToBase64String($ordinarySettingsBefore)) + } + + It 'Preserves merge and partial comparison behavior on the protected file' { + Set-WinGetUserSetting -Admin -UserSettings @{ visual = @{ progressBar = 'rainbow' } } | Out-Null + Set-WinGetUserSetting -Admin -Merge -UserSettings @{ source = @{ autoUpdateIntervalInMinutes = 3 } } | Out-Null + Test-WinGetUserSetting -Admin -IgnoreNotSet -UserSettings @{ visual = @{ progressBar = 'rainbow' } } | + Should -Be $true + Test-WinGetUserSetting -Admin -UserSettings @{ + visual = @{ progressBar = 'rainbow' } + source = @{ autoUpdateIntervalInMinutes = 3 } + } | Should -Be $true + } + + It 'Allows raw overwrite to repair malformed protected JSON' { + [System.IO.File]::WriteAllText($adminSettingsFilePath, '{') + { Get-WinGetUserSetting -Admin } | Should -Throw + Test-WinGetUserSetting -Admin -UserSettings @{} | Should -Be $false + { Set-WinGetUserSetting -Admin -Merge -UserSettings @{} } | Should -Throw + Set-WinGetUserSetting -Admin -UserSettings @{ visual = @{ progressBar = 'retro' } } | Out-Null + (Get-WinGetUserSetting -Admin).visual.progressBar | Should -Be 'retro' + } + + AfterAll { + if ($adminSettingsExisted) { + [System.IO.File]::WriteAllBytes($adminSettingsFilePath, $originalAdminSettings) + } + elseif (Test-Path -LiteralPath $adminSettingsFilePath) { + Remove-Item -LiteralPath $adminSettingsFilePath + } + if ($adminBackupExisted) { + [System.IO.File]::WriteAllBytes($adminBackupPath, $originalAdminBackup) + } + elseif (Test-Path -LiteralPath $adminBackupPath) { + Remove-Item -LiteralPath $adminBackupPath + } + } +} + Describe 'Get|Enable|Disable-WinGetSetting' { It 'Get-WinGetSetting' { diff --git a/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 b/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 index 827fb86505..28a33a8d81 100644 --- a/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 +++ b/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 @@ -128,6 +128,39 @@ Describe 'WinGetUserSettings' { } } +Describe 'WinGetUserSettings protected target' { + BeforeAll { + $protectedPath = (Get-WinGetSetting).adminSettingsFile + $protectedExisted = Test-Path -LiteralPath $protectedPath + if ($protectedExisted) { + $protectedContent = [System.IO.File]::ReadAllBytes($protectedPath) + } + $ordinaryPath = (Get-WinGetSetting).userSettingsFile + $ordinaryContent = [System.IO.File]::ReadAllBytes($ordinaryPath) + } + + It 'Routes Get, Test, and Set to the protected file' { + $desired = @{ visual = @{ progressBar = 'rainbow' } } + $properties = @{ Settings = $desired; Admin = $true } + InvokeWinGetDSC -Name WinGetUserSettings -Method Set -Property $properties + $result = InvokeWinGetDSC -Name WinGetUserSettings -Method Get -Property $properties + $result.Admin | Should -Be $true + $result.Settings.visual.progressBar | Should -Be 'rainbow' + (InvokeWinGetDSC -Name WinGetUserSettings -Method Test -Property $properties).InDesiredState | Should -Be $true + [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($ordinaryPath)) | + Should -Be ([Convert]::ToBase64String($ordinaryContent)) + } + + AfterAll { + if ($protectedExisted) { + [System.IO.File]::WriteAllBytes($protectedPath, $protectedContent) + } + elseif (Test-Path -LiteralPath $protectedPath) { + Remove-Item -LiteralPath $protectedPath + } + } +} + Describe 'WinGetSource' { BeforeAll { $testSourceName = 'TestSource' diff --git a/src/WindowsPackageManager/Source.def b/src/WindowsPackageManager/Source.def index 29e3ac8c24..43baf2e1df 100644 --- a/src/WindowsPackageManager/Source.def +++ b/src/WindowsPackageManager/Source.def @@ -13,3 +13,4 @@ EXPORTS WindowsPackageManagerInProcModuleGetActivationFactory WindowsPackageManagerConfigurationCompleteOutOfProcessFactoryInitialization WindowsPackageManagerServerLog + WindowsPackageManagerConfigurationGetModulePath diff --git a/src/WindowsPackageManager/WindowsPackageManager.h b/src/WindowsPackageManager/WindowsPackageManager.h index 1d3226614f..6ee6d94658 100644 --- a/src/WindowsPackageManager/WindowsPackageManager.h +++ b/src/WindowsPackageManager/WindowsPackageManager.h @@ -2,6 +2,7 @@ // Licensed under the MIT License. #pragma once #include +#include // Forward declaration namespace wil { struct FailureInfo; } @@ -51,4 +52,8 @@ extern "C" // Allows logging from callers. WINDOWS_PACKAGE_MANAGER_API WindowsPackageManagerServerLog(const char* message); + + // Gets the context-specific module path, optionally applying this process's effective user settings. + // The caller owns the returned BSTR and must free it with SysFreeString. + WINDOWS_PACKAGE_MANAGER_API WindowsPackageManagerConfigurationGetModulePath(BOOL useSettings, BOOL* fullToken, BSTR* path); } diff --git a/src/WindowsPackageManager/main.cpp b/src/WindowsPackageManager/main.cpp index fecbef7961..446c8c6d8f 100644 --- a/src/WindowsPackageManager/main.cpp +++ b/src/WindowsPackageManager/main.cpp @@ -14,7 +14,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -149,6 +151,30 @@ extern "C" } CATCH_RETURN(); + WINDOWS_PACKAGE_MANAGER_API WindowsPackageManagerConfigurationGetModulePath(BOOL useSettings, BOOL* fullToken, BSTR* path) try + { + RETURN_HR_IF_NULL(E_POINTER, fullToken); + RETURN_HR_IF_NULL(E_POINTER, path); + *path = nullptr; + *fullToken = AppInstaller::Runtime::IsRunningWithNonDefaultFullToken(); + + std::filesystem::path modulePath; + if (useSettings) + { + modulePath = AppInstaller::Settings::User().Get(); + } + if (modulePath.empty()) + { + modulePath = AppInstaller::Runtime::GetPathTo(AppInstaller::Runtime::PathName::DefaultConfigurationModules); + } + + BSTR result = SysAllocString(modulePath.c_str()); + RETURN_HR_IF_NULL(E_OUTOFMEMORY, result); + *path = result; + return S_OK; + } + CATCH_RETURN(); + #ifndef AICLI_DISABLE_TEST_HOOKS __declspec(dllexport) WINDOWS_PACKAGE_MANAGER_API WindowsPackageManagerTestHook_ReloadGroupPolicy() try { diff --git a/templates/jobs-build.yml b/templates/jobs-build.yml index c185cfe790..d8f925d98a 100644 --- a/templates/jobs-build.yml +++ b/templates/jobs-build.yml @@ -218,6 +218,7 @@ jobs: SourceFolder: $(buildOutDir) Contents: | Microsoft.Management.Configuration\Microsoft.Management.Configuration.dll + WindowsPackageManager\WindowsPackageManager.dll TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Configuration\SharedDependencies\$(BuildPlatform) flattenFolders: true