src/Cache.ps1

# ps12exe 的临时缓存公共逻辑。统一根为 %TEMP%\ps12exe\:更新检查的 txt 直接放根下,各后端的编译缓存放
# cache\<name>\(如 cache\codedom、cache\core)。这里只放与后端无关的东西:缓存根定位、内容哈希、
# 定长占位桶、原子读写,以及托管 PE 内嵌资源的定位与原地补丁。

# 临时根(不创建目录)。
function Get-TempRoot {
    return (Join-Path ([System.IO.Path]::GetTempPath()) 'ps12exe')
}

# 指定后端(codedom/core)的缓存目录,确保存在后返回。
function Get-CacheRoot([string]$Name) {
    $root = Join-Path (Join-Path (Get-TempRoot) 'cache') $Name
    New-Item -ItemType Directory -Force -Path $root | Out-Null
    return $root
}

# 删除长期未用的缓存项(活跃文件刚被 touch,不会命中)。
function Clear-StaleCache([string]$Root, [int]$Days = 14) {
    $cutoff = [DateTime]::UtcNow.AddDays(-$Days)
    Get-ChildItem -LiteralPath $Root -Force -ErrorAction Ignore |
        Where-Object { $_.LastWriteTimeUtc -lt $cutoff } |
        ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Ignore }
}

function Get-Sha256Hex([byte[]]$Bytes) {
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try { return [System.BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '') }
    finally { $sha.Dispose() }
}
function Get-TextHash([string]$Text) {
    Get-Sha256Hex ([System.Text.Encoding]::UTF8.GetBytes($Text))
}
# 找 >= Needed 的定长占位桶(64B 粒度)。占位桶决定帧模板里托管资源槽的大小,进而决定产物大小:
# 粒度太粗会让「实际资源远小于槽」的产物白白多出最多一个粒度的填充。64B 粒度把额外体积压到 <64B,
# 同时模板按内容寻址、不同大小各存一份,条目数上界仍是不同脚本大小的数量级。
# 超过 64MB 返回 0,表示放弃缓存走直编。
function Get-CacheBucket([long]$Needed) {
    if ($Needed -gt 67108864) { return 0 }
    [long]$b = [math]::Ceiling($Needed / 64.0) * 64
    if ($b -lt 64) { $b = 64 }
    return [int]$b
}
# 编译管线源码指纹,作为产物结果缓存的失效键:入口 ps12exe.ps1 + src 顶层脚本 + 程序帧 + 运行时芯,
# 任一内容变化即失效(用 src/*.ps1 通配而非逐文件清单,新增顶层脚本自动纳入)。
# 目录缺失(例如被 ps12exe 自编译成 exe、运行时没有源文件)时返回 $null,调用方据此禁用缓存。
$script:CompilerSourceFingerprint = $null
$script:CompilerSourceFingerprintRepo = $null
function Get-CompilerSourceFingerprint([string]$RepoRoot) {
    if ($script:CompilerSourceFingerprint -and $script:CompilerSourceFingerprintRepo -eq $RepoRoot) {
        return $script:CompilerSourceFingerprint
    }
    $entry = Join-Path $RepoRoot 'ps12exe.ps1'
    if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) { return $null }
    $files = [System.Collections.Generic.List[string]]::new()
    $files.Add($entry)
    foreach ($pattern in @('src/*.ps1', 'src/programFrames/*.cs', 'src/RuntimePwsh2.0/*.ps1')) {
        $dir = Join-Path $RepoRoot (Split-Path -Path $pattern -Parent)
        if (-not (Test-Path -LiteralPath $dir -PathType Container)) { continue }
        Get-ChildItem -LiteralPath $dir -File -Filter (Split-Path -Path $pattern -Leaf) -ErrorAction Ignore |
            ForEach-Object { $files.Add($_.FullName) }
    }
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $sb = [System.Text.StringBuilder]::new()
        foreach ($f in @($files | Sort-Object)) {
            $rel = $f.Substring($RepoRoot.Length).TrimStart('\', '/').Replace('\', '/')
            $h = [System.BitConverter]::ToString($sha.ComputeHash([System.IO.File]::ReadAllBytes($f))).Replace('-', '')
            [void]$sb.Append($rel).Append(':').Append($h).Append("`n")
        }
        $fp = [System.BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sb.ToString()))).Replace('-', '').Substring(0, 32)
        $script:CompilerSourceFingerprint = $fp
        $script:CompilerSourceFingerprintRepo = $RepoRoot
        return $fp
    }
    finally { $sha.Dispose() }
}

function Get-CachedBytes([string]$Path) {
    if (-not (Test-Path -LiteralPath $Path)) { return $null }
    # 触碰 mtime 以躲开 Clear-StaleCache(14 天):只在明显陈旧时才写回,省掉每次命中的元数据写入。
    $item = Get-Item -LiteralPath $Path
    if ($item.LastWriteTimeUtc -lt [DateTime]::UtcNow.AddHours(-1)) { $item.LastWriteTimeUtc = [DateTime]::UtcNow }
    # 前置逗号阻止 PowerShell 展开字节数组(否则会被拆成 Object[],原地修改会丢在副本上)。
    return , ([System.IO.File]::ReadAllBytes($Path))
}
function Set-CachedBytes([string]$Path, [byte[]]$Bytes) {
    # 先写临时文件再原子替换,避免并发编译读到半截缓存。
    $tmp = "$Path." + [Guid]::NewGuid().ToString('N') + '.tmp'
    [System.IO.File]::WriteAllBytes($tmp, $Bytes)
    Move-Item -LiteralPath $tmp -Destination $Path -Force
}
function Convert-RvaToOffset([byte[]]$Image, [int]$SectionCount, [int]$SectionsBase, [uint32]$Rva) {
    for ($sectionIndex = 0; $sectionIndex -lt $SectionCount; $sectionIndex++) {
        $sectionOffset = $SectionsBase + $sectionIndex * 40
        $va = [System.BitConverter]::ToUInt32($Image, $sectionOffset + 12)
        $vs = [System.BitConverter]::ToUInt32($Image, $sectionOffset + 8)
        $raw = [System.BitConverter]::ToUInt32($Image, $sectionOffset + 16)
        $ptr = [System.BitConverter]::ToUInt32($Image, $sectionOffset + 20)
        if ($Rva -ge $va -and $Rva -lt ($va + [math]::Max($vs, $raw))) { return [int]($ptr + ($Rva - $va)) }
    }
    return -1
}
# 定位托管程序集里那份唯一内嵌资源:[Int32 长度][数据] 的长度前缀文件偏移,以及 CLI 头(其 +28 是资源目录大小)。
function Get-FrameLayout([byte[]]$Image) {
    $pe = [System.BitConverter]::ToInt32($Image, 0x3c)
    if ($pe -le 0 -or $pe + 24 -gt $Image.Length) { throw '模板不是 PE' }
    $sectionCount = [System.BitConverter]::ToUInt16($Image, $pe + 6)
    $optSize = [System.BitConverter]::ToUInt16($Image, $pe + 20)
    $magic = [System.BitConverter]::ToUInt16($Image, $pe + 24)
    $ddOff = if ($magic -eq 0x20b) { $pe + 24 + 112 } else { $pe + 24 + 96 }
    $sectionsBase = $pe + 24 + $optSize
    $clrRva = [System.BitConverter]::ToUInt32($Image, $ddOff + 14 * 8)
    $cliOffset = Convert-RvaToOffset $Image $sectionCount $sectionsBase $clrRva
    if ($cliOffset -lt 0) { throw '模板缺少 CLI 头' }
    $resRva = [System.BitConverter]::ToUInt32($Image, $cliOffset + 24)
    $resOffset = Convert-RvaToOffset $Image $sectionCount $sectionsBase $resRva
    if ($resOffset -lt 0) { throw '模板缺少托管资源' }
    return [pscustomobject]@{ CliOffset = $cliOffset; ResourceOffset = $resOffset }
}
# 把 [Int32 长度][数据] 原地写进托管 PE 的内嵌资源(数据不超过占位桶),并同步 CLI 资源目录大小;不重排 PE。
function Set-FrameResource([byte[]]$Template, [byte[]]$Data) {
    $layout = Get-FrameLayout $Template
    $capacity = [System.BitConverter]::ToInt32($Template, $layout.ResourceOffset)
    if ($Data.Length -gt $capacity) { throw "帧模板占位不足($($Data.Length) > $capacity)" }
    [System.Array]::Copy([System.BitConverter]::GetBytes([int]$Data.Length), 0, $Template, $layout.ResourceOffset, 4)
    [System.Array]::Copy($Data, 0, $Template, $layout.ResourceOffset + 4, $Data.Length)
    [System.Array]::Copy([System.BitConverter]::GetBytes([int](4 + $Data.Length)), 0, $Template, $layout.CliOffset + 28, 4)
    # 前置逗号阻止 PowerShell 展开字节数组(否则被拆成 Object[],大脚本会因逐字节装箱显著变慢)。
    return , $Template
}

# 仅用于本项目生成、无 PDB/签名的 CodeDom 镜像。csc 的随机 MVID 与 PE 时间戳
# 会让冷缓存/不同机器的同一帧产生不同负载;归零后由完整内容派生新的 MVID。
# 不改变表、资源、程序集名称或布局;调试产物在调用方跳过,以保留 PDB 身份。
function Set-DeterministicPeIdentity([byte[]]$Image) {
    $pe = [BitConverter]::ToInt32($Image, 0x3c)
    if ($pe -lt 0 -or $pe + 24 -gt $Image.Length -or [BitConverter]::ToUInt32($Image, $pe) -ne 0x4550) { throw 'Invalid generated PE image' }
    $sectionCount = [BitConverter]::ToUInt16($Image, $pe + 6)
    $optSize = [BitConverter]::ToUInt16($Image, $pe + 20)
    $optional = $pe + 24
    $magic = [BitConverter]::ToUInt16($Image, $optional)
    $dd = if ($magic -eq 0x20b) { $optional + 112 } elseif ($magic -eq 0x10b) { $optional + 96 } else { throw 'Unsupported generated PE format' }
    $sections = $optional + $optSize
    $cli = Convert-RvaToOffset $Image $sectionCount $sections ([BitConverter]::ToUInt32($Image, $dd + 112))
    if ($cli -lt 0) { throw 'Generated PE has no CLI header' }
    $metadata = Convert-RvaToOffset $Image $sectionCount $sections ([BitConverter]::ToUInt32($Image, $cli + 8))
    if ($metadata -lt 0 -or [BitConverter]::ToUInt32($Image, $metadata) -ne 0x424a5342) { throw 'Invalid generated CLI metadata' }
    $streams = $metadata + 16 + [BitConverter]::ToUInt32($Image, $metadata + 12)
    $streams = [int](($streams + 3) -band -4)
    $count = [BitConverter]::ToUInt16($Image, $streams + 2)
    $cursor = $streams + 4
    $guidOffset = -1
    for ($i = 0; $i -lt $count; $i++) {
        $offset = [BitConverter]::ToUInt32($Image, $cursor)
        $size = [BitConverter]::ToUInt32($Image, $cursor + 4)
        $nameStart = $cursor + 8
        $end = $nameStart
        while ($end -lt $Image.Length -and $Image[$end] -ne 0) { $end++ }
        $name = [Text.Encoding]::ASCII.GetString($Image, $nameStart, $end - $nameStart)
        if ($name -eq '#GUID') {
            # 非增量 csc 产物只有 Module.Mvid 一个 GUID;不要改写其他 GUID/EnC 身份。
            if ($size -ne 16) { throw 'Unexpected GUID heap in generated CodeDom image' }
            $guidOffset = $metadata + $offset
        }
        $cursor = [int](($end + 4) -band -4)
    }
    if ($guidOffset -lt 0) { throw 'Generated CodeDom image has no MVID' }
    [Array]::Clear($Image, $guidOffset, 16)
    [Array]::Clear($Image, $pe + 8, 4) # COFF TimeDateStamp
    [Array]::Clear($Image, $optional + 64, 4) # checksum, recalculated by signing if requested
    # Native resource directories and DLL export headers also have timestamps.
    $resource = Convert-RvaToOffset $Image $sectionCount $sections ([BitConverter]::ToUInt32($Image, $dd + 16))
    if ($resource -ge 0) {
        $pending = [Collections.Generic.Stack[int]]::new()
        $pending.Push(0)
        while ($pending.Count) {
            $directory = $resource + $pending.Pop()
            [Array]::Clear($Image, $directory + 4, 4)
            $entries = [int][BitConverter]::ToUInt16($Image, $directory + 12) + [int][BitConverter]::ToUInt16($Image, $directory + 14)
            for ($entry = 0; $entry -lt $entries; $entry++) {
                $child = [BitConverter]::ToUInt32($Image, $directory + 20 + 8 * $entry)
                if ($child -band 0x80000000L) { $pending.Push([int]($child -band 0x7fffffff)) }
            }
        }
    }
    $export = Convert-RvaToOffset $Image $sectionCount $sections ([BitConverter]::ToUInt32($Image, $dd))
    if ($export -ge 0) { [Array]::Clear($Image, $export + 4, 4) }
    $sha = [Security.Cryptography.SHA256]::Create()
    try { $hash = $sha.ComputeHash($Image); [Array]::Copy($hash, 0, $Image, $guidOffset, 16) }
    finally { $sha.Dispose() }
    return , $Image
}