Private/Get-CachedOutput.ps1

function ConvertTo-CachedOutputResult {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][bool]$Available,
        [Parameter()][AllowNull()][object]$CacheFile,
        [Parameter()][AllowEmptyString()][string]$Content = '',
        [Parameter()][AllowNull()][object]$LastWriteTime = $null
    )

    return [pscustomobject]@{
        Available     = $Available
        CacheFile     = $CacheFile
        Content       = $Content
        LastWriteTime = $LastWriteTime
    }
}

function ConvertTo-CachedOutputValidationResult {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][bool]$Success,
        [Parameter()][AllowNull()][object]$Value = $null
    )

    return [pscustomobject]@{
        Success = $Success
        Value   = $Value
    }
}

function Test-CachedOutputScriptFile {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$ScriptPath
    )

    try {
        return [bool](& $script:FileExistsDelegate $ScriptPath)
    }
    catch {
        Write-Verbose "Unable to verify script existence for ${ScriptPath}: $($_.Exception.Message)"
        return $false
    }
}

function Get-CachedOutputInitialSnapshot {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ScriptPath,
        [Parameter(Mandatory)][string]$CacheFile,
        [Parameter(Mandatory)][string]$MetadataPath
    )

    if (-not (Test-Path -LiteralPath $CacheFile) -or
        -not (Test-Path -LiteralPath $MetadataPath -PathType Leaf)) {
        Remove-CachedOutputMemoryEntry -CacheFile $CacheFile
        return $null
    }

    $snapshot = Get-CachedOutputFileSnapshot -ScriptPath $ScriptPath -CacheFile $CacheFile -MetadataPath $MetadataPath
    if (-not $snapshot) {
        Remove-CachedOutputMemoryEntry -CacheFile $CacheFile
        return $null
    }

    return $snapshot
}

function Resolve-CachedOutputMemoryResult {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ScriptPath,
        [Parameter(Mandatory)][string]$CacheFile,
        [Parameter(Mandatory)][string]$MetadataPath,
        [Parameter(Mandatory)][object]$Snapshot,
        [switch]$MetadataOnly
    )

    $memoryEntry = Get-CachedOutputMemoryEntry -CacheFile $CacheFile
    if (-not $memoryEntry) {
        return [pscustomobject]@{ Resolved = $false; Result = $null }
    }

    if (-not (Test-CachedOutputMemoryEntryCurrent -Entry $memoryEntry -Snapshot $Snapshot)) {
        Remove-CachedOutputMemoryEntry -CacheFile $CacheFile
        return [pscustomobject]@{ Resolved = $false; Result = $null }
    }

    if ($MetadataOnly) {
        $result = ConvertTo-CachedOutputResult -Available $true -CacheFile $CacheFile -LastWriteTime $Snapshot.CacheInfo.LastWriteTimeUtc
        return [pscustomobject]@{ Resolved = $true; Result = $result }
    }

    if ($memoryEntry.ContentLoaded) {
        $result = ConvertTo-CachedOutputResult -Available $true -CacheFile $CacheFile -Content ([string]$memoryEntry.Content) -LastWriteTime $Snapshot.CacheInfo.LastWriteTimeUtc
        return [pscustomobject]@{ Resolved = $true; Result = $result }
    }

    $content = & $script:FileReadAllTextDelegate $CacheFile $script:Utf8NoBomEncoding
    Set-CachedOutputMemoryEntry -CacheFile $CacheFile -ScriptPath $ScriptPath -MetadataPath $MetadataPath -ScriptInfo $Snapshot.ScriptInfo -CacheInfo $Snapshot.CacheInfo -MetadataInfo $Snapshot.MetadataInfo -ContentLoaded $true -Content $content
    $result = ConvertTo-CachedOutputResult -Available $true -CacheFile $CacheFile -Content $content -LastWriteTime $Snapshot.CacheInfo.LastWriteTimeUtc
    return [pscustomobject]@{ Resolved = $true; Result = $result }
}

function Read-CachedOutputMetadataFile {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ScriptPath,
        [Parameter(Mandatory)][string]$MetadataPath
    )

    try {
        $metadataContent = & $script:FileReadAllTextDelegate $MetadataPath $script:Utf8NoBomEncoding
        if ([string]::IsNullOrWhiteSpace($metadataContent)) {
            return $null
        }

        return $metadataContent | ConvertFrom-Json
    }
    catch {
        Write-Verbose ("Cache metadata read error for {0}: {1}" -f $ScriptPath, $_.Exception.Message)
        return $null
    }
}

function ConvertTo-CachedOutputMetadataHeader {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][object]$Metadata
    )

    $metadataVersion = if ($Metadata.PSObject.Properties['Version']) { [int]$Metadata.Version } else { 0 }
    if ($metadataVersion -ne $script:CacheEntryMetadataVersion) {
        return ConvertTo-CachedOutputValidationResult -Success $false
    }

    $metadataLength = if ($Metadata.PSObject.Properties['ScriptLength']) { [long]$Metadata.ScriptLength } else { $null }
    if ($null -eq $metadataLength) {
        return ConvertTo-CachedOutputValidationResult -Success $false
    }

    return ConvertTo-CachedOutputValidationResult -Success $true -Value $metadataLength
}

function ConvertTo-CachedOutputMetadataLastWriteTime {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory)][object]$Metadata
    )

    if (-not $Metadata.PSObject.Properties['ScriptLastWriteTimeUtc'] -or -not $Metadata.ScriptLastWriteTimeUtc) {
        return $null
    }

    try {
        $lastWriteTime = [System.DateTime]::ParseExact(
            [string]$Metadata.ScriptLastWriteTimeUtc,
            'o',
            [System.Globalization.CultureInfo]::InvariantCulture,
            [System.Globalization.DateTimeStyles]::AdjustToUniversal)
        return $lastWriteTime.ToUniversalTime()
    }
    catch {
        return $null
    }
}

function Get-CachedOutputMetadataHashAlgorithm {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][object]$Metadata
    )

    if ($Metadata.PSObject.Properties['ScriptHashAlgorithm'] -and $Metadata.ScriptHashAlgorithm) {
        return [string]$Metadata.ScriptHashAlgorithm
    }

    if ($script:CacheEntryHashAlgorithm) {
        return $script:CacheEntryHashAlgorithm
    }

    return 'SHA256'
}

function Get-CachedOutputCacheLastWriteTime {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory)][string]$CacheFile
    )

    try {
        $lastWriteTime = & $script:FileGetLastWriteTimeUtcDelegate $CacheFile
        if ($lastWriteTime) {
            return $lastWriteTime
        }
    }
    catch {
        Write-ModuleTrace ("Cache timestamp delegate failed for '{0}': {1}" -f $CacheFile, $_.Exception.Message)
    }

    try {
        return [System.IO.File]::GetLastWriteTimeUtc($CacheFile)
    }
    catch {
        try {
            return [System.IO.File]::GetLastWriteTime($CacheFile)
        }
        catch {
            return $null
        }
    }
}

function Test-CachedOutputScriptSignature {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$ScriptPath,
        [Parameter(Mandatory)][string]$ScriptName,
        [Parameter(Mandatory)][string]$CacheFile,
        [Parameter(Mandatory)][long]$MetadataLength,
        [Parameter()][AllowNull()][object]$MetadataLastWriteTime,
        [Parameter()][AllowNull()][AllowEmptyString()][string]$MetadataHash,
        [Parameter()][AllowNull()][AllowEmptyString()][string]$CacheGeneratedUtc,
        [Parameter()][AllowNull()][AllowEmptyString()][string]$StoredModuleVersion,
        [Parameter(Mandatory)][datetime]$ScriptLastWriteTimeUtc
    )

    if ($MetadataLastWriteTime -and $ScriptLastWriteTimeUtc -eq $MetadataLastWriteTime) {
        return $true
    }

    if ([string]::IsNullOrWhiteSpace($MetadataHash)) {
        return $false
    }

    try {
        $computedSignature = Get-FileContentSignature -Path $ScriptPath -IncludeHash
    }
    catch {
        Write-Verbose ("Failed to compute file signature for {0}: {1}" -f $ScriptPath, $_.Exception.Message)
        return $false
    }

    if (-not $computedSignature -or
        [long]$computedSignature.Length -ne $MetadataLength -or
        [string]::IsNullOrWhiteSpace($computedSignature.Hash) -or
        $MetadataHash.ToLowerInvariant() -ne $computedSignature.Hash.ToLowerInvariant()) {
        return $false
    }

    try {
        Write-CacheEntryMetadataFile -ScriptName $ScriptName -Signature $computedSignature -CacheFile $CacheFile -CacheGeneratedUtc $CacheGeneratedUtc -ModuleVersionOverride $StoredModuleVersion
    }
    catch {
        Write-Verbose ("Cache metadata refresh failed for {0}: {1}" -f $ScriptPath, $_.Exception.Message)
    }

    return $true
}

function Repair-CachedOutputLegacyTimestamp {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory)][string]$CacheFile,
        [Parameter()][AllowNull()][object]$CacheLastWriteTime,
        [Parameter(Mandatory)][datetime]$ScriptLastWriteTimeUtc
    )

    if (-not $CacheLastWriteTime -or $CacheLastWriteTime -ne $ScriptLastWriteTimeUtc) {
        return $CacheLastWriteTime
    }

    # Older versions backdated cache files to the source timestamp. Touch that legacy stamp
    # once so external cleanup tools do not remove a valid payload but leave its sidecar behind.
    try {
        $nowUtc = (Get-Date).ToUniversalTime()
        try {
            $null = Set-FileLastWriteTimeUtc -Path $CacheFile -Timestamp $nowUtc
            return $nowUtc
        }
        catch {
            try {
                $nowLocal = Get-Date
                $null = Set-FileLastWriteTime -Path $CacheFile -Timestamp $nowLocal
                return $nowLocal.ToUniversalTime()
            }
            catch {
                Write-Verbose ("Failed to touch cache file timestamp for {0}: {1}" -f $CacheFile, $_.Exception.Message)
                return $CacheLastWriteTime
            }
        }
    }
    catch {
        Write-Verbose ("Cache timestamp migration check failed for {0}: {1}" -f $CacheFile, $_.Exception.Message)
        return $CacheLastWriteTime
    }
}

function Get-ValidatedCachedOutput {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ScriptPath,
        [Parameter(Mandatory)][string]$ScriptName,
        [Parameter(Mandatory)][string]$CacheFile,
        [Parameter(Mandatory)][string]$MetadataPath,
        [switch]$MetadataOnly
    )

    $metadata = Read-CachedOutputMetadataFile -ScriptPath $ScriptPath -MetadataPath $MetadataPath
    if (-not $metadata) {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $CacheFile
    }

    $header = ConvertTo-CachedOutputMetadataHeader -Metadata $metadata
    if (-not $header.Success) {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $CacheFile
    }

    $metadataLength = [long]$header.Value
    $scriptInfo = Get-Item -LiteralPath $ScriptPath -ErrorAction Stop
    $cacheLastWriteTime = Get-CachedOutputCacheLastWriteTime -CacheFile $CacheFile
    if ([long]$scriptInfo.Length -ne $metadataLength) {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $CacheFile -LastWriteTime $cacheLastWriteTime
    }

    $metadataHashAlgorithm = Get-CachedOutputMetadataHashAlgorithm -Metadata $metadata
    if ($metadataHashAlgorithm.ToUpperInvariant() -ne 'SHA256') {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $CacheFile -LastWriteTime $cacheLastWriteTime
    }

    $metadataLastWriteTime = ConvertTo-CachedOutputMetadataLastWriteTime -Metadata $metadata
    $metadataHash = if ($metadata.PSObject.Properties['ScriptHash']) { [string]$metadata.ScriptHash } else { $null }
    $cacheGeneratedUtc = if ($metadata.PSObject.Properties['CacheGeneratedUtc']) { [string]$metadata.CacheGeneratedUtc } else { $null }
    $storedModuleVersion = if ($metadata.PSObject.Properties['ModuleVersion']) { [string]$metadata.ModuleVersion } else { $null }
    $signatureMatches = Test-CachedOutputScriptSignature -ScriptPath $ScriptPath -ScriptName $ScriptName -CacheFile $CacheFile -MetadataLength $metadataLength -MetadataLastWriteTime $metadataLastWriteTime -MetadataHash $metadataHash -CacheGeneratedUtc $cacheGeneratedUtc -StoredModuleVersion $storedModuleVersion -ScriptLastWriteTimeUtc $scriptInfo.LastWriteTimeUtc
    if (-not $signatureMatches) {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $CacheFile -LastWriteTime $cacheLastWriteTime
    }

    $cacheLastWriteTime = Repair-CachedOutputLegacyTimestamp -CacheFile $CacheFile -CacheLastWriteTime $cacheLastWriteTime -ScriptLastWriteTimeUtc $scriptInfo.LastWriteTimeUtc
    $content = if ($MetadataOnly) { '' } else { & $script:FileReadAllTextDelegate $CacheFile $script:Utf8NoBomEncoding }
    $validatedSnapshot = Get-CachedOutputFileSnapshot -ScriptPath $ScriptPath -CacheFile $CacheFile -MetadataPath $MetadataPath
    if ($validatedSnapshot) {
        Set-CachedOutputMemoryEntry -CacheFile $CacheFile -ScriptPath $ScriptPath -MetadataPath $MetadataPath -ScriptInfo $validatedSnapshot.ScriptInfo -CacheInfo $validatedSnapshot.CacheInfo -MetadataInfo $validatedSnapshot.MetadataInfo -ContentLoaded (-not $MetadataOnly.IsPresent) -Content $content
    }

    return ConvertTo-CachedOutputResult -Available $true -CacheFile $CacheFile -Content $content -LastWriteTime $cacheLastWriteTime
}

function Get-CachedOutput {
    <#
    .SYNOPSIS
        Retrieves cached output for a colorscript if available and valid.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string]$ScriptPath,

        [switch]$MetadataOnly
    )

    if ([string]::IsNullOrWhiteSpace($ScriptPath)) {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $null
    }

    if (-not $script:CacheDir) {
        try {
            Initialize-CacheDirectory
        }
        catch {
            Write-Verbose "Initialize-CacheDirectory failed: $($_.Exception.Message)"
        }
    }

    if (-not $script:CacheDir) {
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $null
    }

    $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($ScriptPath)
    $cacheFile = Join-Path -Path $script:CacheDir -ChildPath ("{0}.cache" -f $scriptName)
    $metadataPath = Get-CacheEntryMetadataPath -ScriptName $scriptName

    try {
        if (-not (Test-CachedOutputScriptFile -ScriptPath $ScriptPath)) {
            return ConvertTo-CachedOutputResult -Available $false -CacheFile $null
        }

        if (-not $metadataPath) {
            Remove-CachedOutputMemoryEntry -CacheFile $cacheFile
            return ConvertTo-CachedOutputResult -Available $false -CacheFile $cacheFile
        }

        $snapshot = Get-CachedOutputInitialSnapshot -ScriptPath $ScriptPath -CacheFile $cacheFile -MetadataPath $metadataPath
        if (-not $snapshot) {
            return ConvertTo-CachedOutputResult -Available $false -CacheFile $cacheFile
        }

        $memoryResult = Resolve-CachedOutputMemoryResult -ScriptPath $ScriptPath -CacheFile $cacheFile -MetadataPath $metadataPath -Snapshot $snapshot -MetadataOnly:$MetadataOnly
        if ($memoryResult.Resolved) {
            return $memoryResult.Result
        }

        return Get-ValidatedCachedOutput -ScriptPath $ScriptPath -ScriptName $scriptName -CacheFile $cacheFile -MetadataPath $metadataPath -MetadataOnly:$MetadataOnly
    }
    catch {
        Write-Verbose "Cache read error for $ScriptPath : $($_.Exception.Message)"
        return ConvertTo-CachedOutputResult -Available $false -CacheFile $cacheFile
    }
}