Runtime/Restore-OSDStageCacheGeneration.ps1

. (Join-Path $PSScriptRoot 'OSDStagePayloadFile.ps1')
# Shared by the module and standalone PreInstall. No deployment is started here.
function Restore-OSDStageCacheGeneration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Root,
        [Parameter(Mandatory)][scriptblock]$Validate
    )

    $parent = Split-Path -Path $Root -Parent
    $leaf = Split-Path -Path $Root -Leaf
    if (-not (Test-Path -LiteralPath $parent -PathType Container)) { return }

    $oldPattern = '^' + [regex]::Escape($leaf) + '\.osdstage-old-[0-9a-f]{32}$'
    $newPattern = '^' + [regex]::Escape($leaf) + '\.osdstage-new-[0-9a-f]{32}$'
    $children = @(Get-ChildItem -LiteralPath $parent -Directory -Force -ErrorAction Stop)
    $backups = @($children | Where-Object { $_.Name -match $oldPattern })
    $candidates = @($children | Where-Object { $_.Name -match $newPattern })

    function Test-RecoveryGeneration {
        param([Parameter(Mandatory)][string]$Path)
        try {
            $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
            if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Recovery does not follow directory links.' }
            $links = @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 })
            if ($links.Count -gt 0) { throw 'Recovery generation contains a file or directory link.' }
            & $Validate $Path | Out-Null
            return $true
        }
        catch {
            Write-Verbose "Recovery validation rejected '$Path': $($_.Exception.Message)"
            return $false
        }
    }

    function Remove-TransactionDirectory {
        param([Parameter(Mandatory)][string]$Path)
        if (Test-Path -LiteralPath $Path -PathType Container) {
            Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
            Write-Verbose "Removed completed or abandoned OSDStage cache transaction directory '$Path'."
            return 1
        }
        return 0
    }

    if (Test-Path -LiteralPath $Root -PathType Container) {
        $activeValid = Test-RecoveryGeneration -Path $Root
        if ($activeValid) {
            $removed = 0
            foreach ($item in @($backups) + @($candidates)) { $removed += Remove-TransactionDirectory -Path $item.FullName }
            if ($removed -gt 0) { return [pscustomobject]@{Root=$Root;Recovered=$false;ResidueRemoved=$removed;ActiveValidated=$true} }
            return
        }

        $validBackups = @($backups | Where-Object { Test-RecoveryGeneration -Path $_.FullName })
        if ($validBackups.Count -gt 1) {
            throw "Cache recovery for '$Root' is ambiguous: found $($validBackups.Count) valid previous generations. Transaction directories were preserved."
        }
        if ($validBackups.Count -eq 1) {
            $quarantine = "$Root.osdstage-bad-$([guid]::NewGuid().ToString('N'))"
            Move-Item -LiteralPath $Root -Destination $quarantine -ErrorAction Stop
            try {
                Move-Item -LiteralPath $validBackups[0].FullName -Destination $Root -ErrorAction Stop
                if (-not (Test-RecoveryGeneration -Path $Root)) { throw 'Restored previous generation failed validation.' }
                Remove-Item -LiteralPath $quarantine -Recurse -Force -ErrorAction Stop
                $removed = 0
                foreach ($item in @($backups | Where-Object FullName -ne $validBackups[0].FullName) + @($candidates)) { $removed += Remove-TransactionDirectory -Path $item.FullName }
                return [pscustomobject]@{Root=$Root;PreviousPath=$validBackups[0].FullName;Recovered=$true;ResidueRemoved=$removed;ActiveValidated=$true}
            }
            catch {
                $restoreError = $_
                if (-not (Test-Path -LiteralPath $Root) -and (Test-Path -LiteralPath $quarantine)) { Move-Item -LiteralPath $quarantine -Destination $Root -ErrorAction SilentlyContinue }
                throw $restoreError
            }
        }

        $removed = 0
        foreach ($item in $candidates) { $removed += Remove-TransactionDirectory -Path $item.FullName }
        if ($removed -gt 0) { return [pscustomobject]@{Root=$Root;Recovered=$false;ResidueRemoved=$removed;ActiveValidated=$false} }
        return
    }

    $validBackups = @($backups | Where-Object { Test-RecoveryGeneration -Path $_.FullName })
    if ($validBackups.Count -gt 1) {
        throw "Cache recovery requires an unambiguous previous generation for '$Root'; found $($validBackups.Count) valid generations. Transaction directories were preserved."
    }
    if ($validBackups.Count -eq 1) {
        $source = $validBackups[0].FullName
        Move-Item -LiteralPath $source -Destination $Root -ErrorAction Stop
        $removed = 0
        foreach ($item in @($backups | Where-Object FullName -ne $source) + @($candidates)) { $removed += Remove-TransactionDirectory -Path $item.FullName }
        return [pscustomobject]@{Root=$Root;PreviousPath=$source;Recovered=$true;ResidueRemoved=$removed;ActiveValidated=$true}
    }

    $removed = 0
    foreach ($item in $candidates) { $removed += Remove-TransactionDirectory -Path $item.FullName }
    if ($removed -gt 0) { return [pscustomobject]@{Root=$Root;Recovered=$false;ResidueRemoved=$removed;ActiveValidated=$false} }
}

function Assert-OSDStageRecoverableBuiltInGeneration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Root,
        [Parameter(Mandatory)][ValidateSet('Microsoft365Apps','MicrosoftTeams','GoogleChromeEnterprise','MozillaFirefoxEnterprise','CiscoWebex','ZoomWorkplace','AdobeAcrobatUnified')][string]$Id,
        [Parameter(Mandatory)][scriptblock]$SignatureValidator,
        [Parameter(Mandatory)][scriptblock]$OfficeValidator,
        [Parameter(Mandatory)][scriptblock]$TeamsVersionReader
    )

    $info = Get-Content -LiteralPath (Join-Path $Root 'CacheInfo.json') -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    if ([string]$info.Id -ne $Id -or $info.Cached -ne $true) {
        throw "Recovery metadata does not describe a cached '$Id' generation."
    }
    $payloadPath = $null
    if ($Id -ne 'Microsoft365Apps') {
        $extension = if ($Id -eq 'MicrosoftTeams') { '.msix' } elseif ($Id -eq 'AdobeAcrobatUnified') { '.zip' } else { '.msi' }
        $payloadPath = Assert-OSDStageCachedPayload -Root $Root -ExpectedExtension $extension -Id $Id
    }
    $required = switch ($Id) {
        'Microsoft365Apps' { 'setup.exe'; 'configuration.xml' }
        'MicrosoftTeams' { 'teamsbootstrapper.exe'; [string]$info.PayloadFileName }
        default { [string]$info.PayloadFileName }
    }
    foreach ($file in $required) {
        $path = (Resolve-OSDStageBuiltInPayloadPath -Path (Join-Path $Root $file))
        if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-Item -LiteralPath $path).Length -le 0) {
            throw "Recovery generation '$Id' is missing nonempty '$file'."
        }
    }
    switch ($Id) {
        'Microsoft365Apps' {
            if ([string]$info.Version -notmatch '^\d+\.\d+\.\d+\.\d+$' -or [string]$info.Architecture -notin @('x86','x64')) {
                throw 'Office recovery metadata has no valid version or architecture.'
            }
            & $OfficeValidator $Root ([string]$info.Version) ([string]$info.Architecture) | Out-Null
            & $SignatureValidator $Id (Join-Path $Root 'setup.exe') | Out-Null
        }
        'MicrosoftTeams' {
            & $SignatureValidator $Id (Join-Path $Root 'teamsbootstrapper.exe') | Out-Null
            $version = & $TeamsVersionReader $payloadPath
            if (-not $version -or [string]$version -ne [string]$info.Version) {
                throw 'Teams recovery package version does not match its metadata.'
            }
        }
        'AdobeAcrobatUnified' {
            Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
            $archive = [IO.Compression.ZipFile]::OpenRead($payloadPath)
            try {
                if ($archive.Entries.Count -eq 0) { throw 'Adobe recovery archive is empty.' }
            }
            finally { $archive.Dispose() }
        }
        default { & $SignatureValidator $Id $payloadPath | Out-Null }
    }
}