Guard against loading, saving & executing undefined features (#665)

This commit is contained in:
Jeffrey
2026-06-23 00:41:33 +02:00
committed by GitHub
parent d1fe541b62
commit 5ebc50d36a
11 changed files with 209 additions and 84 deletions

View File

@@ -368,7 +368,7 @@
"Label": "Create a system restore point",
"Category": null,
"RegistryKey": null,
"ApplyText": null,
"ApplyText": "Creating system restore point",
"UndoLabel": null,
"ApplyUndoText": null,
"RegistryUndoKey": null,

View File

@@ -45,14 +45,8 @@ function PrintPendingChanges {
continue
}
default {
if ($script:Features -and $script:Features.ContainsKey($parameterName)) {
$message = $script:Features[$parameterName].Label
Write-Output "- $message"
}
else {
# Fallback: show the parameter name if no feature description is available
Write-Output "- $parameterName"
}
continue
}
}

View File

@@ -1,17 +1,10 @@
function Get-FeatureId {
param(
[Parameter(Mandatory)]
$Feature
)
$featureId = [string]$Feature.FeatureId
if ([string]::IsNullOrWhiteSpace($featureId)) {
throw 'Selected feature is missing required FeatureId.'
}
return $featureId
}
<#
.SYNOPSIS
Filters a list of features to those that have a non-empty RegistryKey.
.PARAMETER Features
An array of feature objects to filter.
#>
function Get-RegistryBackedFeatures {
param(
[object[]]$Features = @()

View File

@@ -1,3 +1,18 @@
<#
.SYNOPSIS
Creates a timestamped JSON backup of registry state for selected features.
.DESCRIPTION
Resolves selected and undo features from the provided keys, captures their
registry state, and saves the result as a JSON file in the Backups/ folder.
Returns the file path on success, $null if no registry-backed features exist.
.PARAMETER ActionableKeys
Param keys from $script:Params to resolve into apply features.
.PARAMETER ExtraFeatures
Additional synthetic feature objects (e.g. undo features) to include.
#>
function New-RegistrySettingsBackup {
param(
[string[]]$ActionableKeys,
@@ -32,6 +47,13 @@ function New-RegistrySettingsBackup {
return $backupFilePath
}
<#
.SYNOPSIS
Resolves param keys into deduplicated feature objects from the catalog.
.PARAMETER ActionableKeys
Param keys to look up in $script:Features.
#>
function Get-SelectedFeatures {
param(
[string[]]$ActionableKeys
@@ -46,8 +68,7 @@ function Get-SelectedFeatures {
$feature = $script:Features[$paramKey]
if (-not $feature) { continue }
$featureId = Get-FeatureId -Feature $feature
$featureId = [string]$feature.FeatureId
if ($selectedFeatureIds.Add($featureId)) {
$selectedFeatures.Add($feature)
}
@@ -56,6 +77,23 @@ function Get-SelectedFeatures {
return @($selectedFeatures.ToArray())
}
<#
.SYNOPSIS
Builds the full backup payload object from selected and undo features.
.DESCRIPTION
Deduplicates feature IDs, resolves registry capture plans, snapshots all
registry keys, and assembles the final backup hashtable with metadata.
.PARAMETER SelectedFeatures
Feature objects from the apply side.
.PARAMETER UndoFeatures
Synthetic feature objects from the undo side.
.PARAMETER CreatedAt
Timestamp recorded in the backup metadata.
#>
function Get-RegistryBackupPayload {
param(
[object[]]$SelectedFeatures = @(),
@@ -67,8 +105,7 @@ function Get-RegistryBackupPayload {
$selectedFeatureIds = New-Object System.Collections.Generic.List[string]
$seenSelectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($feature in $SelectedFeatures) {
$featureId = Get-FeatureId -Feature $feature
$featureId = [string]$feature.FeatureId
if ($seenSelectedFeatureIds.Add($featureId)) {
$selectedFeatureIds.Add($featureId)
}
@@ -77,8 +114,7 @@ function Get-RegistryBackupPayload {
$selectedUndoFeatureIds = New-Object System.Collections.Generic.List[string]
$seenUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($feature in $UndoFeatures) {
$featureId = Get-FeatureId -Feature $feature
$featureId = [string]$feature.FeatureId
if ($seenUndoFeatureIds.Add($featureId)) {
$selectedUndoFeatureIds.Add($featureId)
}

View File

@@ -1,6 +1,10 @@
# Tests whether the registry operations in a feature's .reg file currently match the live registry.
# Returns $true if ALL operations in the apply reg file match current system state.
# Returns $false if the feature has no RegistryKey, the file is missing, or any operation mismatches.
<#
.SYNOPSIS
Maps a .reg file value type string to its RegistryValueKind enum.
.PARAMETER Operation
A parsed .reg operation object containing a ValueType property.
#>
function Get-ExpectedRegistryValueKind {
param(
[Parameter(Mandatory)]
@@ -18,13 +22,25 @@ function Get-ExpectedRegistryValueKind {
}
}
<#
.SYNOPSIS
Tests whether a feature's registry operations currently match the live registry.
.DESCRIPTION
Returns $true when ALL operations in the apply .reg file match current system
state. Returns $false if the feature has no RegistryKey, the reg file is
missing, or any operation mismatches. Special-cased features (Widgets, Store
suggestions, Windows Sandbox, WSL) bypass .reg checking entirely.
.PARAMETER FeatureId
The feature identifier to test.
#>
function Test-FeatureApplied {
param (
[Parameter(Mandatory)]
[string]$FeatureId
)
if (-not $script:Features.ContainsKey($FeatureId)) { return $false }
$feature = $script:Features[$FeatureId]
switch ($FeatureId) {
@@ -147,8 +163,18 @@ function Test-FeatureApplied {
return $true
}
# Returns the 1-based index of the UiGroup option whose features all match current system state,
# or 0 if no option fully matches (meaning the current state is unknown / "No Change").
<#
.SYNOPSIS
Returns the 1-based index of the UiGroup option whose features all match
current system state.
.DESCRIPTION
Returns 0 if no option fully matches, meaning the current state is unknown
or represents "No Change".
.PARAMETER Group
A UiGroup object whose Values array contains options with FeatureIds.
#>
function Get-CurrentGroupActiveIndex {
param (
[Parameter(Mandatory)]

View File

@@ -16,15 +16,11 @@ function Invoke-FeatureApply {
)
# Resolve feature metadata from Features.json
$feature = $null
if ($script:Features.ContainsKey($FeatureId)) {
$feature = $script:Features[$FeatureId]
}
$applyText = if ($feature -and $feature.ApplyText) { $feature.ApplyText } else { $FeatureId }
$applyText = $feature.ApplyText
# ---- Registry-backed features: import .reg file, then handle side effects ----
if ($feature -and $feature.RegistryKey) {
if ($feature.RegistryKey) {
ImportRegistryFile "> $applyText..." $feature.RegistryKey
# Post-import side effects for specific features
@@ -177,15 +173,13 @@ function Invoke-FeatureUndo {
return
}
'EnableWindowsSandbox' {
$undoText = if ($feature) { $feature.ApplyUndoText } else { 'Disabling Windows Sandbox' }
Write-Host "> $undoText..."
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Containers-DisposableClientVM'
Write-Host ""
return
}
'EnableWindowsSubsystemForLinux' {
$undoText = if ($feature) { $feature.ApplyUndoText } else { 'Disabling Windows Subsystem for Linux' }
Write-Host "> $undoText..."
Write-Host "> $($feature.ApplyUndoText)..."
DisableWindowsFeature 'Microsoft-Windows-Subsystem-Linux'
DisableWindowsFeature 'VirtualMachinePlatform'
Write-Host ""
@@ -245,15 +239,8 @@ function Invoke-ApplyFeatures {
if ($script:CancelRequested) { return }
# Resolve display name for the progress indicator
$displayName = $featureId
if ($script:Features.ContainsKey($featureId)) {
$f = $script:Features[$featureId]
if ($f.ApplyText) {
$displayName = $f.ApplyText
} elseif ($f.Label) {
$displayName = $f.Label
}
}
if ($script:ApplyProgressCallback) {
& $script:ApplyProgressCallback $step $TotalSteps $displayName
@@ -345,7 +332,6 @@ function Invoke-AllChanges {
# ---- Determine if registry backup is needed ----
$needsBackup = $false
foreach ($id in $applyIds) {
if (-not $script:Features.ContainsKey($id)) { continue }
$f = $script:Features[$id]
if ($f -and -not [string]::IsNullOrWhiteSpace([string]$f.RegistryKey)) {
$needsBackup = $true

View File

@@ -21,6 +21,9 @@ function LoadSettings {
$feature = $script:Features[$setting.Name]
# Skip unknown settings that aren't defined in Features.json
if (-not $feature) { continue }
# Check version and feature compatibility using Features.json
if (($feature.MinVersion -and $WinVersion -lt $feature.MinVersion) -or ($feature.MaxVersion -and $WinVersion -gt $feature.MaxVersion) -or ($feature.FeatureId -eq 'DisableModernStandbyNetworking' -and (-not $script:ModernStandbySupported))) {
continue

View File

@@ -11,7 +11,7 @@ function SaveSettings {
}
foreach ($param in $script:Params.Keys) {
if ($script:ControlParams -notcontains $param) {
if ($script:ControlParams -notcontains $param -and $script:Features.ContainsKey($param)) {
$value = $script:Params[$param]
$settings.Settings += @{

View File

@@ -1,29 +1,8 @@
# MainWindow-Deployment.ps1
# Overview generation, pending tweak actions, feature labels, tweak preset maps, apply logic, user mode state, user selection, and validation.
function Get-FeatureLabel {
param(
[string]$FeatureId,
$FallbackLabel = $null
)
$label = $script:FeatureLabelLookup[$FeatureId]
if (-not [string]::IsNullOrWhiteSpace([string]$label)) {
return [string]$label
}
if (-not [string]::IsNullOrWhiteSpace([string]$FallbackLabel)) {
return [string]$FallbackLabel
}
return [string]$FeatureId
}
function Get-UndoFeatureLabel {
param(
[string]$FeatureId,
$FallbackLabel = $null
)
param([string]$FeatureId)
$undoLabel = $script:UndoFeatureLabelLookup[$FeatureId]
if (-not [string]::IsNullOrWhiteSpace([string]$undoLabel)) {
@@ -31,8 +10,7 @@ function Get-UndoFeatureLabel {
}
# Fall back to the regular label (prefixed for undo context)
$label = Get-FeatureLabel -FeatureId $FeatureId -FallbackLabel $FallbackLabel
return [string]$label
return [string]$script:FeatureLabelLookup[$FeatureId]
}
function Get-PendingTweakActions {
@@ -69,14 +47,14 @@ function Get-PendingTweakActions {
$actions.Add([PSCustomObject]@{
Action = 'Apply'
FeatureId = [string]$mapping.FeatureId
Label = (Get-FeatureLabel -FeatureId $mapping.FeatureId -FallbackLabel $mapping.Label)
Label = [string]$script:FeatureLabelLookup[$mapping.FeatureId]
})
}
elseif ($wasApplied -and -not $isNowChecked) {
$actions.Add([PSCustomObject]@{
Action = 'Undo'
FeatureId = [string]$mapping.FeatureId
Label = (Get-FeatureLabel -FeatureId $mapping.FeatureId -FallbackLabel $mapping.Label)
Label = [string]$script:FeatureLabelLookup[$mapping.FeatureId]
})
}
}
@@ -101,7 +79,7 @@ function Get-PendingTweakActions {
$actions.Add([PSCustomObject]@{
Action = 'Apply'
FeatureId = [string]$fid
Label = (Get-FeatureLabel -FeatureId $fid)
Label = [string]$script:FeatureLabelLookup[$fid]
})
}
}

View File

@@ -1,3 +1,11 @@
<#
.SYNOPSIS
Creates a lightweight state object for the restore-backup dialog result.
.DESCRIPTION
Encapsulates the user's dialog choice (Result), the selected backup file
path, and the parsed backup payload so callers receive a single object.
#>
function New-RestoreDialogState {
param(
[string]$Result = 'Cancel',
@@ -8,6 +16,16 @@ function New-RestoreDialogState {
return @{ Result = $Result; SelectedFile = $SelectedFile; Backup = $Backup }
}
<#
.SYNOPSIS
Looks up a feature definition by ID from the provided feature catalog.
.PARAMETER FeatureId
The identifier to search for (e.g. 'DisableTelemetry').
.PARAMETER Features
A hashtable loaded from Features.json (FeatureId -> feature object).
#>
function Get-RestoreDialogFeatureDefinition {
param(
[string]$FeatureId,
@@ -25,6 +43,21 @@ function Get-RestoreDialogFeatureDefinition {
return $null
}
<#
.SYNOPSIS
Determines whether a feature can be automatically reverted via registry restore.
.DESCRIPTION
Returns $true when the feature has a non-empty RegistryKey, indicating
an apply .reg file exists that can be undone automatically. Features
with custom logic (no RegistryKey) must be manually reverted.
.PARAMETER FeatureId
The feature identifier to check.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Test-RestoreDialogFeatureCanAutoRevert {
param(
[string]$FeatureId,
@@ -43,6 +76,20 @@ function Test-RestoreDialogFeatureCanAutoRevert {
return $false
}
<#
.SYNOPSIS
Resolves a human-readable label for a feature shown in the restore dialog.
.DESCRIPTION
Returns the feature's Label from Features.json when found, falling back
to the raw FeatureId string. For null/empty FeatureIds returns 'Unknown feature'.
.PARAMETER FeatureId
The feature identifier to resolve a label for.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Get-RestoreDialogFeatureDisplayLabel {
param(
[string]$FeatureId,
@@ -54,13 +101,28 @@ function Get-RestoreDialogFeatureDisplayLabel {
}
$featureDefinition = Get-RestoreDialogFeatureDefinition -FeatureId $FeatureId -Features $Features
if ($featureDefinition -and -not [string]::IsNullOrWhiteSpace([string]$featureDefinition.Label)) {
if ($featureDefinition) {
return [string]$featureDefinition.Label
}
return $FeatureId
}
<#
.SYNOPSIS
Checks whether a feature should appear in the restore dialog's overview list.
.DESCRIPTION
A feature is considered visible when it exists in the catalog and has
a non-empty Category (meaning it belongs to a UI grouping). Features
without a Category are hidden from the overview.
.PARAMETER FeatureId
The feature identifier to check.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Test-RestoreDialogFeatureVisibleInOverview {
param(
[string]$FeatureId,
@@ -79,6 +141,13 @@ function Test-RestoreDialogFeatureVisibleInOverview {
return -not [string]::IsNullOrWhiteSpace([string]$featureDefinition.Category)
}
<#
.SYNOPSIS
Extracts deduplicated forward (apply) feature IDs from a backup payload.
.PARAMETER SelectedBackup
The parsed backup object containing a SelectedFeatures property.
#>
function Get-SelectedForwardFeatureIdsFromBackup {
param($SelectedBackup)
@@ -99,6 +168,13 @@ function Get-SelectedForwardFeatureIdsFromBackup {
return @($selectedFeatureIds.ToArray())
}
<#
.SYNOPSIS
Extracts deduplicated undo feature IDs from a backup payload.
.PARAMETER SelectedBackup
The parsed backup object containing a SelectedUndoFeatures property.
#>
function Get-SelectedUndoFeatureIdsFromBackup {
param($SelectedBackup)
@@ -119,6 +195,13 @@ function Get-SelectedUndoFeatureIdsFromBackup {
return @($selectedUndoFeatureIds.ToArray())
}
<#
.SYNOPSIS
Merges forward and undo feature IDs from a backup into a single deduplicated list.
.PARAMETER SelectedBackup
The parsed backup object containing SelectedFeatures and SelectedUndoFeatures.
#>
function Get-CombinedSelectedFeatureIdsFromBackup {
param($SelectedBackup)
@@ -139,12 +222,34 @@ function Get-CombinedSelectedFeatureIdsFromBackup {
return @($featureIds.ToArray())
}
<#
.SYNOPSIS
Convenience wrapper that returns all combined feature IDs from a backup.
.PARAMETER SelectedBackup
The parsed backup object.
#>
function Get-SelectedFeatureIdsFromBackup {
param($SelectedBackup)
return @(Get-CombinedSelectedFeatureIdsFromBackup -SelectedBackup $SelectedBackup)
}
<#
.SYNOPSIS
Splits selected feature IDs into revertible and non-revertible lists for display.
.DESCRIPTION
Iterates the provided feature IDs, filters to those visible in the overview,
and separates them into auto-revertible (has a RegistryKey) and non-revertible
(requires manual undo) buckets. Each entry includes a display label.
.PARAMETER SelectedFeatureIds
The list of feature IDs to categorize.
.PARAMETER Features
A hashtable loaded from Features.json.
#>
function Get-RestoreBackupFeatureLists {
param(
[string[]]$SelectedFeatureIds,

View File

@@ -242,6 +242,10 @@ $script:Features = @{}
try {
$featuresData = Get-Content -Path $script:FeaturesFilePath -Raw | ConvertFrom-Json
foreach ($feature in $featuresData.Features) {
if ([string]::IsNullOrWhiteSpace([string]$feature.FeatureId) -or [string]::IsNullOrWhiteSpace([string]$feature.Label) -or [string]::IsNullOrWhiteSpace([string]$feature.ApplyText)) {
Write-Warning "Feature '$($feature.FeatureId)' is missing a FeatureId, Label, or ApplyText in Features.json and will be skipped."
continue
}
$script:Features[$feature.FeatureId] = $feature
}
}