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 ) if (-not (Test-Path $RegistryPath)) { return $false } $regExePath = ConvertTo-OctaRegExePath -PsPath $RegistryPath & reg.exe export $regExePath $DestinationFile /y | Out-Null return $true } 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 { 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 SilentlyContinue } } 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 = ($Action.TargetIdentifier -split '\|', 2)[0] $fileName = "reg-{0:N}.reg" -f [guid]::NewGuid() $destination = Join-Path $RunFolder $fileName if (Save-OctaRegistrySnapshot -RegistryPath $keyPath -DestinationFile $destination) { return [pscustomobject]@{ ActionRef = $Action.TargetIdentifier; Type = 'Registry'; SnapshotFile = $fileName } } return $null } '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 } } 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) } 'Service' { if ($Snapshot.State) { Restore-OctaServiceState -State $Snapshot.State } } 'ScheduledTask' { if ($Snapshot.State) { Restore-OctaScheduledTaskState -State $Snapshot.State } } } } |