Runtime/OSDAppPrerequisite.ps1

# Shared runtime acquisition, validation and installed-version checks.
function Get-OSDAppPrerequisiteSpec {
    param(
        [Parameter(Mandatory)][ValidateSet('VisualCRedist','DotNetRuntime')][string]$Family,
        [Parameter(Mandatory)][ValidateSet('x86','x64')][string]$Architecture,
        [ValidateSet('Runtime','Desktop')][string]$RuntimeType = 'Desktop',
        [string]$Channel = '10.0'
    )
    if ($Family -eq 'VisualCRedist') {
        $id = "VisualCRedist-$Architecture"
        $relative = "BuiltIn\VisualCRedist\$Architecture"
        $display = "Visual C++ v14 Redistributable ($Architecture)"
        $Channel = '14'; $RuntimeType = 'Runtime'
    }
    else {
        Assert-OSDAppDotNetChannelSupport -Channel $Channel
        $id = "DotNetRuntime-$RuntimeType-$Channel-$Architecture"
        $relative = "BuiltIn\DotNetRuntime\$RuntimeType\$Channel\$Architecture"
        $display = ".NET $RuntimeType Runtime $Channel ($Architecture)"
    }
    [pscustomobject]@{ Id=$id; Family=$Family; Architecture=$Architecture; RuntimeType=$RuntimeType; Channel=$Channel; RelativeRoot=$relative; DisplayName=$display }
}

function Assert-OSDAppDotNetChannelSupport {
    param([Parameter(Mandatory)][string]$Channel)
    # Offline lifecycle guard, checked at staging, acquisition and execution.
    # Source: https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core
    $endDates = @{ '8.0'='2026-11-10'; '9.0'='2026-11-10'; '10.0'='2028-11-14' }
    if (-not $endDates.ContainsKey($Channel)) {
        throw [NotSupportedException]::new(".NET channel '$Channel' is not offered. Choose a supported stable channel: 8.0, 9.0 or 10.0.")
    }
    $endDate = [datetime]::ParseExact($endDates[$Channel], 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture)
    if ((Get-Date).ToUniversalTime().Date -gt $endDate) {
        throw [NotSupportedException]::new(".NET $Channel support ended on $($endDates[$Channel]); this channel cannot be staged, acquired or installed, including from cache.")
    }
}

function Assert-OSDAppPrerequisitePublisher {
    param([Parameter(Mandatory)][string]$Path)
    $signature = Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction Stop
    if ([string]$signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or
        [string]$signature.SignerCertificate.Subject -notmatch '(?:^|,\s*)O=(?:Microsoft Corporation|"Microsoft Corporation")(?=,|$)') {
        throw 'Runtime installer requires a valid Microsoft Corporation Authenticode signature.'
    }
}

function Get-OSDAppPrerequisiteRelease {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Spec)
    if ($Spec.Family -eq 'VisualCRedist') {
        $uri = "https://aka.ms/vc14/vc_redist.$($Spec.Architecture).exe"
        $request = [Net.HttpWebRequest]::Create($uri)
        $request.Method = 'HEAD'; $request.Timeout = 60000; $request.ReadWriteTimeout = 60000
        $response = $request.GetResponse()
        try {
            $resolved = $response.ResponseUri
            if ($resolved.Scheme -ne 'https' -or $resolved.Host -notin @('download.visualstudio.microsoft.com','download.microsoft.com')) {
                throw 'Unexpected Visual C++ download host.'
            }
            $etag = [string]$response.Headers['ETag']; $modified = [string]$response.Headers['Last-Modified']
            if ($response.ContentLength -le 0 -or (-not $etag -and -not $modified)) { throw 'Visual C++ freshness metadata is incomplete.' }
            [pscustomobject]@{ PackageUri=$uri; ResolvedUri=$resolved.AbsoluteUri; Size=[long]$response.ContentLength; ETag=$etag; LastModified=$modified; Version=$null; Sha512=$null }
        }
        finally { $response.Close() }
        return
    }
    Assert-OSDAppDotNetChannelSupport -Channel $Spec.Channel
    $metadata = Invoke-RestMethod -Uri "https://builds.dotnet.microsoft.com/dotnet/release-metadata/$($Spec.Channel)/releases.json" -TimeoutSec 60 -ErrorAction Stop
    if ([string]$metadata.'channel-version' -ne $Spec.Channel) {
        throw 'The .NET release metadata does not match the requested channel.'
    }
    $supportProperty = $metadata.PSObject.Properties['support-phase']
    $supportPhase = if ($supportProperty) { [string]$supportProperty.Value } else { '' }
    if ($supportPhase -in @('eol','preview','go-live','golive')) {
        throw [NotSupportedException]::new(".NET $($Spec.Channel) is not a supported stable channel (status: $supportPhase).")
    }
    if ($supportPhase -notin @('active','maintenance')) {
        throw 'Cannot confirm the .NET channel support status from Microsoft release metadata.'
    }
    $version = [string]$metadata.'latest-runtime'
    if ($version -notmatch ('^' + [regex]::Escape($Spec.Channel) + '\.\d+$')) { throw 'Invalid stable .NET runtime version.' }
    $releases = @($metadata.releases | Where-Object { [string]$_.'release-version' -eq $version })
    if ($releases.Count -ne 1) { throw 'Expected exactly one stable .NET release.' }
    $kind = if ($Spec.RuntimeType -eq 'Desktop') { 'windowsdesktop' } else { 'runtime' }
    $component = $releases[0].$kind
    if ([string]$component.version -ne $version) { throw '.NET component version does not match its release.' }
    $files = @($component.files | Where-Object { $_.rid -eq "win-$($Spec.Architecture)" -and $_.name -like '*.exe' })
    if ($files.Count -ne 1 -or [string]$files[0].hash -notmatch '^[a-fA-F0-9]{128}$') { throw 'Expected one .NET installer with a release SHA-512.' }
    $segment = if ($Spec.RuntimeType -eq 'Desktop') { 'WindowsDesktop' } else { 'Runtime' }
    $prefix = if ($Spec.RuntimeType -eq 'Desktop') { 'windowsdesktop-runtime' } else { 'dotnet-runtime' }
    $expected = "https://builds.dotnet.microsoft.com/dotnet/$segment/$version/$prefix-$version-win-$($Spec.Architecture).exe"
    if ([string]$files[0].url -cne $expected) { throw 'Unexpected .NET runtime installer URL.' }
    [pscustomobject]@{ PackageUri=$expected; ResolvedUri=$expected; Size=0; ETag=''; LastModified=''; Version=$version; Sha512=[string]$files[0].hash }
}

function Assert-OSDAppPrerequisiteGeneration {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)]$Spec, $Release, [switch]$VerifyPublisher)
    if ($Spec.Family -eq 'DotNetRuntime') { Assert-OSDAppDotNetChannelSupport -Channel $Spec.Channel }
    $info = Get-Content -LiteralPath (Join-Path $Root 'CacheInfo.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    foreach ($name in @('Architecture','RuntimeType','Channel')) {
        if ([string]$info.$name -cne [string]$Spec.$name) { throw "Runtime cache $name is incompatible." }
    }
    if ([string]$info.Id -ne $Spec.Family -or [string]$info.VariantId -ne $Spec.Id -or [string]$info.Sha256 -notmatch '^[a-fA-F0-9]{64}$' -or [long]$info.Size -le 0) { throw 'Runtime cache identity or integrity metadata is invalid.' }
    $version = [version]$info.Version
    if ($Spec.Family -eq 'VisualCRedist') {
        if ($version.Major -ne 14 -or [string]$info.PackageUri -cne "https://aka.ms/vc14/vc_redist.$($Spec.Architecture).exe") { throw 'Invalid Visual C++ cache version or source.' }
    }
    else {
        if ("$($version.Major).$($version.Minor)" -ne $Spec.Channel -or [string]$info.Sha512 -notmatch '^[a-fA-F0-9]{128}$') { throw 'Invalid .NET cache version or release hash.' }
        $segment = if ($Spec.RuntimeType -eq 'Desktop') { 'WindowsDesktop' } else { 'Runtime' }
        $prefix = if ($Spec.RuntimeType -eq 'Desktop') { 'windowsdesktop-runtime' } else { 'dotnet-runtime' }
        if ([string]$info.PackageUri -cne "https://builds.dotnet.microsoft.com/dotnet/$segment/$($info.Version)/$prefix-$($info.Version)-win-$($Spec.Architecture).exe") { throw 'Invalid .NET cache source.' }
    }
    if ($Release) {
        foreach ($name in @('PackageUri','ResolvedUri','ETag','LastModified')) {
            if ([string]$info.$name -cne [string]$Release.$name) { throw 'Runtime cache does not match the current release.' }
        }
        if ($Release.Version -and [string]$info.Version -ne [string]$Release.Version) { throw 'Runtime cache version is outdated.' }
        if ($Release.Sha512 -and [string]$info.Sha512 -ne [string]$Release.Sha512) { throw 'Runtime cache release SHA-512 differs.' }
        if ($Release.Size -gt 0 -and [long]$info.Size -ne [long]$Release.Size) { throw 'Runtime cache release size differs.' }
    }
    $package = Join-Path $Root 'Package.exe'
    if ((Get-Item -LiteralPath $package -ErrorAction Stop).Length -ne [long]$info.Size -or
        (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash -ne [string]$info.Sha256) { throw 'Runtime cache size or SHA-256 mismatch.' }
    if ($Spec.Family -eq 'DotNetRuntime' -and (Get-FileHash -LiteralPath $package -Algorithm SHA512).Hash -ne [string]$info.Sha512) { throw '.NET release SHA-512 mismatch.' }
    if ($VerifyPublisher) { Assert-OSDAppPrerequisitePublisher -Path $package }
    $info
}

function Sync-OSDAppPrerequisiteGeneration {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)]$Spec, [Parameter(Mandatory)][scriptblock]$Download, [double]$MinimumFreeSpaceGB=1)
    if ($Spec.Family -eq 'DotNetRuntime') { Assert-OSDAppDotNetChannelSupport -Channel $Spec.Channel }
    Restore-OSDAppCacheGeneration -Root $Root -Validate { param($candidate) Assert-OSDAppPrerequisiteGeneration -Root $candidate -Spec $Spec -VerifyPublisher } | Out-Null
    $release = Get-OSDAppPrerequisiteRelease -Spec $Spec
    $current = $false
    try { $info = Assert-OSDAppPrerequisiteGeneration -Root $Root -Spec $Spec -Release $release -VerifyPublisher; $current = $true }
    catch { Write-Verbose "Runtime cache requires acquisition: $($_.Exception.Message)" }
    if (-not $current) {
        $downloadAction = $Download
        Invoke-OSDAppBuiltInCacheTransaction -Root $Root -MinimumFreeSpaceGB $MinimumFreeSpaceGB -Build {
            param($candidate)
            $package = Join-Path $candidate 'Package.exe'
            & $downloadAction $release.PackageUri $package | Out-Null
            Assert-OSDAppPrerequisitePublisher -Path $package
            $version = $release.Version
            if ($Spec.Family -eq 'VisualCRedist') {
                $fileVersion = (Get-Item -LiteralPath $package).VersionInfo.FileVersion
                if ($fileVersion -notmatch '(?<version>14\.\d+\.\d+\.\d+)') { throw 'Cannot determine the signed Visual C++ installer version.' }
                $version = $Matches.version
            }
            [ordered]@{
                Id=$Spec.Family; VariantId=$Spec.Id; DisplayName=$Spec.DisplayName; Cached=$true; Version=$version;
                Architecture=$Spec.Architecture; RuntimeType=$Spec.RuntimeType; Channel=$Spec.Channel;
                VersionSource='Microsoft'; PackageUri=$release.PackageUri; ResolvedUri=$release.ResolvedUri;
                Sha256=(Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash; Sha512=$release.Sha512;
                Size=(Get-Item -LiteralPath $package).Length; ETag=$release.ETag; LastModified=$release.LastModified;
                SourcePolicy='Evergreen'; SyncMethod=$(if ($Spec.Family -eq 'DotNetRuntime') { 'ReleaseSha512' } else { 'HttpMetadataAndPublisher' });
                SyncedAt=(Get-Date).ToUniversalTime().ToString('o')
            } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $candidate 'CacheInfo.json') -Encoding UTF8
        } -Validate { param($candidate) Assert-OSDAppPrerequisiteGeneration -Root $candidate -Spec $Spec -Release $release -VerifyPublisher } | Out-Null
        $info = Assert-OSDAppPrerequisiteGeneration -Root $Root -Spec $Spec -VerifyPublisher
    }
    [pscustomobject]@{ Id=$Spec.Id; Version=$info.Version; Architecture=$Spec.Architecture; Root=$Root; Updated=(-not $current) }
}

function Get-OSDAppInstalledPrerequisiteVersion {
    param([Parameter(Mandatory)]$Spec)
    $versions = @()
    foreach ($view in @([Microsoft.Win32.RegistryView]::Registry32,[Microsoft.Win32.RegistryView]::Registry64)) {
        $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,$view)
        try {
            if ($Spec.Family -eq 'VisualCRedist') {
                $key = $base.OpenSubKey("SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\$($Spec.Architecture)")
                if ($key) {
                    try {
                        $version = [string]$key.GetValue('Version','')
                        if ($key.GetValue('Installed',0) -eq 1 -and $version -match '^v?(14\.\d+\.\d+\.\d+)$') { $versions += [version]$Matches[1] }
                    } finally { $key.Dispose() }
                }
            }
            else {
                $framework = if ($Spec.RuntimeType -eq 'Desktop') { 'Microsoft.WindowsDesktop.App' } else { 'Microsoft.NETCore.App' }
                $key = $base.OpenSubKey("SOFTWARE\dotnet\Setup\InstalledVersions\$($Spec.Architecture)\sharedfx\$framework")
                if ($key) {
                    try { foreach ($name in $key.GetValueNames()) { if ($name -match ('^'+[regex]::Escape($Spec.Channel)+'\.\d+$') -and $key.GetValue($name,0) -eq 1) { $versions += [version]$name } } }
                    finally { $key.Dispose() }
                }
            }
        } finally { $base.Dispose() }
    }
    $latest = $versions | Sort-Object -Descending | Select-Object -First 1
    if ($latest -and $Spec.Family -eq 'DotNetRuntime' -and $Spec.RuntimeType -eq 'Desktop') {
        $coreSpec = Get-OSDAppPrerequisiteSpec -Family DotNetRuntime -Architecture $Spec.Architecture -Channel $Spec.Channel -RuntimeType Runtime
        $core = Get-OSDAppInstalledPrerequisiteVersion -Spec $coreSpec
        if (-not $core) { return $null }
        if ($core -lt $latest) { return $core }
    }
    $latest
}