Files
Win11Debloat/Scripts/Features/RegistryBackupValidation.ps1

383 lines
12 KiB
PowerShell
Raw Normal View History

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('Missing property: SelectedFeatures')
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('SelectedFeatures must contain non-empty string feature IDs.')
}
if ($selectedFeatures.Count -eq 0) {
$errors.Add('SelectedFeatures must contain at least one feature ID.')
}
return [PSCustomObject]@{
SelectedFeatures = $selectedFeatures.ToArray()
Errors = $errors.ToArray()
}
}
function Normalize-RegistryKeySnapshot {
param(
[Parameter(Mandatory)]
$Snapshot
)
if (-not $Snapshot.PSObject.Properties['Path'] -or [string]::IsNullOrWhiteSpace([string]$Snapshot.Path)) {
throw 'Backup validation failed: Registry key snapshot is missing Path.'
}
$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)
}
}
function Test-RegistryBackupMatchesSelectedFeatures {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]]$RegistryKeys
)
$errors = New-Object System.Collections.Generic.List[string]
if (-not $script:Features -or $script:Features.Count -eq 0) {
$errors.Add('Unable to validate registry backup allowlist because feature definitions are not loaded.')
return $errors.ToArray()
}
$selectedRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedFeatureIds) -Errors $errors)
$capturePlans = @()
if ($errors.Count -eq 0 -and $selectedRegistryFeatures.Count -gt 0) {
$capturePlans = @(Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @($selectedRegistryFeatures))
}
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @($capturePlans)
if ($planMap.Count -eq 0 -and @($RegistryKeys).Count -gt 0) {
$errors.Add('Backup contains registry snapshots but no allowed registry paths were derived from SelectedFeatures.')
}
foreach ($rootSnapshot in @($RegistryKeys)) {
Test-RegistrySnapshotAgainstAllowList -Snapshot $rootSnapshot -PlanMap $planMap -Errors $errors
}
return $errors.ToArray()
}
function Get-SelectedRegistryFeaturesForBackupValidation {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]]$SelectedFeatureIds,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
$Errors
)
if ($null -eq $Errors -or -not ($Errors -is [System.Collections.IList])) {
throw 'Get-SelectedRegistryFeaturesForBackupValidation requires Errors to be a mutable list collection.'
}
$selectedRegistryFeatures = New-Object System.Collections.Generic.List[object]
foreach ($featureId in @($SelectedFeatureIds)) {
if (-not $script:Features.ContainsKey($featureId)) {
$Errors.Add("Selected feature '$featureId' was not found in the current feature catalog.")
continue
}
$feature = $script:Features[$featureId]
if ($feature -and -not [string]::IsNullOrWhiteSpace([string]$feature.RegistryKey)) {
$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
}
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)
}
return $valueNameSet
}
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("Backup contains unsupported registry path '$snapshotPath'.")
return
}
$planMatch = Find-RegistryAllowListPlanMatch -NormalizedPath $normalizedPath -PlanMap $PlanMap
if ($null -eq $planMatch) {
$Errors.Add("Backup contains unexpected registry path '$snapshotPath' that is not allowed by SelectedFeatures.")
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("Backup contains unexpected value '$valueName' under '$snapshotPath'.")
}
$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("Backup contains unsupported registry value kind '$kindName' for '$valueReference'.")
}
}
elseif (-not [string]::IsNullOrWhiteSpace($kindName)) {
$Errors.Add("Backup value '$valueReference' must not define Kind when Exists is false.")
}
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
Test-RegistrySnapshotAgainstAllowList -Snapshot $subKeySnapshot -PlanMap $PlanMap -Errors $Errors
}
}
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)\\"
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"
}
function Test-RegistryValueKindNameSupported {
param(
[string]$KindName
)
if ([string]::IsNullOrWhiteSpace($KindName)) {
return $false
}
try {
$kind = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
return $kind -ne [Microsoft.Win32.RegistryValueKind]::Unknown
}
catch {
return $false
}
}