src/Public/Parse/ConvertFrom-FotonJumplist.ps1

function ConvertFrom-FotonJumplist {
    <#
    .SYNOPSIS
        Parse a Windows jumplist already copied off the host.
 
    .DESCRIPTION
        Offline bytes only. Automatic destinations (.automaticDestinations-ms) are
        OLE compound files: DestList paths/FILETIME (versions 1 and 2/3/4) plus
        embedded LNK LocalBasePath. Custom destinations (.customDestinations-ms)
        are scanned for the Shell Link CLSID and parsed with the LNK helper.
 
        Unknown DestList versions and failed OLE reads emit unsupported rows
        instead of guessed times. Does not execute targets.
 
    .PARAMETER Path
        Path to a .automaticDestinations-ms or .customDestinations-ms file.
 
    .EXAMPLE
        ConvertFrom-FotonJumplist -Path D:\Cases\...\9b9cdc69de0e5dea.automaticDestinations-ms
 
    .OUTPUTS
        One or more parse rows (DestList entries and/or embedded LNK paths).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "Jumplist file not found: $Path"
    }
    $item = Get-Item -LiteralPath $Path
    $bytes = [IO.File]::ReadAllBytes($item.FullName)
    $sha = Get-FotonSha256Hex -LiteralPath $item.FullName
    $base = {
        param($Kind, $Supported, $Identity, $Note, $StreamName, $LastAccessUtc, $PinStatus, $Hostname, $LocalBasePath)
        [PSCustomObject]@{
            SourcePath     = $item.FullName
            SizeBytes      = $bytes.Length
            SHA256         = $sha
            Format         = $Kind
            Supported      = $Supported
            Identity       = $Identity
            StreamName     = $StreamName
            Hostname       = $Hostname
            LastAccessUtc  = $LastAccessUtc
            PinStatus      = $PinStatus
            LocalBasePath  = $LocalBasePath
            Note           = $Note
        }
    }

    if ($bytes.Length -lt 8) {
        return @(
            & $base 'Unsupported-TooSmall' $false '' 'Shorter than a jumplist header. Paths were not guessed.' '' '' '' '' ''
        )
    }

    $isOle = ($bytes[0] -eq 0xD0 -and $bytes[1] -eq 0xCF -and $bytes[2] -eq 0x11 -and $bytes[3] -eq 0xE0)
    $rows = @()
    if ($isOle) {
        $streams = @(Get-FotonOleCompoundStreams -Bytes $bytes)
        if ($streams.Count -eq 0) {
            return @(
                & $base 'AutomaticDestinations' $false '' 'OLE compound header present but streams were not extracted. Use an OLE/jumplist tool; DestList times were not guessed.' '' '' '' '' ''
            )
        }
        foreach ($st in $streams) {
            if ($st.Name -eq 'DestList') {
                foreach ($d in @(ConvertFrom-FotonDestListBytes -Bytes $st.Data)) {
                    $rows += & $base 'DestList' $d.Supported $d.Identity $d.Note 'DestList' $d.LastAccessUtc $d.PinStatus $d.Hostname ''
                }
            }
            elseif ($st.Name -match '^[0-9a-fA-F]+$') {
                $lnk = Get-FotonLnkParseFromBytes -Bytes $st.Data
                $ok = [bool]$lnk.Supported
                $note = $lnk.Note
                if ($note) { $note = 'Embedded LNK stream ' + $st.Name + '. ' + $note }
                $rows += & $base 'JumplistLnk' $ok $lnk.LocalBasePath $note $st.Name '' '' '' $lnk.LocalBasePath
            }
        }
        if ($rows.Count -eq 0) {
            $rows += & $base 'AutomaticDestinations' $false '' 'OLE streams were present but none were DestList or hex-named LNK streams.' '' '' '' '' ''
        }
        return $rows
    }

    $clsid = [byte[]](0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46)
    $found = 0
    $i = 0
    while ($i -le ($bytes.Length - 20) -and $found -lt 40) {
        $hit = $true
        for ($c = 0; $c -lt 16; $c++) {
            if ($bytes[$i + $c] -ne $clsid[$c]) { $hit = $false; break }
        }
        if (-not $hit) { $i++; continue }
        $lnkStart = -1
        if (($i + 20) -le $bytes.Length -and [BitConverter]::ToUInt32($bytes, $i + 16) -eq 0x4C) {
            $lnkStart = $i + 16
        }
        elseif ($i -ge 4 -and [BitConverter]::ToUInt32($bytes, $i - 4) -eq 0x4C) {
            $lnkStart = $i - 4
        }
        if ($lnkStart -lt 0) {
            $i++
            continue
        }
        $end = $bytes.Length
        for ($j = $lnkStart + 4; $j -le ($bytes.Length - 4); $j++) {
            if ($bytes[$j] -eq 0xAB -and $bytes[$j + 1] -eq 0xFB -and $bytes[$j + 2] -eq 0xBF -and $bytes[$j + 3] -eq 0xBA) {
                $end = $j
                break
            }
        }
        $len = $end - $lnkStart
        if ($len -lt 0x4C) { $i++; continue }
        $slice = New-Object byte[] $len
        [Array]::Copy($bytes, $lnkStart, $slice, 0, $len)
        $lnk = Get-FotonLnkParseFromBytes -Bytes $slice
        $found++
        $verNote = 'Custom destinations embedded LNK (Shell Link CLSID). '
        if ($bytes.Length -ge 4) {
            $hv = [BitConverter]::ToUInt32($bytes, 0)
            if ($hv -ne 2) { $verNote = $verNote + ("File header version is {0} (libyal documents 2); LNK still parsed when CLSID+HeaderSize matched. " -f $hv) }
        }
        $rows += & $base 'CustomDestinations' ([bool]$lnk.Supported) $lnk.LocalBasePath ($verNote + $lnk.Note) '' '' '' '' $lnk.LocalBasePath
        $i = $end
        if ($i -lt $lnkStart + 1) { $i = $lnkStart + 1 }
    }
    if ($rows.Count -eq 0) {
        return @(
            & $base 'Unsupported-NotJumplist' $false '' 'Not an OLE automatic destinations file and no Shell Link CLSID+LNK header was found. Paths were not guessed.' '' '' '' '' ''
        )
    }
    return $rows
}