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