Parsing AutomaticDestinations in Python the Hard Way
JLECmd already parses these files correctly, so the honest reason to write your own is integration: you want Jump List events flowing into a pipeline, a custom schema, a triage tool, or a notebook, without shelling out to a Windows binary and scraping CSV. That is a good reason. "Because I do not trust the existing tool" is not, and you should validate whatever you build against JLECmd before you rely on it.
Here is the path through the file, in Python, with the parts that actually trip people up called out. The byte-level field layout lives in the automaticDestinations file format post; this is about getting at it in code.
The container is OLE, so start there
A .automaticDestinations-ms file is an OLE compound file (the old structured-storage / CFBF format). That single fact decides your first dependency. Use olefile:
import olefile
path = "1b4dd67f29cb1962.automaticDestinations-ms"
ole = olefile.OleFileIO(path)
# Streams: numbered streams hold one LNK each, plus a "DestList" stream.
for entry in ole.listdir():
print(entry) # e.g. ['1'], ['2'], ['DestList']
The numbered streams (1, 2, 3, ...) each contain a single Windows shortcut, the same on-disk structure as a standalone .lnk. DestList is the index that ties them together and carries the metadata you actually came for: the hostname, the access count, the last-access time, the pin state, and which numbered stream each entry maps to.
First gotcha, before any parsing: on a live system the file is locked by explorer.exe. OleFileIO will fail or read a partial file. Work from a forensic image, a Volume Shadow Copy, or a robocopy /b of a mounted snapshot. Never point this at the live profile and trust the result.
Decode the LNK streams
Do not hand-roll the LNK parser unless you enjoy pain. pylnk3 reads the shortcut structure including the target path and the TrackerDataBlock:
import io, pylnk3
def read_lnk_stream(ole, stream_name):
raw = ole.openstream(stream_name).read()
return pylnk3.parse(io.BytesIO(raw))
lnk = read_lnk_stream(ole, "1")
print(lnk.path) # target path, when present
Be defensive here. Some streams carry shortcuts with no LinkInfo block (network-only targets, or items addressed purely by shell item ID list), so lnk.path can be empty even though the entry is perfectly valid. Catch the empty case and fall back to the relative path or the shell item ID list rather than dropping the entry.
Walk the DestList
DestList is a header followed by fixed-shape entries, each ending with a variable-length UTF-16 path. The fields you want per entry are the entry ID, the NetBIOS hostname, the last-access FILETIME, the pin status, and the target stream number. Sketch:
import struct
def parse_destlist(raw):
# Header: version + counters. Version drives entry layout.
version, n_entries = struct.unpack_from("<II", raw, 0)
offset = 32 # header size; confirm against the file-format reference
entries = []
for _ in range(n_entries):
# Field offsets below are illustrative. Read the format post
# and confirm against your sample before trusting them.
hostname = raw[offset+72:offset+88].split(b"\x00")[0].decode("ascii", "replace")
entry_id, = struct.unpack_from("<I", raw, offset+88)
filetime, = struct.unpack_from("<Q", raw, offset+100)
path_chars, = struct.unpack_from("<H", raw, offset+128)
path = raw[offset+130:offset+130+path_chars*2].decode("utf-16-le")
entries.append({
"entry_id": entry_id,
"hostname": hostname,
"filetime": filetime,
"path": path,
})
offset += 130 + path_chars*2
return entries
I have left the offsets deliberately marked as illustrative, because this is the part that bites. Do not copy a field table from a blog and ship it. Open your own sample, confirm the offsets, and only then trust the loop.
The two things that actually break
FILETIME conversion. It is 100-nanosecond intervals since 1601-01-01 UTC. Every wrong timeline I have reviewed got this slightly off.
from datetime import datetime, timezone
def filetime_to_dt(ft):
if ft == 0:
return None
return datetime.fromtimestamp(ft / 10_000_000 - 11_644_473_600, tz=timezone.utc)
Guard the zero case. A FILETIME of 0 is "not set," not 1601, and feeding it through the math produces a real-looking timestamp from the seventeenth century that will quietly poison a timeline.
DestList version skew. Windows 7 wrote version 1. Windows 10 and 11 commonly write version 3 or 4. The entry size and several field offsets shifted between them. A loop hardcoded for one version reads later files misaligned, and the symptom is subtle: hostnames come out as garbage, paths look truncated, the byte cursor drifts a little further off with each entry. If your output degrades progressively down a file, you have a version mismatch, not a corrupt file. Branch on the header version and keep per-version offset tables.
What olefile will not do for you
customDestinations-ms is a different format. It is not OLE. It is a sequence of LNK structures with its own framing, so OleFileIO raises on it and you parse it by hand or with a dedicated reader. The customDestinations file format post covers that structure. Mixing the two up is the single most common reason a "Jump List parser" script works on some files and throws on others.
When you just need to look at one file without writing any of this, the parser on the homepage runs the same decode in the browser, client-side, nothing uploaded. The script is for when you need it in a pipeline.