From 5ebc50d36aa67465dcc47b0dfe52cb7fcba71d8d Mon Sep 17 00:00:00 2001 From: Jeffrey <9938813+Raphire@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:41:33 +0200 Subject: [PATCH] Guard against loading, saving & executing undefined features (#665) --- Config/Features.json | 2 +- Scripts/CLI/PrintPendingChanges.ps1 | 10 +- .../BackupRegistryFeatureSelection.ps1 | 19 +--- Scripts/Features/BackupRegistryState.ps1 | 48 +++++++- Scripts/Features/GetCurrentTweakState.ps1 | 38 ++++++- Scripts/Features/InvokeChanges.ps1 | 28 ++--- Scripts/FileIO/LoadSettings.ps1 | 3 + Scripts/FileIO/SaveSettings.ps1 | 2 +- Scripts/GUI/MainWindow-Deployment.ps1 | 32 +----- .../GUI/RestoreBackupDialogFeatureLists.ps1 | 107 +++++++++++++++++- Win11Debloat.ps1 | 4 + 11 files changed, 209 insertions(+), 84 deletions(-) diff --git a/Config/Features.json b/Config/Features.json index b4311ba..ab2ee55 100644 --- a/Config/Features.json +++ b/Config/Features.json @@ -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, diff --git a/Scripts/CLI/PrintPendingChanges.ps1 b/Scripts/CLI/PrintPendingChanges.ps1 index 00fff4e..5a135b9 100644 --- a/Scripts/CLI/PrintPendingChanges.ps1 +++ b/Scripts/CLI/PrintPendingChanges.ps1 @@ -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" - } + $message = $script:Features[$parameterName].Label + Write-Output "- $message" continue } } diff --git a/Scripts/Features/BackupRegistryFeatureSelection.ps1 b/Scripts/Features/BackupRegistryFeatureSelection.ps1 index 9bf8e73..b900c3f 100644 --- a/Scripts/Features/BackupRegistryFeatureSelection.ps1 +++ b/Scripts/Features/BackupRegistryFeatureSelection.ps1 @@ -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 = @() diff --git a/Scripts/Features/BackupRegistryState.ps1 b/Scripts/Features/BackupRegistryState.ps1 index 7ed9942..0ce7e9e 100644 --- a/Scripts/Features/BackupRegistryState.ps1 +++ b/Scripts/Features/BackupRegistryState.ps1 @@ -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) } diff --git a/Scripts/Features/GetCurrentTweakState.ps1 b/Scripts/Features/GetCurrentTweakState.ps1 index 4aaa483..da7f232 100644 --- a/Scripts/Features/GetCurrentTweakState.ps1 +++ b/Scripts/Features/GetCurrentTweakState.ps1 @@ -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)] diff --git a/Scripts/Features/InvokeChanges.ps1 b/Scripts/Features/InvokeChanges.ps1 index 85d8d51..a8a1e92 100644 --- a/Scripts/Features/InvokeChanges.ps1 +++ b/Scripts/Features/InvokeChanges.ps1 @@ -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 } + $feature = $script:Features[$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 - } - } + $f = $script:Features[$featureId] + $displayName = $f.ApplyText 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 diff --git a/Scripts/FileIO/LoadSettings.ps1 b/Scripts/FileIO/LoadSettings.ps1 index 9c18ac4..2df00f9 100644 --- a/Scripts/FileIO/LoadSettings.ps1 +++ b/Scripts/FileIO/LoadSettings.ps1 @@ -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 diff --git a/Scripts/FileIO/SaveSettings.ps1 b/Scripts/FileIO/SaveSettings.ps1 index 2548d6e..c8adca6 100644 --- a/Scripts/FileIO/SaveSettings.ps1 +++ b/Scripts/FileIO/SaveSettings.ps1 @@ -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 += @{ diff --git a/Scripts/GUI/MainWindow-Deployment.ps1 b/Scripts/GUI/MainWindow-Deployment.ps1 index 3c26c29..fa4abef 100644 --- a/Scripts/GUI/MainWindow-Deployment.ps1 +++ b/Scripts/GUI/MainWindow-Deployment.ps1 @@ -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] }) } } diff --git a/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1 b/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1 index 7bbda18..74605b7 100644 --- a/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1 +++ b/Scripts/GUI/RestoreBackupDialogFeatureLists.ps1 @@ -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, diff --git a/Win11Debloat.ps1 b/Win11Debloat.ps1 index ef787fd..b6952f5 100644 --- a/Win11Debloat.ps1 +++ b/Win11Debloat.ps1 @@ -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 } }