private/StateSnapshot.ps1
|
# FR-006: export prior state before mutating; research.md -> reg.exe export/import for # registry (atomic, multi-value-safe), plain JSON-friendly objects for services/tasks. function ConvertTo-OctaRegExePath { param([Parameter(Mandatory)][string]$PsPath) # HKLM:\SOFTWARE\Foo -> HKLM\SOFTWARE\Foo (reg.exe does not use the PSDrive colon form) return ($PsPath -replace '^([A-Za-z]+):\\', '$1\') } function Save-OctaRegistrySnapshot { param( [Parameter(Mandatory)][string]$RegistryPath, [Parameter(Mandatory)][string]$DestinationFile ) $regExePath = ConvertTo-OctaRegExePath -PsPath $RegistryPath & reg.exe export $regExePath $DestinationFile /y | Out-Null } function Restore-OctaRegistrySnapshot { <# reg.exe writes a benign "some keys were open" warning to stderr - and returns a non-zero exit code - even when the value was, in fact, written correctly (observed: HKCU keys the shell/Explorer also has open). Neither reg.exe's stderr text nor its exit code reliably distinguishes that from a genuine failure here, so this function redirects stderr (so the benign warning doesn't print as an alarming "ERROR" line) but does NOT treat a non-zero exit code as failure on its own. If reg.exe genuinely cannot run (missing file, bad path), that surfaces as a real PowerShell exception instead. #> param([Parameter(Mandatory)][string]$SnapshotFile) if (Test-Path $SnapshotFile) { # Stream redirection alone (2>$null, *>$null) does NOT stop $ErrorActionPreference = # 'Stop' (e.g. GitHub Actions' default for `shell: powershell` steps) from turning # reg.exe's benign stderr warning into a terminating NativeCommandError - redirection # only decides where content goes *if* execution isn't stopped first. try/catch is the # only reliable way to swallow it, verified real (before/after registry state) to be a # false failure signal, not an actual one. try { & reg.exe import $SnapshotFile 2>$null 1>$null } catch { # Intentionally ignored - see above. } } } function Save-OctaServiceState { param([Parameter(Mandatory)][string]$ServiceName) $wmiSvc = Get-CimInstance -ClassName Win32_Service -Filter "Name='$ServiceName'" -ErrorAction SilentlyContinue if (-not $wmiSvc) { return $null } return [pscustomobject]@{ Type = 'Service'; Name = $ServiceName; PriorStartMode = $wmiSvc.StartMode } } function Restore-OctaServiceState { <# No -ErrorAction SilentlyContinue here on purpose: a service whose ACL rejects Set-Service (observed: DoSvc/Delivery Optimization, access denied even under a full admin token) must surface that failure so Undo-OctaRun reports "could not undo" honestly, instead of printing "Restored" for a change that silently never happened. #> param([Parameter(Mandatory)][object]$State) $startupTypeMap = @{ 'Auto' = 'Automatic'; 'Manual' = 'Manual'; 'Disabled' = 'Disabled' } $mapped = $startupTypeMap[$State.PriorStartMode] if ($mapped) { Set-Service -Name $State.Name -StartupType $mapped -ErrorAction Stop } } function Save-OctaOptionalFeatureState { param([Parameter(Mandatory)][string]$FeatureName) $feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction SilentlyContinue if (-not $feature) { return $null } return [pscustomobject]@{ Type = 'OptionalFeature'; FeatureName = $FeatureName; PriorState = $feature.State.ToString() } } function Restore-OctaOptionalFeatureState { param([Parameter(Mandatory)][object]$State) if ($State.PriorState -eq 'Disabled') { Disable-WindowsOptionalFeature -Online -FeatureName $State.FeatureName -NoRestart -ErrorAction Stop | Out-Null } else { Enable-WindowsOptionalFeature -Online -FeatureName $State.FeatureName -All -NoRestart -ErrorAction Stop | Out-Null } } function Save-OctaScheduledTaskState { param( [Parameter(Mandatory)][string]$TaskPath, [Parameter(Mandatory)][string]$TaskName ) $task = Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue if (-not $task) { return $null } return [pscustomobject]@{ Type = 'ScheduledTask'; TaskPath = $TaskPath; TaskName = $TaskName; PriorState = $task.State.ToString() } } function Restore-OctaScheduledTaskState { param([Parameter(Mandatory)][object]$State) if ($State.PriorState -eq 'Disabled') { Disable-ScheduledTask -TaskPath $State.TaskPath -TaskName $State.TaskName -ErrorAction SilentlyContinue | Out-Null } else { Enable-ScheduledTask -TaskPath $State.TaskPath -TaskName $State.TaskName -ErrorAction SilentlyContinue | Out-Null } } function Save-OctaActionSnapshot { <# Dispatches to the right snapshot function for an Action (data-model.md), writing any file-based snapshot (registry .reg export) into $RunFolder. Returns a snapshot record suitable for Run Record's ActionSnapshots list, or $null for irreversible actions. #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Action, [Parameter(Mandatory)][string]$RunFolder ) if (-not $Action.Reversible) { return $null } switch ($Action.TargetType) { 'Registry' { # TargetIdentifier is "RegistryKeyPath|ValueName" - only the key path is exportable. $keyPath, $valueName = $Action.TargetIdentifier -split '\|', 2 # ponytail: a key Octa itself creates (policy keys, opt-in feature keys, the classic # context-menu CLSID hack) doesn't exist yet at snapshot time - reg.exe export has # nothing to capture, and there's no prior state to "restore" other than absence. # Recording that explicitly here means Restore-OctaActionSnapshot can delete the key # back to that absent state, instead of this action silently becoming unreversible. # -LiteralPath, not -Path: a key path can legitimately contain "*" as a literal # registry key name (e.g. HKCR:\*\... = HKEY_CLASSES_ROOT's real "all file types" # key) - without -LiteralPath, PowerShell treats "*" as a wildcard and enumerates # every one of HKCR's tens of thousands of subkeys trying to match it, hanging for # a very long time instead of doing a direct lookup. if (-not (Test-Path -LiteralPath $keyPath)) { return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'RegistryKeyAbsent'; KeyPath = $keyPath } } $fileName = "reg-{0:N}.reg" -f [guid]::NewGuid() $destination = Join-Path $RunFolder $fileName Save-OctaRegistrySnapshot -RegistryPath $keyPath -DestinationFile $destination # ponytail: `reg import` only adds/overwrites values present in the exported file - # it never deletes a value absent from it. So when the key existed but this specific # value didn't (a fresh toggle, not an edit of an existing one), the exported .reg # has nothing to say about it and import can't remove what Octa is about to create. # Recording that here lets Restore-OctaActionSnapshot explicitly delete the value # back out instead of "undo" silently leaving it in place while reporting success. $valueExisted = ($valueName -eq '(delete)') -or ($null -ne (Get-ItemProperty -LiteralPath $keyPath -Name $valueName -ErrorAction SilentlyContinue)) return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'Registry'; SnapshotFile = $fileName; ValueExisted = $valueExisted } } 'Service' { $state = Save-OctaServiceState -ServiceName $Action.TargetIdentifier return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'Service'; State = $state } } 'ScheduledTask' { $parts = $Action.TargetIdentifier -split '\|', 2 $state = Save-OctaScheduledTaskState -TaskPath $parts[0] -TaskName $parts[1] return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'ScheduledTask'; State = $state } } 'Package' { # Only Windows optional features (e.g. Sandbox, WSL) are reversible Package actions # this way - irreversible ones (Edge removal) never reach here, since the # Reversible = $false check above already returned $null for them. $state = Save-OctaOptionalFeatureState -FeatureName $Action.TargetIdentifier if (-not $state) { return $null } return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'OptionalFeature'; State = $state } } default { return $null } } } function Restore-OctaActionSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Snapshot, [Parameter(Mandatory)][string]$RunFolder ) switch ($Snapshot.Type) { 'Registry' { Restore-OctaRegistrySnapshot -SnapshotFile (Join-Path $RunFolder $Snapshot.SnapshotFile) # Older/other snapshot builders (Invoke-OctaStartupManager constructs its own Registry # snapshot directly, bypassing Save-OctaActionSnapshot) may not carry ValueExisted at # all - property-existence must be checked explicitly, since $Snapshot.ValueExisted on # a missing property silently returns $null, and "-not $null" is $true, which would # wrongly delete the value this same call just restored. $hasValueExistedField = $Snapshot.PSObject.Properties.Name -contains 'ValueExisted' if ($hasValueExistedField -and -not $Snapshot.ValueExisted) { $keyPath, $valueName = $Snapshot.ActionRef -split '\|', 2 Remove-ItemProperty -LiteralPath $keyPath -Name $valueName -ErrorAction SilentlyContinue } } 'RegistryKeyAbsent' { Remove-Item -LiteralPath $Snapshot.KeyPath -Recurse -Force -ErrorAction SilentlyContinue } 'Service' { if ($Snapshot.State) { Restore-OctaServiceState -State $Snapshot.State } } 'ScheduledTask' { if ($Snapshot.State) { Restore-OctaScheduledTaskState -State $Snapshot.State } } 'OptionalFeature' { if ($Snapshot.State) { Restore-OctaOptionalFeatureState -State $Snapshot.State } } } } |