DriveFSCacheCorrelator.ps1


<#PSScriptInfo
 
.VERSION 1.0.1
 
.GUID c0791fc5-9c4b-467d-afb6-9f3a3b280211
 
.AUTHOR Nebian
 
.COMPANYNAME
 
.COPYRIGHT
 
.TAGS forensics dfir sqlite google-drive powershell drivefs-cache
 
.LICENSEURI https://github.com/Nebian/DriveFS-Cache-Correlator/blob/main/LICENSE
 
.PROJECTURI https://github.com/Nebian/DriveFS-Cache-Correlator
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
 
 
.PRIVATEDATA
 
#>


<#
 
.DESCRIPTION
 Correlates Google Drive for desktop content_cache IDs to original file metadata by manually parsing the SQLite DB and WAL.
 
#>
 

Param(
    [string]$Path,
    [UInt64]$Filename
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Show-Usage {
    Write-Output ""
    $scriptName = Split-Path -Leaf $PSCommandPath
    Write-Output (" Usage: .\{0} -Path <sqlite_db_path> -Filename <cache_id>" -f $scriptName)
    Write-Output ""
}

function Write-VerticalResults {
    param([object[]]$InputObject)

    if (-not $InputObject) {
        return
    }

    $index = 0
    foreach ($row in $InputObject) {
        $index++
        Write-Output ("[{0}]" -f $index)
        foreach ($prop in $row.PSObject.Properties) {
            Write-Output ("{0}: {1}" -f $prop.Name, $prop.Value)
        }
        Write-Output ""
    }
}

function Read-At {
    param(
        [System.IO.FileStream]$Stream,
        [long]$Offset,
        [int]$Count
    )

    $buffer = New-Object byte[] $Count
    [void]$Stream.Seek($Offset, [System.IO.SeekOrigin]::Begin)

    $read = 0
    while ($read -lt $Count) {
        $n = $Stream.Read($buffer, $read, $Count - $read)
        if ($n -le 0) {
            throw "Unexpected EOF at offset $Offset"
        }
        $read += $n
    }

    return , $buffer
}

function Get-Slice {
    param(
        [byte[]]$Data,
        [int]$Offset,
        [int]$Length
    )

    $slice = New-Object byte[] $Length
    [System.Buffer]::BlockCopy($Data, $Offset, $slice, 0, $Length)
    return , $slice
}

function ConvertTo-ByteArray {
    param([object]$Value)

    if ($null -eq $Value) {
        return $null
    }

    if ($Value -is [byte[]]) {
        return , $Value
    }

    if ($Value -is [System.Array]) {
        try {
            return , ([byte[]]$Value)
        }
        catch {
            return $null
        }
    }

    return $null
}

function Read-BEUInt16 {
    param(
        [byte[]]$Bytes,
        [int]$Offset
    )
    return (([int]$Bytes[$Offset] -shl 8) -bor [int]$Bytes[$Offset + 1])
}

function Read-BEUInt32 {
    param(
        [byte[]]$Bytes,
        [int]$Offset
    )
    return (
        (([uint32]$Bytes[$Offset] -shl 24) -bor
        ([uint32]$Bytes[$Offset + 1] -shl 16) -bor
        ([uint32]$Bytes[$Offset + 2] -shl 8) -bor
        ([uint32]$Bytes[$Offset + 3]))
    )
}

function Read-LEUInt32 {
    param(
        [byte[]]$Bytes,
        [int]$Offset
    )
    return (
        ([uint32]$Bytes[$Offset] -bor
        ([uint32]$Bytes[$Offset + 1] -shl 8) -bor
        ([uint32]$Bytes[$Offset + 2] -shl 16) -bor
        ([uint32]$Bytes[$Offset + 3] -shl 24))
    )
}

function Read-WalChecksumUInt32 {
    param(
        [byte[]]$Bytes,
        [int]$Offset,
        [bool]$ChecksumBigEndian
    )

    if ($ChecksumBigEndian) {
        return [uint32](Read-BEUInt32 -Bytes $Bytes -Offset $Offset)
    }

    return [uint32](Read-LEUInt32 -Bytes $Bytes -Offset $Offset)
}

function Add-UInt32Wrap {
    param(
        [UInt64[]]$Values
    )

    [UInt64]$sum = 0
    foreach ($value in $Values) {
        $sum += [UInt64]$value
    }

    return [uint32]($sum % 0x100000000)
}

function Update-WalChecksum {
    param(
        [byte[]]$Bytes,
        [uint32]$S0 = 0,
        [uint32]$S1 = 0,
        [bool]$ChecksumBigEndian
    )

    if (($Bytes.Length % 8) -ne 0) {
        throw "WAL checksum input length must be a multiple of 8 bytes"
    }

    $sum0 = [uint32]$S0
    $sum1 = [uint32]$S1

    for ($i = 0; $i -lt $Bytes.Length; $i += 8) {
        $x0 = [uint32](Read-WalChecksumUInt32 -Bytes $Bytes -Offset $i -ChecksumBigEndian:$ChecksumBigEndian)
        $x1 = [uint32](Read-WalChecksumUInt32 -Bytes $Bytes -Offset ($i + 4) -ChecksumBigEndian:$ChecksumBigEndian)

        $sum0 = Add-UInt32Wrap -Values @([UInt64]$sum0, [UInt64]$x0, [UInt64]$sum1)
        $sum1 = Add-UInt32Wrap -Values @([UInt64]$sum1, [UInt64]$x1, [UInt64]$sum0)
    }

    return [pscustomobject]@{
        S0 = $sum0
        S1 = $sum1
    }
}

function Initialize-WalOverlay {
    param([string]$DatabasePath)

    $walPath = "$DatabasePath-wal"
    if (-not (Test-Path -LiteralPath $walPath -PathType Leaf)) {
        return
    }

    $walFs = [System.IO.File]::Open(
        $walPath,
        [System.IO.FileMode]::Open,
        [System.IO.FileAccess]::Read,
        [System.IO.FileShare]::ReadWrite
    )

    try {
        if ($walFs.Length -lt 32) {
            return
        }

        $walHeader = Read-At -Stream $walFs -Offset 0 -Count 32
        $magic = [uint32](Read-BEUInt32 -Bytes $walHeader -Offset 0)

        if ($magic -ne 0x377F0682 -and $magic -ne 0x377F0683) {
            return
        }

        $checksumBigEndian = ($magic -eq 0x377F0683)
        $walPageSize = [int](Read-BEUInt32 -Bytes $walHeader -Offset 8)
        if ($walPageSize -le 0) {
            return
        }

        if ($walPageSize -ne $script:PageSize) {
            return
        }

        $salt1 = [uint32](Read-BEUInt32 -Bytes $walHeader -Offset 16)
        $salt2 = [uint32](Read-BEUInt32 -Bytes $walHeader -Offset 20)
        $headerChecksum1 = [uint32](Read-BEUInt32 -Bytes $walHeader -Offset 24)
        $headerChecksum2 = [uint32](Read-BEUInt32 -Bytes $walHeader -Offset 28)

        $checksumState = Update-WalChecksum -Bytes (Get-Slice -Data $walHeader -Offset 0 -Length 24) -ChecksumBigEndian:$checksumBigEndian
        if ($checksumState.S0 -ne $headerChecksum1 -or $checksumState.S1 -ne $headerChecksum2) {
            return
        }

        $frameSize = 24 + $walPageSize
        if ($frameSize -le 24) {
            return
        }

        $frames = New-Object System.Collections.Generic.List[object]
        $lastCommitFrameIndex = 0
        $frameIndex = 0
        $frameOffset = 32

        while (($frameOffset + $frameSize) -le $walFs.Length) {
            $frameIndex++

            $frameHeader = Read-At -Stream $walFs -Offset $frameOffset -Count 24
            $pageData = Read-At -Stream $walFs -Offset ($frameOffset + 24) -Count $walPageSize

            $pageNumber = [int](Read-BEUInt32 -Bytes $frameHeader -Offset 0)
            $databaseSizeAfterCommit = [uint32](Read-BEUInt32 -Bytes $frameHeader -Offset 4)
            $frameSalt1 = [uint32](Read-BEUInt32 -Bytes $frameHeader -Offset 8)
            $frameSalt2 = [uint32](Read-BEUInt32 -Bytes $frameHeader -Offset 12)
            $frameChecksum1 = [uint32](Read-BEUInt32 -Bytes $frameHeader -Offset 16)
            $frameChecksum2 = [uint32](Read-BEUInt32 -Bytes $frameHeader -Offset 20)

            if ($frameSalt1 -ne $salt1 -or $frameSalt2 -ne $salt2) {
                break
            }

            $checksumState = Update-WalChecksum -Bytes (Get-Slice -Data $frameHeader -Offset 0 -Length 8) -S0 $checksumState.S0 -S1 $checksumState.S1 -ChecksumBigEndian:$checksumBigEndian
            $checksumState = Update-WalChecksum -Bytes $pageData -S0 $checksumState.S0 -S1 $checksumState.S1 -ChecksumBigEndian:$checksumBigEndian

            if ($checksumState.S0 -ne $frameChecksum1 -or $checksumState.S1 -ne $frameChecksum2) {
                break
            }

            $frames.Add([pscustomobject]@{
                    FrameIndex = $frameIndex
                    PageNumber = $pageNumber
                    PageData   = [byte[]]$pageData
                    DbSize     = $databaseSizeAfterCommit
                })

            if ($databaseSizeAfterCommit -ne 0) {
                $lastCommitFrameIndex = $frameIndex
            }

            $frameOffset += $frameSize
        }

        if ($lastCommitFrameIndex -le 0) {
            return
        }

        foreach ($frame in $frames) {
            if ($frame.FrameIndex -gt $lastCommitFrameIndex) {
                break
            }

            $script:WalPageCache[[int]$frame.PageNumber] = [byte[]]$frame.PageData
        }
    }
    finally {
        $walFs.Dispose()
    }
}

function Read-SignedBE {
    param(
        [byte[]]$Bytes,
        [int]$Offset,
        [int]$Length
    )

    if ($Length -le 0) { return 0 }

    $tmp = Get-Slice -Data $Bytes -Offset $Offset -Length $Length

    switch ($Length) {
        1 { return [sbyte]$tmp[0] }
        2 {
            if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($tmp) }
            return [BitConverter]::ToInt16($tmp, 0)
        }
        3 {
            $sign = if (($tmp[0] -band 0x80) -ne 0) { 0xFF } else { 0x00 }
            $full = [byte[]]@($sign) + $tmp
            if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($full) }
            return [BitConverter]::ToInt32($full, 0)
        }
        4 {
            if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($tmp) }
            return [BitConverter]::ToInt32($tmp, 0)
        }
        6 {
            $sign = if (($tmp[0] -band 0x80) -ne 0) { 0xFF } else { 0x00 }
            $full = [byte[]]@($sign, $sign) + $tmp
            if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($full) }
            return [BitConverter]::ToInt64($full, 0)
        }
        8 {
            if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($tmp) }
            return [BitConverter]::ToInt64($tmp, 0)
        }
        default {
            throw "Unsupported integer length: $Length"
        }
    }
}

function Read-Varint {
    param(
        [byte[]]$Bytes,
        [ref]$Offset
    )

    [UInt64]$value = 0

    for ($i = 0; $i -lt 8; $i++) {
        [UInt64]$b = [UInt64]$Bytes[$Offset.Value]
        $Offset.Value++

        if ($b -lt 0x80) {
            $value = ($value -shl 7) -bor $b
            return $value
        }

        $value = ($value -shl 7) -bor ($b -band 0x7F)
    }

    [UInt64]$b9 = [UInt64]$Bytes[$Offset.Value]
    $Offset.Value++
    $value = ($value -shl 8) -bor $b9
    return $value
}

function Get-Page {
    param([int]$PageNumber)

    if ($script:WalPageCache.ContainsKey($PageNumber)) {
        return , $script:WalPageCache[$PageNumber]
    }

    if ($script:PageCache.ContainsKey($PageNumber)) {
        return , $script:PageCache[$PageNumber]
    }

    $offset = [long](($PageNumber - 1) * $script:PageSize)
    $page = Read-At -Stream $script:Fs -Offset $offset -Count $script:PageSize
    $script:PageCache[$PageNumber] = $page
    return , $page
}

function Get-TableLeafCellPayload {
    param(
        [int]$PageNumber,
        [byte[]]$PageBytes,
        [int]$CellOffset
    )

    $off = [ref]$CellOffset
    [UInt64]$payloadLength = Read-Varint -Bytes $PageBytes -Offset $off
    [UInt64]$rowid = Read-Varint -Bytes $PageBytes -Offset $off

    $U = $script:UsableSize
    $X = $U - 35
    $M = [math]::Floor((($U - 12) * 32) / 255) - 23

    if ($payloadLength -le $X) {
        $local = [int]$payloadLength
    }
    else {
        $K = $M + (($payloadLength - $M) % ($U - 4))
        if ($K -le $X) {
            $local = [int]$K
        }
        else {
            $local = [int]$M
        }
    }

    $payload = New-Object System.Collections.Generic.List[byte]
    if ($local -gt 0) {
        $payload.AddRange((Get-Slice -Data $PageBytes -Offset $off.Value -Length $local))
    }

    if ($payloadLength -gt $local) {
        $overflowPage = [int](Read-BEUInt32 -Bytes $PageBytes -Offset ($off.Value + $local))
        [int64]$remaining = [int64]$payloadLength - $local

        while ($overflowPage -ne 0 -and $remaining -gt 0) {
            $ov = Get-Page -PageNumber $overflowPage
            $nextOverflow = [int](Read-BEUInt32 -Bytes $ov -Offset 0)
            $chunkLen = [int][Math]::Min($remaining, $U - 4)

            if ($chunkLen -gt 0) {
                $payload.AddRange((Get-Slice -Data $ov -Offset 4 -Length $chunkLen))
            }

            $remaining -= $chunkLen
            $overflowPage = $nextOverflow
        }
    }

    return [pscustomobject]@{
        RowId   = [UInt64]$rowid
        Payload = [byte[]]$payload.ToArray()
    }
}

function ConvertFrom-SqliteRecord {
    param([byte[]]$Payload)

    $off = [ref]0
    [UInt64]$headerSize = Read-Varint -Bytes $Payload -Offset $off

    $serials = New-Object System.Collections.Generic.List[UInt64]
    while ($off.Value -lt $headerSize) {
        $serials.Add((Read-Varint -Bytes $Payload -Offset $off))
    }

    $bodyOffset = [int]$headerSize
    $values = New-Object System.Collections.Generic.List[object]

    foreach ($serial in $serials) {
        switch ($serial) {
            0 { $values.Add($null) }
            1 {
                $values.Add((Read-SignedBE -Bytes $Payload -Offset $bodyOffset -Length 1))
                $bodyOffset += 1
            }
            2 {
                $values.Add((Read-SignedBE -Bytes $Payload -Offset $bodyOffset -Length 2))
                $bodyOffset += 2
            }
            3 {
                $values.Add((Read-SignedBE -Bytes $Payload -Offset $bodyOffset -Length 3))
                $bodyOffset += 3
            }
            4 {
                $values.Add((Read-SignedBE -Bytes $Payload -Offset $bodyOffset -Length 4))
                $bodyOffset += 4
            }
            5 {
                $values.Add((Read-SignedBE -Bytes $Payload -Offset $bodyOffset -Length 6))
                $bodyOffset += 6
            }
            6 {
                $values.Add((Read-SignedBE -Bytes $Payload -Offset $bodyOffset -Length 8))
                $bodyOffset += 8
            }
            7 {
                $raw = Get-Slice -Data $Payload -Offset $bodyOffset -Length 8
                if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($raw) }
                $values.Add([BitConverter]::ToDouble($raw, 0))
                $bodyOffset += 8
            }
            8 { $values.Add(0) }
            9 { $values.Add(1) }
            default {
                if ($serial -ge 12) {
                    if (($serial % 2) -eq 0) {
                        $len = [int](($serial - 12) / 2)
                        [byte[]]$blobBytes = Get-Slice -Data $Payload -Offset $bodyOffset -Length $len
                        $values.Add($blobBytes)
                        $bodyOffset += $len
                    }
                    else {
                        $len = [int](($serial - 13) / 2)
                        $txtBytes = Get-Slice -Data $Payload -Offset $bodyOffset -Length $len
                        $values.Add([System.Text.Encoding]::UTF8.GetString($txtBytes))
                        $bodyOffset += $len
                    }
                }
                else {
                    throw "Unsupported serial type: $serial"
                }
            }
        }
    }

    return , $values.ToArray()
}

function Get-SqliteSchemaRows {
    param([int]$PageNumber)

    if ($script:VisitedPages.Contains($PageNumber)) {
        return @()
    }
    [void]$script:VisitedPages.Add($PageNumber)

    $page = Get-Page -PageNumber $PageNumber
    $hdrOffset = if ($PageNumber -eq 1) { 100 } else { 0 }
    $pageType = $page[$hdrOffset]

    $rows = New-Object System.Collections.Generic.List[object]

    switch ($pageType) {
        0x05 {
            $cellCount = Read-BEUInt16 -Bytes $page -Offset ($hdrOffset + 3)
            $rightMost = [int](Read-BEUInt32 -Bytes $page -Offset ($hdrOffset + 8))
            $ptrBase = $hdrOffset + 12

            for ($i = 0; $i -lt $cellCount; $i++) {
                $cellPtr = Read-BEUInt16 -Bytes $page -Offset ($ptrBase + ($i * 2))
                $leftChild = [int](Read-BEUInt32 -Bytes $page -Offset $cellPtr)
                $rows.AddRange((Get-SqliteSchemaRows -PageNumber $leftChild))
            }

            $rows.AddRange((Get-SqliteSchemaRows -PageNumber $rightMost))
        }

        0x0D {
            $cellCount = Read-BEUInt16 -Bytes $page -Offset ($hdrOffset + 3)
            $ptrBase = $hdrOffset + 8

            for ($i = 0; $i -lt $cellCount; $i++) {
                $cellPtr = Read-BEUInt16 -Bytes $page -Offset ($ptrBase + ($i * 2))
                $cell = Get-TableLeafCellPayload -PageNumber $PageNumber -PageBytes $page -CellOffset $cellPtr
                $cols = ConvertFrom-SqliteRecord -Payload $cell.Payload

                if ($cols.Length -ge 5) {
                    $rows.Add([pscustomobject]@{
                            type     = $cols[0]
                            name     = $cols[1]
                            tbl_name = $cols[2]
                            rootpage = $cols[3]
                            sql      = $cols[4]
                            rowid    = $cell.RowId
                            page     = $PageNumber
                            cell     = $cellPtr
                        })
                }
            }
        }

        default {
            throw ("Unexpected sqlite_schema page type 0x{0:X2} at page {1}" -f $pageType, $PageNumber)
        }
    }

    return , $rows.ToArray()
}

function Get-TablePageHeaderOffset {
    param([int]$PageNumber)

    if ($PageNumber -eq 1) {
        return 100
    }

    return 0
}

function Get-TableLeafCells {
    param([int]$PageNumber)

    $page = Get-Page -PageNumber $PageNumber
    $hdrOffset = Get-TablePageHeaderOffset -PageNumber $PageNumber
    $pageType = $page[$hdrOffset]

    $rows = New-Object System.Collections.Generic.List[object]

    switch ($pageType) {
        0x05 {
            $cellCount = Read-BEUInt16 -Bytes $page -Offset ($hdrOffset + 3)
            $rightMost = [int](Read-BEUInt32 -Bytes $page -Offset ($hdrOffset + 8))
            $ptrBase = $hdrOffset + 12

            for ($i = 0; $i -lt $cellCount; $i++) {
                $cellPtr = Read-BEUInt16 -Bytes $page -Offset ($ptrBase + ($i * 2))
                $leftChild = [int](Read-BEUInt32 -Bytes $page -Offset $cellPtr)
                $rows.AddRange((Get-TableLeafCells -PageNumber $leftChild))
            }

            $rows.AddRange((Get-TableLeafCells -PageNumber $rightMost))
        }

        0x0D {
            $cellCount = Read-BEUInt16 -Bytes $page -Offset ($hdrOffset + 3)
            $ptrBase = $hdrOffset + 8

            for ($i = 0; $i -lt $cellCount; $i++) {
                $cellPtr = Read-BEUInt16 -Bytes $page -Offset ($ptrBase + ($i * 2))
                $cell = Get-TableLeafCellPayload -PageNumber $PageNumber -PageBytes $page -CellOffset $cellPtr
                $rows.Add([pscustomobject]@{
                        PageNumber = $PageNumber
                        CellOffset = $cellPtr
                        RowId      = [UInt64]$cell.RowId
                        Payload    = [byte[]]$cell.Payload
                    })
            }
        }

        default {
            throw ("Unexpected table b-tree page type 0x{0:X2} at page {1}" -f $pageType, $PageNumber)
        }
    }

    return , $rows.ToArray()
}

function ConvertTo-ProtobufVarint {
    param([UInt64]$Value)

    $bytes = New-Object System.Collections.Generic.List[byte]
    [UInt64]$remaining = $Value

    do {
        [byte]$b = [byte]($remaining -band 0x7F)
        $remaining = $remaining -shr 7

        if ($remaining -ne 0) {
            $b = [byte]($b -bor 0x80)
        }

        $bytes.Add($b)
    } while ($remaining -ne 0)

    return , ([byte[]]$bytes.ToArray())
}

function Get-ContentEntryPrefix {
    param([UInt64]$CacheId)

    $field1Tag = [byte]0x08
    $encoded = ConvertTo-ProtobufVarint -Value $CacheId
    return , ([byte[]]@($field1Tag) + $encoded)
}

function Test-ByteArrayStartsWith {
    param(
        [byte[]]$Data,
        [byte[]]$Prefix
    )

    if ($null -eq $Data -or $null -eq $Prefix) {
        return $false
    }

    if ($Data.Length -lt $Prefix.Length) {
        return $false
    }

    for ($i = 0; $i -lt $Prefix.Length; $i++) {
        if ($Data[$i] -ne $Prefix[$i]) {
            return $false
        }
    }

    return $true
}

function Resolve-TableRoots {
    param(
        [object[]]$SchemaRows,
        [string[]]$Names
    )

    $resolved = New-Object 'System.Collections.Generic.Dictionary[string, object]'

    foreach ($name in $Names) {
        $row = $SchemaRows |
        Where-Object { $_.type -eq 'table' -and $_.name -eq $name } |
        Select-Object -First 1

        if ($null -ne $row) {
            $resolved[$name] = $row
        }
    }

    return $resolved
}

function Get-ItemPropertiesMatches {
    param(
        [int]$RootPage,
        [byte[]]$TargetPrefix
    )

    $matchRows = New-Object System.Collections.Generic.List[object]

    foreach ($leafCell in (Get-TableLeafCells -PageNumber $RootPage)) {
        $cols = ConvertFrom-SqliteRecord -Payload $leafCell.Payload

        if ($cols.Length -lt 4) {
            continue
        }

        $itemStableId = $cols[0]
        $key = $cols[1]
        $value = $cols[2]
        $valueType = $cols[3]

        if ($key -ne 'content-entry') {
            continue
        }

        $valueBytes = ConvertTo-ByteArray -Value $value
        if ($null -eq $valueBytes) {
            continue
        }

        if (-not (Test-ByteArrayStartsWith -Data $valueBytes -Prefix $TargetPrefix)) {
            continue
        }

        $matchRows.Add([pscustomobject]@{
                item_stable_id = [Int64]$itemStableId
                value_type     = $valueType
                property_page  = $leafCell.PageNumber
                property_cell  = ('0x{0:X}' -f $leafCell.CellOffset)
            })
    }

    return , $matchRows.ToArray()
}

function Get-ItemsByStableId {
    param(
        [int]$RootPage,
        [Int64[]]$StableIds
    )

    $wanted = New-Object 'System.Collections.Generic.HashSet[Int64]'
    foreach ($stableId in $StableIds) {
        [void]$wanted.Add([Int64]$stableId)
    }

    $rows = New-Object System.Collections.Generic.List[object]

    foreach ($leafCell in (Get-TableLeafCells -PageNumber $RootPage)) {
        $stableId = [Int64]$leafCell.RowId
        if (-not $wanted.Contains($stableId)) {
            continue
        }

        $cols = ConvertFrom-SqliteRecord -Payload $leafCell.Payload
        if ($cols.Length -lt 18) {
            continue
        }

        $rows.Add([pscustomobject]@{
                stable_id     = $stableId
                id            = $cols[1]
                modified_date = $cols[8]
                file_size     = $cols[11]
                local_title   = $cols[13]
                items_page    = $leafCell.PageNumber
                items_cell    = ('0x{0:X}' -f $leafCell.CellOffset)
            })
    }

    return , $rows.ToArray()
}

$script:PageCache = New-Object 'System.Collections.Generic.Dictionary[int, byte[]]'
$script:WalPageCache = New-Object 'System.Collections.Generic.Dictionary[int, byte[]]'
$script:VisitedPages = New-Object 'System.Collections.Generic.HashSet[int]'

if ([string]::IsNullOrWhiteSpace($Path) -or -not $PSBoundParameters.ContainsKey('Filename')) {
    Show-Usage
    return
}

$script:Fs = [System.IO.File]::Open(
    $Path,
    [System.IO.FileMode]::Open,
    [System.IO.FileAccess]::Read,
    [System.IO.FileShare]::ReadWrite
)

try {
    $header = Read-At -Stream $script:Fs -Offset 0 -Count 100

    $sig = [System.Text.Encoding]::ASCII.GetString($header[0..15])
    if (-not $sig.StartsWith("SQLite format 3")) {
        throw "Not a SQLite 3 database"
    }

    $script:PageSize = Read-BEUInt16 -Bytes $header -Offset 16
    if ($script:PageSize -eq 1) {
        $script:PageSize = 65536
    }

    $reserved = [int]$header[20]
    $script:UsableSize = $script:PageSize - $reserved

    Initialize-WalOverlay -DatabasePath $Path

    $schemaRows = Get-SqliteSchemaRows -PageNumber 1

    $neededTables = Resolve-TableRoots -SchemaRows $schemaRows -Names @('item_properties', 'items')
    foreach ($requiredName in @('item_properties', 'items')) {
        if (-not $neededTables.ContainsKey($requiredName)) {
            throw "Required table '$requiredName' not found in sqlite_schema"
        }
    }

    $targetPrefix = Get-ContentEntryPrefix -CacheId $Filename
    $propertyMatches = Get-ItemPropertiesMatches -RootPage ([int]$neededTables['item_properties'].rootpage) -TargetPrefix $targetPrefix

    if (-not $propertyMatches) {
        Write-Output ""
        Write-Output ("No item_properties rows matched content-entry prefix for cache ID {0}." -f $Filename)
        return
    }

    $stableIds = $propertyMatches |
    Select-Object -ExpandProperty item_stable_id -Unique

    $itemsRows = Get-ItemsByStableId -RootPage ([int]$neededTables['items'].rootpage) -StableIds $stableIds
    $itemsByStableId = @{}
    foreach ($item in $itemsRows) {
        $itemsByStableId[[Int64]$item.stable_id] = $item
    }

    $final = foreach ($match in $propertyMatches) {
        $item = $itemsByStableId[[Int64]$match.item_stable_id]

        [pscustomobject]@{
            stable_id     = [Int64]$match.item_stable_id
            id            = if ($null -ne $item) { $item.id } else { $null }
            modified_date = if ($null -ne $item) { $item.modified_date } else { $null }
            local_title   = if ($null -ne $item) { $item.local_title } else { $null }
            file_size     = if ($null -ne $item) { $item.file_size } else { $null }
        }
    }

    Write-Output ""
    Write-Output ("Matches for cache ID {0}" -f $Filename)
    Write-VerticalResults -InputObject ($final | Sort-Object stable_id, id, local_title)
}
finally {
    $script:Fs.Dispose()
}