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 } }