mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-05-20 04:36:17 +00:00
Add registry backup & restore (#566)
Starting from this commit, Win11Debloat will automatically create a registry backup every time the script is run. This registry backup can be used to revert any registry changes made by the script.
This commit is contained in:
22
Scripts/Features/BackupRegistryFeatureSelection.ps1
Normal file
22
Scripts/Features/BackupRegistryFeatureSelection.ps1
Normal file
@@ -0,0 +1,22 @@
|
||||
function Get-FeatureId {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Feature
|
||||
)
|
||||
|
||||
$featureId = [string]$Feature.FeatureId
|
||||
if ([string]::IsNullOrWhiteSpace($featureId)) {
|
||||
throw 'Selected feature is missing required FeatureId.'
|
||||
}
|
||||
|
||||
return $featureId
|
||||
}
|
||||
|
||||
function Get-RegistryBackedFeatures {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$Features
|
||||
)
|
||||
|
||||
return @($Features | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) })
|
||||
}
|
||||
256
Scripts/Features/BackupRegistrySnapshotCapture.ps1
Normal file
256
Scripts/Features/BackupRegistrySnapshotCapture.ps1
Normal file
@@ -0,0 +1,256 @@
|
||||
function Get-RegistryBackupCapturePlans {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$SelectedRegistryFeatures
|
||||
)
|
||||
|
||||
$planMap = @{}
|
||||
foreach ($feature in $SelectedRegistryFeatures) {
|
||||
$regFilePath = Get-RegistryFilePathForFeature -Feature $feature
|
||||
if (-not (Test-Path $regFilePath)) {
|
||||
throw "Unable to find registry file for backup: $($feature.RegistryKey) ($regFilePath)"
|
||||
}
|
||||
|
||||
foreach ($operation in @(Get-RegFileOperations -regFilePath $regFilePath)) {
|
||||
if (-not $operation.KeyPath) { continue }
|
||||
|
||||
$mapKey = $operation.KeyPath.ToLowerInvariant()
|
||||
if (-not $planMap.ContainsKey($mapKey)) {
|
||||
$planMap[$mapKey] = [PSCustomObject]@{
|
||||
Path = $operation.KeyPath
|
||||
IncludeSubKeys = $false
|
||||
CaptureAllValues = $false
|
||||
ValueNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
}
|
||||
}
|
||||
|
||||
$plan = $planMap[$mapKey]
|
||||
switch ($operation.OperationType) {
|
||||
'DeleteKey' {
|
||||
$plan.IncludeSubKeys = $true
|
||||
$plan.CaptureAllValues = $true
|
||||
}
|
||||
'SetValue' {
|
||||
if (-not $plan.CaptureAllValues) {
|
||||
$null = $plan.ValueNames.Add([string]$operation.ValueName)
|
||||
}
|
||||
}
|
||||
'DeleteValue' {
|
||||
if (-not $plan.CaptureAllValues) {
|
||||
$null = $plan.ValueNames.Add([string]$operation.ValueName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return @(
|
||||
foreach ($entry in $planMap.Values) {
|
||||
[PSCustomObject]@{
|
||||
Path = $entry.Path
|
||||
IncludeSubKeys = [bool]$entry.IncludeSubKeys
|
||||
CaptureAllValues = [bool]$entry.CaptureAllValues
|
||||
ValueNames = @($entry.ValueNames)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function Get-RegistrySnapshotsForBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$CapturePlans
|
||||
)
|
||||
|
||||
if ($CapturePlans.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
|
||||
$snapshotScript = {
|
||||
param($plans)
|
||||
|
||||
$snapshots = @()
|
||||
foreach ($plan in $plans) {
|
||||
$snapshots += Get-RegistryKeySnapshot -KeyPath $plan.Path -CaptureAllValues:$plan.CaptureAllValues -ValueNames @($plan.ValueNames) -IncludeSubKeys:$plan.IncludeSubKeys
|
||||
}
|
||||
|
||||
return @($snapshots)
|
||||
}
|
||||
|
||||
if ($script:Params.ContainsKey('Sysprep') -or $script:Params.ContainsKey('User')) {
|
||||
return Invoke-WithLoadedBackupHive -ScriptBlock $snapshotScript -ArgumentObject @($CapturePlans)
|
||||
}
|
||||
|
||||
return & $snapshotScript $CapturePlans
|
||||
}
|
||||
|
||||
function Invoke-WithLoadedBackupHive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock,
|
||||
$ArgumentObject = $null
|
||||
)
|
||||
|
||||
$hiveDatPath = if ($script:Params.ContainsKey('Sysprep')) {
|
||||
GetUserDirectory -userName 'Default' -fileName 'NTUSER.DAT'
|
||||
}
|
||||
else {
|
||||
GetUserDirectory -userName $script:Params.Item('User') -fileName 'NTUSER.DAT'
|
||||
}
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
reg load 'HKU\Default' "$hiveDatPath" | Out-Null
|
||||
$loadExitCode = $LASTEXITCODE
|
||||
if ($loadExitCode -ne 0) {
|
||||
throw "Failed to load user hive for registry backup at '$hiveDatPath' (exit code: $loadExitCode)"
|
||||
}
|
||||
|
||||
try {
|
||||
return & $ScriptBlock $ArgumentObject
|
||||
}
|
||||
finally {
|
||||
$global:LASTEXITCODE = 0
|
||||
reg unload 'HKU\Default' | Out-Null
|
||||
$unloadExitCode = $LASTEXITCODE
|
||||
if ($unloadExitCode -ne 0) {
|
||||
throw "Failed to unload registry hive 'HKU\Default' (exit code: $unloadExitCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegistryKeySnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$KeyPath,
|
||||
[bool]$CaptureAllValues = $false,
|
||||
[string[]]$ValueNames = @(),
|
||||
[bool]$IncludeSubKeys = $false
|
||||
)
|
||||
|
||||
$registryParts = Split-RegistryPath -path $KeyPath
|
||||
if (-not $registryParts) {
|
||||
throw "Unsupported registry path in backup: $KeyPath"
|
||||
}
|
||||
|
||||
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
|
||||
if (-not $rootKey) {
|
||||
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
|
||||
}
|
||||
|
||||
$subKeyPath = $registryParts.SubKey
|
||||
$key = $rootKey.OpenSubKey($subKeyPath, $false)
|
||||
if ($null -eq $key) {
|
||||
return @{
|
||||
Path = $KeyPath
|
||||
Exists = $false
|
||||
Values = @()
|
||||
SubKeys = @()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return (Convert-RegistryKeyToSnapshot -RegistryKey $key -FullPath $KeyPath -CaptureAllValues:$CaptureAllValues -ValueNames $ValueNames -IncludeSubKeys:$IncludeSubKeys)
|
||||
}
|
||||
finally {
|
||||
$key.Close()
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RegistryKeyToSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FullPath,
|
||||
[bool]$CaptureAllValues = $false,
|
||||
[string[]]$ValueNames = @(),
|
||||
[bool]$IncludeSubKeys = $false
|
||||
)
|
||||
|
||||
$values = @()
|
||||
if ($CaptureAllValues) {
|
||||
foreach ($valueName in @($RegistryKey.GetValueNames())) {
|
||||
$values += @(Convert-RegistryValueToSnapshot -RegistryKey $RegistryKey -ValueName $valueName)
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($valueName in @($ValueNames | Sort-Object -Unique)) {
|
||||
$exists = ($RegistryKey.GetValueNames() -contains $valueName)
|
||||
if ($exists) {
|
||||
$values += @(Convert-RegistryValueToSnapshot -RegistryKey $RegistryKey -ValueName $valueName)
|
||||
}
|
||||
else {
|
||||
$values += @{
|
||||
Name = $valueName
|
||||
Exists = $false
|
||||
Kind = $null
|
||||
Data = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$subKeys = @()
|
||||
if ($IncludeSubKeys) {
|
||||
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
|
||||
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
|
||||
if ($null -eq $childKey) { continue }
|
||||
|
||||
try {
|
||||
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
|
||||
$subKeys += @(Convert-RegistryKeyToSnapshot -RegistryKey $childKey -FullPath $childPath -CaptureAllValues:$true -IncludeSubKeys:$true)
|
||||
}
|
||||
finally {
|
||||
$childKey.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Path = $FullPath
|
||||
Exists = $true
|
||||
Values = $values
|
||||
SubKeys = $subKeys
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RegistryValueToSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string]$ValueName
|
||||
)
|
||||
|
||||
$valueKind = $RegistryKey.GetValueKind($ValueName)
|
||||
$value = $RegistryKey.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
$normalizedValue = switch ($valueKind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) { @($value | ForEach-Object { [int]$_ }) }
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) { @($value) }
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) { [uint32]$value }
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) { [uint64]$value }
|
||||
default { if ($null -ne $value) { [string]$value } else { $null } }
|
||||
}
|
||||
|
||||
return @{
|
||||
Name = $ValueName
|
||||
Exists = $true
|
||||
Kind = $valueKind.ToString()
|
||||
Data = $normalizedValue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegistryBackupTargetDescription {
|
||||
if ($script:Params.ContainsKey('Sysprep')) {
|
||||
return 'DefaultUserProfile'
|
||||
}
|
||||
|
||||
$resolvedUserName = [string](GetUserName)
|
||||
|
||||
if ($script:Params.ContainsKey('User')) {
|
||||
return "User:$resolvedUserName"
|
||||
}
|
||||
|
||||
return "CurrentUser:$resolvedUserName"
|
||||
}
|
||||
88
Scripts/Features/BackupRegistryState.ps1
Normal file
88
Scripts/Features/BackupRegistryState.ps1
Normal file
@@ -0,0 +1,88 @@
|
||||
function New-RegistrySettingsBackup {
|
||||
param(
|
||||
[string[]]$ActionableKeys
|
||||
)
|
||||
|
||||
$ActionableKeys = @($ActionableKeys)
|
||||
$selectedFeatures = Get-SelectedFeatures -ActionableKeys $ActionableKeys
|
||||
if (@($selectedFeatures | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.RegistryKey) }).Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$timestamp = Get-Date
|
||||
$backupDirectory = $script:RegistryBackupsPath
|
||||
if (-not (Test-Path $backupDirectory)) {
|
||||
New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
$backupFileName = 'Win11Debloat-RegistryBackup-{0}.json' -f $timestamp.ToString('yyyyMMdd_HHmmss')
|
||||
$backupFilePath = Join-Path $backupDirectory $backupFileName
|
||||
|
||||
$backupConfig = Get-RegistryBackupPayload -SelectedFeatures $selectedFeatures -CreatedAt $timestamp
|
||||
if (-not (SaveToFile -Config $backupConfig -FilePath $backupFilePath -MaxDepth 25)) {
|
||||
throw "Failed to save registry backup to '$backupFilePath'"
|
||||
}
|
||||
|
||||
Write-Host "Backup successfully created: $backupFilePath"
|
||||
Write-Host ""
|
||||
|
||||
return $backupFilePath
|
||||
}
|
||||
|
||||
function Get-SelectedFeatures {
|
||||
param(
|
||||
[string[]]$ActionableKeys
|
||||
)
|
||||
|
||||
$selectedFeatures = New-Object System.Collections.Generic.List[object]
|
||||
$selectedFeatureIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
foreach ($paramKey in $ActionableKeys) {
|
||||
if (-not $script:Features.ContainsKey($paramKey)) { continue }
|
||||
|
||||
$feature = $script:Features[$paramKey]
|
||||
if (-not $feature) { continue }
|
||||
|
||||
$featureId = Get-FeatureId -Feature $feature
|
||||
|
||||
if ($selectedFeatureIds.Add($featureId)) {
|
||||
$selectedFeatures.Add($feature)
|
||||
}
|
||||
}
|
||||
|
||||
return @($selectedFeatures.ToArray())
|
||||
}
|
||||
|
||||
function Get-RegistryBackupPayload {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[object[]]$SelectedFeatures,
|
||||
[Parameter(Mandatory)]
|
||||
[datetime]$CreatedAt
|
||||
)
|
||||
|
||||
$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
|
||||
|
||||
if ($seenSelectedFeatureIds.Add($featureId)) {
|
||||
$selectedFeatureIds.Add($featureId)
|
||||
}
|
||||
}
|
||||
|
||||
$selectedRegistryFeatures = Get-RegistryBackedFeatures -Features $SelectedFeatures
|
||||
$capturePlans = Get-RegistryBackupCapturePlans -SelectedRegistryFeatures $SelectedRegistryFeatures
|
||||
$registryKeys = @(Get-RegistrySnapshotsForBackup -CapturePlans $capturePlans)
|
||||
|
||||
return @{
|
||||
Version = '1.0'
|
||||
BackupType = 'RegistryState'
|
||||
CreatedAt = $CreatedAt.ToString('o')
|
||||
CreatedBy = 'Win11Debloat'
|
||||
Target = (Get-RegistryBackupTargetDescription)
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
SelectedFeatures = @($selectedFeatureIds)
|
||||
RegistryKeys = @($registryKeys)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
function DisableStoreSearchSuggestionsForAllUsers {
|
||||
# Get path to Store app database for all users
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages"
|
||||
$usersStoreDbPaths = get-childitem -path $userPathString
|
||||
$usersStoreDbPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and disable start search suggestions
|
||||
ForEach ($storeDbPath in $usersStoreDbPaths) {
|
||||
|
||||
@@ -146,10 +146,32 @@ function ExecuteAllChanges {
|
||||
if ($paramKey -eq 'CreateRestorePoint') { continue }
|
||||
$actionableKeys += $paramKey
|
||||
}
|
||||
|
||||
$hasRegistryBackedFeature = $false
|
||||
foreach ($paramKey in $actionableKeys) {
|
||||
if (-not $script:Features.ContainsKey($paramKey)) { continue }
|
||||
|
||||
$feature = $script:Features[$paramKey]
|
||||
if ($feature -and -not [string]::IsNullOrWhiteSpace([string]$feature.RegistryKey)) {
|
||||
$hasRegistryBackedFeature = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$totalSteps = $actionableKeys.Count
|
||||
if ($hasRegistryBackedFeature) { $totalSteps++ }
|
||||
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
|
||||
$currentStep = 0
|
||||
|
||||
if ($hasRegistryBackedFeature) {
|
||||
$currentStep++
|
||||
if ($script:ApplyProgressCallback) {
|
||||
& $script:ApplyProgressCallback $currentStep $totalSteps "Creating registry backup"
|
||||
}
|
||||
|
||||
Write-Host "> Creating registry backup..."
|
||||
New-RegistrySettingsBackup -ActionableKeys $actionableKeys | Out-Null
|
||||
}
|
||||
|
||||
# Create restore point if requested (CLI only - GUI handles this separately)
|
||||
if ($script:Params.ContainsKey("CreateRestorePoint")) {
|
||||
@@ -178,12 +200,8 @@ function ExecuteAllChanges {
|
||||
# Prefer explicit ApplyText when provided
|
||||
$stepName = $feature.ApplyText
|
||||
} elseif ($feature.Label) {
|
||||
# Fallback: construct a name from Action and Label, or just Label
|
||||
if ($feature.Action) {
|
||||
$stepName = "$($feature.Action) $($feature.Label)"
|
||||
} else {
|
||||
$stepName = $feature.Label
|
||||
}
|
||||
# Fallback: use label from Features.json
|
||||
$stepName = $feature.Label
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function ImportRegistryFile {
|
||||
reg unload "HKU\Default" | Out-Null
|
||||
$unloadExitCode = $LASTEXITCODE
|
||||
if ($unloadExitCode -ne 0 -and -not $result.Error) {
|
||||
$result.Error = "Failed to unload temporary hive HKU\\Default (exit code: $unloadExitCode)"
|
||||
$result.Error = "Failed to unload registry hive HKU\Default (exit code: $unloadExitCode)"
|
||||
$result.ExitCode = $unloadExitCode
|
||||
}
|
||||
}
|
||||
|
||||
382
Scripts/Features/RegistryBackupValidation.ps1
Normal file
382
Scripts/Features/RegistryBackupValidation.ps1
Normal file
@@ -0,0 +1,382 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
# Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/
|
||||
function ReplaceStartMenuForAllUsers {
|
||||
param (
|
||||
$startMenuTemplate = "$script:AssetsPath/Start/start2.bin"
|
||||
$startMenuTemplate = "$script:AssetsPath\Start\start2.bin"
|
||||
)
|
||||
|
||||
Write-Host "> Removing all pinned apps from the start menu for all users..."
|
||||
@@ -16,7 +16,7 @@ function ReplaceStartMenuForAllUsers {
|
||||
|
||||
# Get path to start menu file for all users
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = get-childitem -path $userPathString
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
|
||||
# Go through all users and replace the start menu file
|
||||
ForEach ($startMenuPath in $usersStartMenuPaths) {
|
||||
@@ -43,13 +43,13 @@ function ReplaceStartMenuForAllUsers {
|
||||
# Credit: https://lazyadmin.nl/win-11/customize-windows-11-start-menu-layout/
|
||||
function ReplaceStartMenu {
|
||||
param (
|
||||
$startMenuTemplate = "$script:AssetsPath/Start/start2.bin",
|
||||
$startMenuTemplate = "$script:AssetsPath\Start\start2.bin",
|
||||
$startMenuBinFile = "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
)
|
||||
|
||||
# Change path to correct user if a user was specified
|
||||
if ($script:Params.ContainsKey("User")) {
|
||||
$startMenuBinFile = GetUserDirectory -userName "$(GetUserName)" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false
|
||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName (GetUserName)
|
||||
}
|
||||
|
||||
# Check if template bin file exists
|
||||
@@ -63,7 +63,7 @@ function ReplaceStartMenu {
|
||||
return
|
||||
}
|
||||
|
||||
$userName = [regex]::Match($startMenuBinFile, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
|
||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $startMenuBinFile
|
||||
|
||||
$backupBinFile = $startMenuBinFile + ".bak"
|
||||
|
||||
@@ -80,4 +80,142 @@ function ReplaceStartMenu {
|
||||
Copy-Item -Path $startMenuTemplate -Destination $startMenuBinFile -Force
|
||||
|
||||
Write-Host "Replaced start menu for user $userName"
|
||||
}
|
||||
|
||||
function GetStartMenuBinPathForUser {
|
||||
param(
|
||||
[string]$UserName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
return "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin"
|
||||
}
|
||||
|
||||
return (GetUserDirectory -userName $UserName -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -exitIfPathNotFound $false)
|
||||
}
|
||||
|
||||
function GetStartMenuUserNameFromPath {
|
||||
param(
|
||||
[string]$StartMenuBinFile
|
||||
)
|
||||
|
||||
$resolvedUserName = [regex]::Match($StartMenuBinFile, '(?:Users\\)([^\\]+)(?:\\AppData)').Groups[1].Value
|
||||
if ([string]::IsNullOrWhiteSpace($resolvedUserName)) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
return $resolvedUserName
|
||||
}
|
||||
|
||||
|
||||
|
||||
function RestoreStartMenuFromBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$StartMenuBinFile,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userName = GetStartMenuUserNameFromPath -StartMenuBinFile $StartMenuBinFile
|
||||
$backupBinFile = if ([string]::IsNullOrWhiteSpace($BackupFilePath)) {
|
||||
$StartMenuBinFile + '.bak'
|
||||
}
|
||||
else {
|
||||
$BackupFilePath
|
||||
}
|
||||
$currentBinBackup = $StartMenuBinFile + '.restore.bak'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $backupBinFile)) {
|
||||
return [PSCustomObject]@{
|
||||
UserName = $userName
|
||||
Result = $false
|
||||
Message = "No start menu backup file found for user $userName."
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (Test-Path -LiteralPath $StartMenuBinFile) {
|
||||
Move-Item -Path $StartMenuBinFile -Destination $currentBinBackup -Force
|
||||
}
|
||||
|
||||
Copy-Item -Path $backupBinFile -Destination $StartMenuBinFile -Force
|
||||
return [PSCustomObject]@{
|
||||
UserName = $userName
|
||||
Result = $true
|
||||
Message = "Restored start menu for user $userName."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [PSCustomObject]@{
|
||||
UserName = $userName
|
||||
Result = $false
|
||||
Message = "Failed to restore start menu for user $userName. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function RestoreStartMenu {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$targetUserName = GetUserName
|
||||
$startMenuBinFile = GetStartMenuBinPathForUser -UserName $targetUserName
|
||||
|
||||
Write-Host "Restoring start menu for user $targetUserName from backup..."
|
||||
|
||||
return RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
}
|
||||
|
||||
function RestoreStartMenuForAllUsers {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BackupFilePath
|
||||
)
|
||||
|
||||
$userPathString = GetUserDirectory -userName "*" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState"
|
||||
$usersStartMenuPaths = Get-ChildItem -Path $userPathString -ErrorAction SilentlyContinue
|
||||
$results = @()
|
||||
|
||||
Write-Host "Restoring start menu for all users from backup..."
|
||||
|
||||
foreach ($startMenuPath in $usersStartMenuPaths) {
|
||||
$startMenuBinFile = Join-Path $startMenuPath.FullName 'start2.bin'
|
||||
$results += RestoreStartMenuFromBackup -StartMenuBinFile $startMenuBinFile -BackupFilePath $BackupFilePath
|
||||
}
|
||||
|
||||
$defaultStartMenuPath = GetUserDirectory -userName "Default" -fileName "AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState" -exitIfPathNotFound $false
|
||||
|
||||
if (Test-Path $defaultStartMenuPath) {
|
||||
$defaultStartMenuBinFile = Join-Path $defaultStartMenuPath 'start2.bin'
|
||||
if (Test-Path -LiteralPath $defaultStartMenuBinFile) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $defaultStartMenuBinFile -Force
|
||||
$results += [PSCustomObject]@{
|
||||
UserName = 'Default'
|
||||
Result = $true
|
||||
Message = 'Removed start2.bin for the default user profile.'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$results += [PSCustomObject]@{
|
||||
UserName = 'Default'
|
||||
Result = $false
|
||||
Message = "Failed to remove start2.bin for the default user profile. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($results.Count -eq 0) {
|
||||
$results += [PSCustomObject]@{
|
||||
UserName = 'unknown'
|
||||
Result = $false
|
||||
Message = 'No user start menu locations were found.'
|
||||
}
|
||||
}
|
||||
|
||||
return $results
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function RestartExplorer {
|
||||
foreach ($paramKey in $script:Params.Keys) {
|
||||
if ($script:Features.ContainsKey($paramKey) -and $script:Features[$paramKey].RequiresReboot -eq $true) {
|
||||
$feature = $script:Features[$paramKey]
|
||||
Write-Host "Warning: '$($feature.Action) $($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
|
||||
Write-Host "Warning: '$($feature.Label)' requires a reboot to take full effect" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
235
Scripts/Features/RestoreRegistryApplyState.ps1
Normal file
235
Scripts/Features/RestoreRegistryApplyState.ps1
Normal file
@@ -0,0 +1,235 @@
|
||||
function Invoke-WithLoadedRestoreHive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock,
|
||||
$ArgumentObject = $null
|
||||
)
|
||||
|
||||
$hiveDatPath = if ($Target -eq 'DefaultUserProfile') {
|
||||
GetUserDirectory -userName 'Default' -fileName 'NTUSER.DAT'
|
||||
}
|
||||
elseif ($Target -like 'User:*') {
|
||||
$userName = $Target.Substring(5)
|
||||
if ([string]::IsNullOrWhiteSpace($userName)) {
|
||||
throw 'Invalid backup target format for user restore.'
|
||||
}
|
||||
GetUserDirectory -userName $userName -fileName 'NTUSER.DAT'
|
||||
}
|
||||
else {
|
||||
throw "Unsupported backup target '$Target'."
|
||||
}
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
reg load 'HKU\Default' "$hiveDatPath" | Out-Null
|
||||
$loadExitCode = $LASTEXITCODE
|
||||
if ($loadExitCode -ne 0) {
|
||||
throw "Failed to load target user hive '$hiveDatPath' (exit code: $loadExitCode)."
|
||||
}
|
||||
|
||||
try {
|
||||
& $ScriptBlock $ArgumentObject
|
||||
}
|
||||
finally {
|
||||
$global:LASTEXITCODE = 0
|
||||
reg unload 'HKU\Default' | Out-Null
|
||||
$unloadExitCode = $LASTEXITCODE
|
||||
if ($unloadExitCode -ne 0) {
|
||||
throw "Failed to unload registry hive 'HKU\Default' (exit code: $unloadExitCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-RegistryKeySnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
$registryParts = Split-RegistryPath -path $Snapshot.Path
|
||||
if (-not $registryParts) {
|
||||
throw "Unsupported registry path in backup: $($Snapshot.Path)"
|
||||
}
|
||||
|
||||
$rootKey = Get-RegistryRootKey -hiveName $registryParts.Hive
|
||||
if (-not $rootKey) {
|
||||
throw "Unsupported registry hive in backup: $($registryParts.Hive)"
|
||||
}
|
||||
|
||||
$subKeyPath = $registryParts.SubKey
|
||||
if ([string]::IsNullOrWhiteSpace($subKeyPath)) {
|
||||
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
|
||||
}
|
||||
|
||||
if (-not $Snapshot.Exists) {
|
||||
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
|
||||
return
|
||||
}
|
||||
|
||||
$forceFullTree = @($Snapshot.SubKeys).Count -gt 0
|
||||
if ($forceFullTree) {
|
||||
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
|
||||
}
|
||||
|
||||
$key = $rootKey.CreateSubKey($subKeyPath)
|
||||
if ($null -eq $key) {
|
||||
throw "Unable to create or open registry key '$($Snapshot.Path)'"
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($valueSnapshot in @($Snapshot.Values)) {
|
||||
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot $valueSnapshot
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$key.Close()
|
||||
}
|
||||
|
||||
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
|
||||
Restore-RegistryKeySnapshot -Snapshot $subKeySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-RegistryValueSnapshot {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RegistryKey,
|
||||
[Parameter(Mandatory)]
|
||||
$Snapshot
|
||||
)
|
||||
|
||||
$valueName = if ($null -ne $Snapshot.Name) { [string]$Snapshot.Name } else { '' }
|
||||
|
||||
if (-not [bool]$Snapshot.Exists) {
|
||||
try {
|
||||
$RegistryKey.DeleteValue($valueName, $false)
|
||||
}
|
||||
catch {
|
||||
throw "Failed deleting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$valueKind = Convert-RegistryValueKindFromBackup -KindName $Snapshot.Kind
|
||||
$normalizedData = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $Snapshot.Data
|
||||
|
||||
try {
|
||||
$RegistryKey.SetValue($valueName, $normalizedData, $valueKind)
|
||||
}
|
||||
catch {
|
||||
$retryBytes = Convert-BackupDataToByteArray -Data $Snapshot.Data
|
||||
if ($null -ne $retryBytes) {
|
||||
try {
|
||||
$RegistryKey.SetValue($valueName, $retryBytes, [Microsoft.Win32.RegistryValueKind]::Binary)
|
||||
return
|
||||
}
|
||||
catch {
|
||||
# Fall through to original error message for context.
|
||||
}
|
||||
}
|
||||
|
||||
throw "Failed setting registry value '$valueName' in '$($RegistryKey.Name)': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RegistryValueKindFromBackup {
|
||||
param(
|
||||
[string]$KindName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($KindName)) {
|
||||
return [Microsoft.Win32.RegistryValueKind]::String
|
||||
}
|
||||
|
||||
try {
|
||||
return [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $KindName, $true)
|
||||
}
|
||||
catch {
|
||||
throw "Unsupported registry value kind in backup: $KindName"
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RegistryValueDataFromBackup {
|
||||
param(
|
||||
[Microsoft.Win32.RegistryValueKind]$Kind,
|
||||
$Data
|
||||
)
|
||||
|
||||
switch ($Kind) {
|
||||
([Microsoft.Win32.RegistryValueKind]::DWord) { return [uint32]$Data }
|
||||
([Microsoft.Win32.RegistryValueKind]::QWord) { return [uint64]$Data }
|
||||
([Microsoft.Win32.RegistryValueKind]::MultiString) { return @($Data | ForEach-Object { [string]$_ }) }
|
||||
([Microsoft.Win32.RegistryValueKind]::Binary) {
|
||||
$bytes = Convert-BackupDataToByteArray -Data $Data
|
||||
if ($null -eq $bytes) {
|
||||
return (New-Object byte[] 0)
|
||||
}
|
||||
return $bytes
|
||||
}
|
||||
([Microsoft.Win32.RegistryValueKind]::None) { return $null }
|
||||
default {
|
||||
if ($null -ne $Data) {
|
||||
return [string]$Data
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-BackupDataToByteArray {
|
||||
param(
|
||||
$Data
|
||||
)
|
||||
|
||||
if ($null -eq $Data) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Data -is [byte[]]) {
|
||||
return ,$Data
|
||||
}
|
||||
|
||||
$items = @($Data)
|
||||
if ($items.Count -eq 0) {
|
||||
return ,(New-Object byte[] 0)
|
||||
}
|
||||
|
||||
foreach ($item in $items) {
|
||||
if ($item -isnot [ValueType] -and $item -isnot [string]) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parsed = 0
|
||||
if (-not [int]::TryParse([string]$item, [ref]$parsed)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($parsed -lt 0 -or $parsed -gt 255) {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
$bytes = New-Object byte[] $items.Count
|
||||
for ($i = 0; $i -lt $items.Count; $i++) {
|
||||
$bytes[$i] = [byte][int]$items[$i]
|
||||
}
|
||||
|
||||
return ,$bytes
|
||||
}
|
||||
|
||||
function Remove-RegistrySubKeyTreeIfExists {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Microsoft.Win32.RegistryKey]$RootKey,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SubKeyPath
|
||||
)
|
||||
|
||||
$existing = $RootKey.OpenSubKey($SubKeyPath, $false)
|
||||
if ($existing) {
|
||||
$existing.Close()
|
||||
$RootKey.DeleteSubKeyTree($SubKeyPath, $false)
|
||||
}
|
||||
}
|
||||
145
Scripts/Features/RestoreRegistryBackup.ps1
Normal file
145
Scripts/Features/RestoreRegistryBackup.ps1
Normal file
@@ -0,0 +1,145 @@
|
||||
function Load-RegistryBackupFromFile {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FilePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $FilePath)) {
|
||||
throw "Backup file was not found: $FilePath"
|
||||
}
|
||||
|
||||
try {
|
||||
$rawBackup = Get-Content -LiteralPath $FilePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
throw "Failed to read backup file '$FilePath'. The file is not valid JSON."
|
||||
}
|
||||
|
||||
return Normalize-RegistryBackup -Backup $rawBackup
|
||||
}
|
||||
|
||||
function Normalize-RegistryBackup {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Backup
|
||||
)
|
||||
|
||||
$errors = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
if (-not $Backup.PSObject.Properties['Version']) {
|
||||
$errors.Add('Missing property: Version')
|
||||
}
|
||||
elseif ([string]$Backup.Version -ne '1.0') {
|
||||
$errors.Add("Unsupported backup version '$($Backup.Version)'.")
|
||||
}
|
||||
|
||||
if (-not $Backup.PSObject.Properties['BackupType']) {
|
||||
$errors.Add('Missing property: BackupType')
|
||||
}
|
||||
elseif ([string]$Backup.BackupType -ne 'RegistryState') {
|
||||
$errors.Add("Unsupported BackupType '$($Backup.BackupType)'.")
|
||||
}
|
||||
|
||||
$normalizedTarget = ''
|
||||
if (-not $Backup.PSObject.Properties['Target'] -or [string]::IsNullOrWhiteSpace([string]$Backup.Target)) {
|
||||
$errors.Add('Missing property: Target')
|
||||
}
|
||||
else {
|
||||
$normalizedTarget = [string]$Backup.Target
|
||||
|
||||
if ($normalizedTarget -eq 'DefaultUserProfile') {
|
||||
# Valid target format.
|
||||
}
|
||||
elseif ($normalizedTarget -like 'User:*') {
|
||||
$targetUserName = $normalizedTarget.Substring(5)
|
||||
$targetValidation = Test-TargetUserName -UserName $targetUserName
|
||||
if (-not $targetValidation.IsValid) {
|
||||
$errors.Add("Invalid user '$normalizedTarget'")
|
||||
}
|
||||
}
|
||||
elseif ($normalizedTarget -like 'CurrentUser:*') {
|
||||
$targetCurrentUserName = $normalizedTarget.Substring(12)
|
||||
if ([string]::IsNullOrWhiteSpace($targetCurrentUserName) -or ($targetCurrentUserName -ne $env:USERNAME)) {
|
||||
$errors.Add("Backup was made for '$targetCurrentUserName', this does not match current user '$env:USERNAME'.")
|
||||
}
|
||||
}
|
||||
else {
|
||||
$errors.Add("Unsupported Target '$normalizedTarget'.")
|
||||
}
|
||||
}
|
||||
|
||||
$registryKeys = @()
|
||||
if (-not $Backup.PSObject.Properties['RegistryKeys']) {
|
||||
$errors.Add('Missing property: RegistryKeys')
|
||||
}
|
||||
else {
|
||||
$registryKeys = @($Backup.RegistryKeys)
|
||||
}
|
||||
|
||||
$normalizedKeys = @()
|
||||
foreach ($keySnapshot in $registryKeys) {
|
||||
$normalizedKeys += @(Normalize-RegistryKeySnapshot -Snapshot $keySnapshot)
|
||||
}
|
||||
|
||||
$selectedFeatureParseResult = Get-NormalizedSelectedFeatureIdsFromBackup -Backup $Backup
|
||||
$selectedFeatures = @($selectedFeatureParseResult.SelectedFeatures)
|
||||
foreach ($selectedFeatureParseError in @($selectedFeatureParseResult.Errors)) {
|
||||
$errors.Add([string]$selectedFeatureParseError)
|
||||
}
|
||||
|
||||
$allowListValidationErrors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @($selectedFeatures) -RegistryKeys @($normalizedKeys))
|
||||
foreach ($allowListValidationError in $allowListValidationErrors) {
|
||||
$errors.Add([string]$allowListValidationError)
|
||||
}
|
||||
|
||||
if ($errors.Count -gt 0) {
|
||||
Write-Error "Backup validation failed: $($errors -join ' ')"
|
||||
if ($errors.Count -eq 1) {
|
||||
throw ("Validation failed: $($errors[0])")
|
||||
}
|
||||
else {
|
||||
throw ("Validation failed with $($errors.Count) errors. See console output for details.")
|
||||
}
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Version = [string]$Backup.Version
|
||||
BackupType = [string]$Backup.BackupType
|
||||
CreatedAt = [string]$Backup.CreatedAt
|
||||
CreatedBy = [string]$Backup.CreatedBy
|
||||
ComputerName = [string]$Backup.ComputerName
|
||||
Target = $normalizedTarget
|
||||
SelectedFeatures = @($selectedFeatures)
|
||||
RegistryKeys = @($normalizedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-RegistryBackupState {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Backup
|
||||
)
|
||||
|
||||
$friendlyTarget = GetFriendlyRegistryBackupTarget -Target ([string]$Backup.Target)
|
||||
|
||||
$restoreAction = {
|
||||
param($normalizedBackup)
|
||||
|
||||
Write-Host "Applying registry restore from $(@($normalizedBackup.RegistryKeys).Count) root snapshot(s)."
|
||||
foreach ($rootSnapshot in @($normalizedBackup.RegistryKeys)) {
|
||||
Restore-RegistryKeySnapshot -Snapshot $rootSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Starting restore for $friendlyTarget."
|
||||
|
||||
if ($Backup.Target -eq 'DefaultUserProfile' -or $Backup.Target -like 'User:*') {
|
||||
Write-Host "Restore requires loading target user hive."
|
||||
Invoke-WithLoadedRestoreHive -Target $Backup.Target -ScriptBlock $restoreAction -ArgumentObject $Backup
|
||||
Write-Host "Restore completed for $friendlyTarget."
|
||||
return
|
||||
}
|
||||
|
||||
& $restoreAction $Backup
|
||||
Write-Host "Restore completed for $friendlyTarget."
|
||||
}
|
||||
Reference in New Issue
Block a user