mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-08-23 08:02:07 +00:00
Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708)
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
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.')
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
SelectedFeatures = $selectedFeatures.ToArray()
|
||||
Errors = $errors.ToArray()
|
||||
}
|
||||
}
|
||||
|
||||
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('SelectedUndoFeatures must contain non-empty string feature IDs.')
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
SelectedUndoFeatures = $selectedUndoFeatures.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()]
|
||||
[string[]]$SelectedUndoFeatureIds,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
[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) -IsUndoFeature:$false -Errors $errors)
|
||||
$undoRegistryFeatures = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @($SelectedUndoFeatureIds) -IsUndoFeature:$true -Errors $errors)
|
||||
$useSysprepRegFiles = ($Target -eq 'DefaultUserProfile') -or ($Target -like 'User:*')
|
||||
|
||||
$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)
|
||||
}
|
||||
|
||||
$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 the selected features.')
|
||||
}
|
||||
|
||||
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)]
|
||||
[bool]$IsUndoFeature,
|
||||
[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 (-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)) {
|
||||
$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.
|
||||
#>
|
||||
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
|
||||
}
|
||||
|
||||
<#
|
||||
.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.
|
||||
#>
|
||||
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 (Test-RegistryValueDataMatchesKind -KindName $kindName -Data $valueSnapshot.Data)) {
|
||||
$Errors.Add("Backup contains invalid registry data for kind '$kindName' at '$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
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.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]) }
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
<#
|
||||
.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.
|
||||
#>
|
||||
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)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user