support/Epp.Packages.ps1

function Get-EppBundle {
    param([string] $AssetDirectory)

    $path = Join-Path $AssetDirectory 'bundle.json'
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        throw 'The Epp.Setup deployment bundle is missing. Install a packaged module, or build it with package-setup.ps1 before importing. No setup assets will be downloaded.'
    }
    $bundle = Read-EppJson $path
    $manifest = Import-PowerShellDataFile (Join-Path $AssetDirectory 'Epp.Setup.psd1')
    if ($bundle['schemaVersion'] -ne 1 -or $bundle['moduleVersion'] -cne $manifest.ModuleVersion -or
        $bundle['sourceRevision'] -cnotmatch '^[0-9a-f]{40}$' -or $bundle['sourceModified'] -isnot [bool] -or
        $bundle['files'] -isnot [Collections.IDictionary]) {
        throw 'Invalid or mismatched Epp.Setup bundle metadata. Reinstall the complete module version.'
    }
    foreach ($required in @('infra/main.json', 'providers/catalog.json', 'packages/catalog.json')) {
        if (-not $bundle.files.Contains($required)) { throw "The module bundle is missing '$required'." }
    }
    foreach ($file in $bundle.files.GetEnumerator()) {
        if ($file.Key -cnotmatch '^(infra|providers|packages)/[A-Za-z0-9][A-Za-z0-9_.-]*$' -or
            $file.Value -cnotmatch '^[0-9a-f]{64}$') {
            throw 'The module bundle contains an invalid asset path or SHA-256.'
        }
        $asset = Join-Path $AssetDirectory $file.Key
        if (-not (Test-Path -LiteralPath $asset -PathType Leaf)) { throw "The module bundle is missing '$($file.Key)'." }
        if ((Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash -ine $file.Value) {
            throw "The bundled asset '$($file.Key)' does not match its SHA-256. Reinstall the complete module version."
        }
    }
    $providers = Read-EppJson (Join-Path $AssetDirectory 'providers\catalog.json')
    $referencedAssets = @($providers.providers | ForEach-Object { "providers/$($_.file)" })
    foreach ($asset in $referencedAssets) {
        if (-not $bundle.files.Contains($asset)) { throw "The module bundle has no checksum for '$asset'." }
    }
    return $bundle
}

function Get-EppLanguage {
    param(
        [string] $AssetDirectory, [string] $Language, [string] $PackageReleaseTag = 'latest',
        [switch] $NonInteractive
    )

    $catalog = Read-EppJson (Join-Path $AssetDirectory 'packages/catalog.json')
    if ($catalog['schemaVersion'] -ne 4 -or @($catalog['packages']).Count -ne 3 -or
        [string]$catalog['repository'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$' -or
        [string]$catalog['releaseTagPrefix'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9.-]*-$') {
        throw 'Unsupported or empty language package catalog.'
    }
    $tagPattern = '^' + [regex]::Escape($catalog['releaseTagPrefix']) + '[0-9]+-[0-9]+$'
    $PackageReleaseTag = ([string]$PackageReleaseTag).Trim()
    if ($PackageReleaseTag -ieq 'latest') { $PackageReleaseTag = 'latest' }
    elseif ($PackageReleaseTag -cnotmatch $tagPattern) {
        throw "Invalid -PackageReleaseTag. Use latest or a tag matching '$($catalog['releaseTagPrefix'])<run>-<attempt>'."
    }
    $strategies = @{ javascript = 'ready'; dotnet = 'dotnet-publish'; python = 'remote-build' }
    $seen = @{}
    $assets = @{}
    $entries = @($catalog['packages'])
    foreach ($entry in $entries) {
        if ($entry -isnot [Collections.IDictionary] -or -not $strategies.ContainsKey([string]$entry['id']) -or
            $seen.ContainsKey($entry['id']) -or $entry['buildStrategy'] -cne $strategies[$entry['id']] -or
            -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]' -or
            [string]$entry['assetName'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$' -or
            $assets.ContainsKey($entry['assetName'])) {
            throw 'Language catalog contains an invalid, unsupported, or duplicate entry.'
        }
        $seen[$entry['id']] = $true
        $assets[$entry['assetName']] = $true
    }
    $entry = Select-EppOption -Entries $entries -Name Language -PromptName Platform `
        -Value $Language -NonInteractive:$NonInteractive
    $assetName = [string]$entry['assetName']
    return [pscustomobject]@{
        Id = $entry['id']; DisplayName = $entry['displayName']; AssetName = $assetName
        Repository = $catalog['repository']; ReleaseTag = $PackageReleaseTag; ReleaseTagPattern = $tagPattern
        BuildStrategy = $entry['buildStrategy']
    }
}

function Get-EppPackageSource {
    param($Selection)

    $releaseBase = "https://github.com/$($Selection.Repository)/releases"
    $tag = $Selection.ReleaseTag
    if ($tag -eq 'latest') {
        # Resolve the public web redirect once, so the ZIP and checksums cannot select different releases.
        $response = Invoke-WebRequest -Uri "$releaseBase/latest" -Method Head -MaximumRedirection 5 -TimeoutSec 60
        $resolved = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
        $match = [regex]::Match($resolved, '^' + [regex]::Escape("$releaseBase/tag/") + '([^/?#]+)$')
        if (-not $match.Success) { throw 'GitHub latest did not resolve to a release in the configured package repository.' }
        $tag = [Uri]::UnescapeDataString($match.Groups[1].Value)
    }
    if ($tag -cnotmatch $Selection.ReleaseTagPattern) {
        throw "GitHub release '$tag' is not a supported EPP package release. Specify a reviewed -PackageReleaseTag."
    }
    return [pscustomobject]@{
        ReleaseTag = $tag
        Url = "$releaseBase/download/$tag/$($Selection.AssetName)"
        ChecksumsUrl = "$releaseBase/download/$tag/SHA256SUMS.txt"
    }
}

function Get-EppPublishedChecksum {
    param([string] $Text, [string] $FileName)

    $hashes = @()
    foreach ($line in ($Text.TrimStart([char]0xfeff) -split '\r?\n')) {
        $match = [regex]::Match($line, '^([0-9a-fA-F]{64})[ \t]+\*?(.+?)[ \t]*$')
        if ($match.Success -and $match.Groups[2].Value -ceq $FileName) {
            $hashes += $match.Groups[1].Value.ToLowerInvariant()
        }
    }
    if ($hashes.Count -ne 1) { throw "The release checksum file must contain exactly one SHA-256 entry for '$FileName'." }
    return $hashes[0]
}

function Assert-EppArchive {
    param(
        [string] $Path,
        [ValidateSet('javascript', 'dotnet', 'python')][string] $Language,
        [ValidateSet('source', 'ready')][string] $Kind
    )

    $archive = [IO.Compression.ZipFile]::OpenRead($Path)
    try {
        $names = @($archive.Entries | ForEach-Object FullName)
        $required = @('host.json')
        switch ($Language) {
            'javascript' { $required += 'package.json' }
            'dotnet' {
                if ($Kind -eq 'source') { $required += 'dotnet.csproj' }
                else { $required += @('worker.config.json', 'functions.metadata') }
            }
            'python' {
                $required += @('function_app.py', 'requirements.txt')
                if ($Kind -eq 'ready') { $required += '.python_packages/lib/site-packages/azure/functions/__init__.py' }
            }
        }
        foreach ($file in $required) {
            if (@($names | Where-Object { $_ -ceq $file }).Count -ne 1) {
                throw "The $Language $Kind ZIP must contain exactly one '$file' at its required deployment path."
            }
        }
        if ($Language -eq 'dotnet' -and $Kind -eq 'ready' -and -not @($names | Where-Object { $_ -cmatch '^[^/]+\.dll$' }).Count) {
            throw 'The .NET publish output contains no application assemblies.'
        }
        if (@($names | Where-Object { $_ -match '\\|(^|/)\.\.?(/|$)|^/|^[a-zA-Z]:' }).Count) {
            throw 'The Function ZIP contains an absolute or traversing archive path.'
        }
        foreach ($entry in $archive.Entries) {
            if ($entry.FullName -match '(?i)(^|/)(local\.settings[^/]*\.json|\.env(?:\.[^/]*)?|[^/]+\.(pfx|p12|pem|key))$') {
                # Python's certifi dependency ships public CA roots, not an application private key.
                if ($Language -eq 'python' -and $Kind -eq 'ready' -and
                    $entry.FullName -ceq '.python_packages/lib/site-packages/certifi/cacert.pem') {
                    $reader = [IO.StreamReader]::new($entry.Open())
                    try {
                        if ($reader.ReadToEnd() -match '-----BEGIN [^-]*PRIVATE KEY-----') { throw 'The CA bundle contains a private key.' }
                    }
                    finally { $reader.Dispose() }
                    continue
                }
                throw 'The Function ZIP contains local settings or key material. Do not deploy this package.'
            }
        }
    }
    finally { $archive.Dispose() }
}

function Invoke-EppDotNet {
    param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments)

    $PSNativeCommandUseErrorActionPreference = $false
    $output = & dotnet @Arguments 2>&1
    if ($LASTEXITCODE -ne 0) { throw "dotnet $($Arguments[0]) failed (exit $LASTEXITCODE):`n$($output -join "`n")" }
    return $output -join "`n"
}

function Build-EppDotNetPackage {
    param([string] $SourcePath, [string] $Directory)

    if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
        throw 'The .NET language needs the .NET 8 SDK on this computer. Install it once; setup performs the build automatically.'
    }
    $versions = @((Invoke-EppDotNet --list-sdks) -split '\r?\n' | ForEach-Object {
        if ($_ -match '^(8\.\d+\.\d+)\s') { [Version]$Matches[1] }
    } | Sort-Object -Descending)
    if (-not $versions.Count) { throw 'Install the .NET 8 SDK before deploying the .NET language. No Azure resources were changed.' }
    $sourceDirectory = Join-Path $Directory 'dotnet-source'
    $publishDirectory = Join-Path $Directory 'dotnet-publish'
    [IO.Compression.ZipFile]::ExtractToDirectory($SourcePath, $sourceDirectory)
    @{ sdk = @{ version = $versions[0].ToString(); rollForward = 'latestPatch' } } |
        ConvertTo-Json | Set-Content -LiteralPath (Join-Path $sourceDirectory 'global.json') -Encoding utf8NoBOM
    Write-Host 'Building .NET 8 for Linux automatically...' -ForegroundColor Cyan
    Push-Location -LiteralPath $sourceDirectory
    try {
        Invoke-EppDotNet -Arguments @('publish', 'dotnet.csproj', '--configuration', 'Release', '--runtime', 'linux-x64',
            '--self-contained', 'false', '-p:UseAppHost=false', '--output', $publishDirectory, '--nologo') | Out-Null
    }
    finally { Pop-Location }
    $path = Join-Path $Directory 'dotnet-ready.zip'
    [IO.Compression.ZipFile]::CreateFromDirectory($publishDirectory, $path)
    Assert-EppArchive -Path $path -Language dotnet -Kind ready
    return $path
}

function Get-EppPackage {
    param($Selection, [string] $Directory)

    $source = Get-EppPackageSource -Selection $Selection
    Write-Host "Downloading $($Selection.DisplayName) from release $($source.ReleaseTag) and verifying its SHA-256..." -ForegroundColor Cyan
    $checksumPath = Join-Path $Directory 'SHA256SUMS.txt'
    Invoke-WebRequest -Uri $source.ChecksumsUrl -OutFile $checksumPath -TimeoutSec 60 -MaximumRedirection 5
    if ((Get-Item -LiteralPath $checksumPath).Length -gt 1MB) { throw 'The release checksum file is unexpectedly large.' }
    $expected = Get-EppPublishedChecksum -Text (Get-Content -LiteralPath $checksumPath -Raw -Encoding utf8) -FileName $Selection.AssetName
    $sourcePath = Join-Path $Directory $Selection.AssetName
    Invoke-WebRequest -Uri $source.Url -OutFile $sourcePath -TimeoutSec 300 -MaximumRedirection 5
    if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash -ine $expected) {
        throw 'The downloaded Function ZIP does not match its published SHA-256. No Azure resources were changed.'
    }
    $kind = if ($Selection.BuildStrategy -eq 'ready') { 'ready' } else { 'source' }
    Assert-EppArchive -Path $sourcePath -Language $Selection.Id -Kind $kind
    $path = if ($Selection.BuildStrategy -eq 'dotnet-publish') { Build-EppDotNetPackage -SourcePath $sourcePath -Directory $Directory } else { $sourcePath }
    return [pscustomobject]@{
        Path = $path
        SourceSha256 = $expected
        Sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
        RequiresRemoteBuild = $Selection.BuildStrategy -eq 'remote-build'
        Url = $source.Url; ReleaseTag = $source.ReleaseTag
    }
}