Files
Win11Debloat/Scripts/Features/Registry-BackupValidation.ps1
T

581 lines
19 KiB
PowerShell
Raw Normal View History

<#
.SYNOPSIS
Extracts and deduplicates the SelectedFeatures list from a backup, validating each entry.
.DESCRIPTION
Returns an empty SelectedFeatures array with an error when the property is missing
entirely, and flags (without stopping) any entry that isn't a non-empty string.
#>
2026-05-08 21:19:52 +02:00
function Get-NormalizedSelectedFeatureIdsFromBackup {
param(
[Parameter(Mandatory)]
$Backup
)
$selectedFeatures = New-Object System.Collections.Generic.List[string]
$selectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$errors = New-Object System.Collections.Generic.List[string]
$hasInvalidSelectedFeatureId = $false
if (-not $Backup.PSObject.Properties['SelectedFeatures']) {
$errors.Add((Get-Translation -Key 'BackupMissingProperty' -FormatArgs @('SelectedFeatures')))
2026-05-08 21:19:52 +02:00
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
Errors = $errors.ToArray()
}
}
foreach ($featureId in @($Backup.SelectedFeatures)) {
if ($featureId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$featureId)) {
$hasInvalidSelectedFeatureId = $true
continue
}
$normalizedFeatureId = [string]$featureId
if ($selectedFeatureIds.Add($normalizedFeatureId)) {
$selectedFeatures.Add($normalizedFeatureId)
}
}
if ($hasInvalidSelectedFeatureId) {
$errors.Add((Get-Translation -Key 'BackupSelectedFeaturesMustBeStrings'))
2026-05-08 21:19:52 +02:00
}
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
Errors = $errors.ToArray()
}
}
<#
.SYNOPSIS
Extracts and deduplicates the SelectedUndoFeatures list from a backup, validating each entry.
.DESCRIPTION
Unlike SelectedFeatures, this property is optional: a missing property returns an
empty array with no error, since not every backup has undone features to restore.
#>
function Get-NormalizedSelectedUndoFeatureIdsFromBackup {
param(
[Parameter(Mandatory)]
$Backup
)
$selectedUndoFeatures = New-Object System.Collections.Generic.List[string]
$selectedUndoFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$errors = New-Object System.Collections.Generic.List[string]
# SelectedUndoFeatures is optional - only process if present
if (-not $Backup.PSObject.Properties['SelectedUndoFeatures']) {
return [PSCustomObject]@{
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
Errors = $errors.ToArray()
}
}
$hasInvalidSelectedUndoFeatureId = $false
foreach ($featureId in @($Backup.SelectedUndoFeatures)) {
if ($featureId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$featureId)) {
$hasInvalidSelectedUndoFeatureId = $true
continue
}
$normalizedFeatureId = [string]$featureId
if ($selectedUndoFeatureIds.Add($normalizedFeatureId)) {
$selectedUndoFeatures.Add($normalizedFeatureId)
}
}
if ($hasInvalidSelectedUndoFeatureId) {
$errors.Add((Get-Translation -Key 'BackupSelectedUndoFeaturesMustBeStrings'))
2026-05-08 21:19:52 +02:00
}
return [PSCustomObject]@{
SelectedUndoFeatures = $selectedUndoFeatures.ToArray()
2026-05-08 21:19:52 +02:00
Errors = $errors.ToArray()
}
}
<#
.SYNOPSIS
Normalizes a raw registry key snapshot into a consistent shape, recursing into sub-keys.
.DESCRIPTION
Fills in defaults for optional properties (Exists, Values, SubKeys) so downstream
validation can read a predictable shape regardless of what the backup file omitted.
Throws if Path is missing, since every snapshot must be anchored to a real key.
#>
2026-05-08 21:19:52 +02:00
function Normalize-RegistryKeySnapshot {
param(
[Parameter(Mandatory)]
$Snapshot
)
if (-not $Snapshot.PSObject.Properties['Path'] -or [string]::IsNullOrWhiteSpace([string]$Snapshot.Path)) {
throw (Get-Translation -Key 'BackupSnapshotMissingPath')
2026-05-08 21:19:52 +02:00
}
$exists = $false
if ($Snapshot.PSObject.Properties['Exists']) {
$exists = [bool]$Snapshot.Exists
}
$values = @()
if ($Snapshot.PSObject.Properties['Values']) {
foreach ($valueSnapshot in @($Snapshot.Values)) {
$valueExists = $true
if ($valueSnapshot.PSObject.Properties['Exists']) {
$valueExists = [bool]$valueSnapshot.Exists
}
$values += [PSCustomObject]@{
Name = [string]$valueSnapshot.Name
Exists = $valueExists
Kind = if ($valueSnapshot.PSObject.Properties['Kind']) { [string]$valueSnapshot.Kind } else { $null }
Data = if ($valueSnapshot.PSObject.Properties['Data']) { $valueSnapshot.Data } else { $null }
}
}
}
$subKeys = @()
if ($Snapshot.PSObject.Properties['SubKeys']) {
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
$subKeys += @(Normalize-RegistryKeySnapshot -Snapshot $subKeySnapshot)
}
}
return [PSCustomObject]@{
Path = [string]$Snapshot.Path
Exists = $exists
Values = @($values)
SubKeys = @($subKeys)
}
}
<#
.SYNOPSIS
Validates that a backup's registry snapshots only contain paths and values the
selected (and undo) features are actually allowed to touch.
.DESCRIPTION
Builds an allow-list from the capture plans of the selected features, then checks
every snapshot against it. Returns an array of translated error strings; an empty
array means the backup is valid.
#>
2026-05-08 21:19:52 +02:00
function Test-RegistryBackupMatchesSelectedFeatures {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedUndoFeatureIds,
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
2026-05-08 21:19:52 +02:00
[AllowEmptyCollection()]
[object[]]$RegistryKeys
)
$errors = New-Object System.Collections.Generic.List[string]
if (-not $script:Features -or $script:Features.Count -eq 0) {
$errors.Add((Get-Translation -Key 'BackupFeatureCatalogNotLoaded'))
2026-05-08 21:19:52 +02:00
return $errors.ToArray()
}
$selectedRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedFeatureIds) -IsUndoFeature:$false -Errors $errors)
$undoRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedUndoFeatureIds) -IsUndoFeature:$true -Errors $errors)
$useSysprepRegFiles = ($Target -eq 'DefaultUserProfile') -or ($Target -like 'User:*')
2026-05-08 21:19:52 +02:00
$capturePlans = @()
if ($errors.Count -eq 0 -and ($selectedRegistryFeatures.Count -gt 0 -or $undoRegistryFeatures.Count -gt 0)) {
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @($selectedRegistryFeatures) -UndoRegistryFeatures @($undoRegistryFeatures) -UseSysprepRegFiles:$useSysprepRegFiles)
2026-05-08 21:19:52 +02:00
}
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @($capturePlans)
if ($planMap.Count -eq 0 -and @($RegistryKeys).Count -gt 0) {
$errors.Add((Get-Translation -Key 'BackupNoAllowedRegistryPaths'))
2026-05-08 21:19:52 +02:00
}
foreach ($rootSnapshot in @($RegistryKeys)) {
Test-RegistrySnapshotAgainstAllowList -Snapshot $rootSnapshot -PlanMap $planMap -Errors $errors
}
return $errors.ToArray()
}
<#
.SYNOPSIS
Resolves selected feature IDs to their catalog entries, keeping only the ones with a
registry-backed apply or undo key relevant to the requested direction.
.DESCRIPTION
Appends a translated error to the caller-supplied Errors list for any feature ID that
isn't in the current catalog, rather than failing the whole validation outright.
#>
2026-05-08 21:19:52 +02:00
function Get-SelectedRegistryFeaturesForBackupValidation {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[bool]$IsUndoFeature,
[Parameter(Mandatory)]
2026-05-08 21:19:52 +02:00
[AllowEmptyCollection()]
$Errors
)
if ($null -eq $Errors -or -not ($Errors -is [System.Collections.IList])) {
throw 'Get-SelectedRegistryFeaturesForBackupValidation requires Errors to be a mutable list collection.'
}
# Intentionally not localized: this throw signals a programming error (a caller passing the
# wrong collection type), not a condition a user's backup file can trigger.
2026-05-08 21:19:52 +02:00
$selectedRegistryFeatures = New-Object System.Collections.Generic.List[object]
foreach ($featureId in @($SelectedFeatureIds)) {
if (-not $script:Features.ContainsKey($featureId)) {
$Errors.Add((Get-Translation -Key 'BackupFeatureNotInCatalog' -FormatArgs @($featureId)))
2026-05-08 21:19:52 +02:00
continue
}
$feature = $script:Features[$featureId]
if (-not $feature) {
continue
}
# For undo features, check RegistryUndoKey if present (real features)
# Otherwise check RegistryKey (for synthetic features from backup capture)
$registryKeyToUse = if ($IsUndoFeature) {
$key = [string]$feature.RegistryUndoKey
if (-not [string]::IsNullOrWhiteSpace($key)) {
$key
}
else {
[string]$feature.RegistryKey
}
}
else {
[string]$feature.RegistryKey
}
if (-not [string]::IsNullOrWhiteSpace($registryKeyToUse)) {
2026-05-08 21:19:52 +02:00
$selectedRegistryFeatures.Add($feature)
}
}
return $selectedRegistryFeatures.ToArray()
}
function New-RegistryBackupAllowListPlanMap {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]]$CapturePlans
)
$planMap = @{}
foreach ($plan in @($CapturePlans)) {
$normalizedPath = Get-NormalizedRegistryPathKey -Path $plan.Path
if ([string]::IsNullOrWhiteSpace($normalizedPath)) {
continue
}
$planMap[$normalizedPath] = [PSCustomObject]@{
Path = $plan.Path
NormalizedPath = $normalizedPath
IncludeSubKeys = [bool]$plan.IncludeSubKeys
CaptureAllValues = [bool]$plan.CaptureAllValues
ValueNames = ConvertTo-RegistryValueNameSet -ValueNames @($plan.ValueNames)
}
}
return $planMap
}
<#
.SYNOPSIS
Converts registry value names into a case-insensitive set.
.DESCRIPTION
Preserves empty names and prevents PowerShell from enumerating the returned
HashSet.
#>
2026-05-08 21:19:52 +02:00
function ConvertTo-RegistryValueNameSet {
param(
[AllowEmptyCollection()]
[string[]]$ValueNames
)
$valueNameSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($valueName in @($ValueNames)) {
$null = $valueNameSet.Add([string]$valueName)
}
# Prevent PowerShell from enumerating the HashSet into an array or single string
return ,$valueNameSet
2026-05-08 21:19:52 +02:00
}
<#
.SYNOPSIS
Validates a registry snapshot against the selected-feature allow list.
.DESCRIPTION
Recursively validates snapshot paths, value names, value kinds, and value
data, appending validation errors to the supplied list.
#>
2026-05-08 21:19:52 +02:00
function Test-RegistrySnapshotAgainstAllowList {
param(
[Parameter(Mandatory)]
$Snapshot,
[Parameter(Mandatory)]
[hashtable]$PlanMap,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.Generic.List[string]]$Errors
)
$snapshotPath = [string]$Snapshot.Path
$normalizedPath = Get-NormalizedRegistryPathKey -Path $snapshotPath
if ([string]::IsNullOrWhiteSpace($normalizedPath)) {
$Errors.Add((Get-Translation -Key 'BackupUnsupportedRegistryPath' -FormatArgs @($snapshotPath)))
2026-05-08 21:19:52 +02:00
return
}
$planMatch = Find-RegistryAllowListPlanMatch -NormalizedPath $normalizedPath -PlanMap $PlanMap
if ($null -eq $planMatch) {
$Errors.Add((Get-Translation -Key 'BackupUnexpectedRegistryPath' -FormatArgs @($snapshotPath)))
2026-05-08 21:19:52 +02:00
return
}
foreach ($valueSnapshot in @($Snapshot.Values)) {
$valueName = Get-NormalizedRegistryValueName -ValueName $valueSnapshot.Name
$valueExists = [bool]$valueSnapshot.Exists
if (-not (Test-RegistryValueAllowedByPlan -PlanMatch $planMatch -ValueName $valueName)) {
$Errors.Add((Get-Translation -Key 'BackupUnexpectedValue' -FormatArgs @($valueName, $snapshotPath)))
2026-05-08 21:19:52 +02:00
}
$kindName = if ($valueSnapshot.PSObject.Properties['Kind']) { [string]$valueSnapshot.Kind } else { '' }
$valueReference = Get-RegistryValueReferenceForError -SnapshotPath $snapshotPath -ValueName $valueName
if ($valueExists) {
if (-not (Test-RegistryValueKindNameSupported -KindName $kindName)) {
$Errors.Add((Get-Translation -Key 'BackupUnsupportedValueKind' -FormatArgs @($kindName, $valueReference)))
2026-05-08 21:19:52 +02:00
}
elseif (-not (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) {
$Errors.Add((Get-Translation -Key 'BackupInvalidValueData' -FormatArgs @($kindName, $valueReference)))
}
2026-05-08 21:19:52 +02:00
}
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
$Errors.Add((Get-Translation -Key 'BackupValueMustNotDefineKind' -FormatArgs @($valueReference)))
2026-05-08 21:19:52 +02:00
}
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
Test-RegistrySnapshotAgainstAllowList -Snapshot $subKeySnapshot -PlanMap $PlanMap -Errors $Errors
}
}
<#
.SYNOPSIS
Tests whether backed-up registry data is valid for its declared value kind.
.DESCRIPTION
Rejects corrupted or hand-edited backup data that cannot be restored safely,
such as a DWord that overflows UInt32 or binary data containing an invalid byte.
This validation runs before Restore-RegistryKeySnapshot mutates the live
registry, preventing a failed conversion from leaving a partially restored key.
.PARAMETER KindName
The declared registry value kind name, such as DWord, QWord, or Binary.
.PARAMETER Data
The backed-up value data to validate against the declared kind.
.OUTPUTS
System.Boolean
#>
function Test-RegistryValueDataMatchesKind {
param(
[Parameter(Mandatory)]
[string]$KindName,
[AllowNull()]
$Data
)
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
switch ($kind) {
([Microsoft.Win32.RegistryValueKind]::DWord) {
$parsed = [uint32]0
return [uint32]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
}
([Microsoft.Win32.RegistryValueKind]::QWord) {
$parsed = [uint64]0
return [uint64]::TryParse([string]$Data, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)
}
([Microsoft.Win32.RegistryValueKind]::Binary) {
if ($null -eq $Data -or $Data -isnot [array]) { return $false }
foreach ($item in @($Data)) {
if ($item -isnot [ValueType] -and $item -isnot [string]) { return $false }
$parsed = 0
if (-not [int]::TryParse([string]$item, [ref]$parsed) -or $parsed -lt 0 -or $parsed -gt 255) {
return $false
}
}
return $true
}
([Microsoft.Win32.RegistryValueKind]::MultiString) {
foreach ($item in @($Data)) {
if ($item -isnot [string]) { return $false }
}
return $true
}
default { return ($null -eq $Data -or $Data -is [string]) }
}
}
2026-05-08 21:19:52 +02:00
function Test-RegistryValueAllowedByPlan {
param(
[Parameter(Mandatory)]
$PlanMatch,
[Parameter(Mandatory)]
[AllowNull()]
[AllowEmptyString()]
[string]$ValueName
)
$ValueName = Get-NormalizedRegistryValueName -ValueName $ValueName
if ($PlanMatch.CaptureAllValues -or $PlanMatch.IsDescendant) {
return $true
}
return $PlanMatch.ValueNames.Contains($ValueName)
}
function Get-RegistryValueReferenceForError {
param(
[Parameter(Mandatory)]
[string]$SnapshotPath,
[Parameter(Mandatory)]
[AllowNull()]
[AllowEmptyString()]
[string]$ValueName
)
$ValueName = Get-NormalizedRegistryValueName -ValueName $ValueName
if ([string]::IsNullOrWhiteSpace($ValueName)) {
return "$SnapshotPath\\(Default)"
}
return "$SnapshotPath\\$ValueName"
}
function Get-NormalizedRegistryValueName {
param(
[AllowNull()]
[AllowEmptyString()]
[object]$ValueName
)
if ($null -eq $ValueName) {
return ''
}
return [string]$ValueName
}
function Find-RegistryAllowListPlanMatch {
param(
[Parameter(Mandatory)]
[string]$NormalizedPath,
[Parameter(Mandatory)]
[hashtable]$PlanMap
)
if ($PlanMap.ContainsKey($NormalizedPath)) {
$plan = $PlanMap[$NormalizedPath]
return [PSCustomObject]@{
IsDescendant = $false
CaptureAllValues = [bool]$plan.CaptureAllValues
ValueNames = $plan.ValueNames
}
}
foreach ($plan in @($PlanMap.Values)) {
if (-not [bool]$plan.IncludeSubKeys) {
continue
}
$subKeyPrefix = "$($plan.NormalizedPath)\"
2026-05-08 21:19:52 +02:00
if ($NormalizedPath.StartsWith($subKeyPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
return [PSCustomObject]@{
IsDescendant = $true
CaptureAllValues = $true
ValueNames = $plan.ValueNames
}
}
}
return $null
}
function Get-NormalizedRegistryPathKey {
param(
[Parameter(Mandatory)]
[string]$Path
)
$parts = Split-RegistryPath -path $Path
if (-not $parts) {
return $null
}
$hiveName = [string]$parts.Hive
if ([string]::IsNullOrWhiteSpace($hiveName)) {
return $null
}
$normalizedHive = $hiveName.ToUpperInvariant()
$subKey = [string]$parts.SubKey
if ([string]::IsNullOrWhiteSpace($subKey)) {
return $normalizedHive
}
$normalizedSubKey = ($subKey -replace '/', '\\').Trim('\')
if ([string]::IsNullOrWhiteSpace($normalizedSubKey)) {
return $normalizedHive
}
return "$normalizedHive\\$normalizedSubKey"
}
<#
.SYNOPSIS
Tests whether a registry value-kind name is supported in backups.
.DESCRIPTION
Parses kind names case-insensitively and rejects empty, invalid, Unknown,
and None values.
#>
2026-05-08 21:19:52 +02:00
function Test-RegistryValueKindNameSupported {
param(
[string]$KindName
)
if ([string]::IsNullOrWhiteSpace($KindName)) {
return $false
}
try {
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
return $kind -notin @([Microsoft.Win32.RegistryValueKind]::Unknown, [Microsoft.Win32.RegistryValueKind]::None)
2026-05-08 21:19:52 +02:00
}
catch {
return $false
}
}