lnk-core 0.4.0

Windows Shell Link (.lnk) + Jump List reader: parse [MS-SHLLINK] header, LinkInfo (volume serial, drive type, local base path), StringData, ExtraData (TrackerDataBlock machine_id / droid GUIDs), and Automatic/Custom Destinations Jump Lists into typed ShellLink / JumpList
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
//! Windows Jump List reader — `*.automaticDestinations-ms` (an OLE/CFB compound
//! file holding a `DestList` MRU stream plus one embedded `[MS-SHLLINK]` shell
//! link per entry) and `*.customDestinations-ms` (a flat sequence of categories,
//! each a run of concatenated shell links).
//!
//! Forensic value: a Jump List ties a per-application MRU history (recency, pin
//! state, access count, origin hostname) to the full target evidence of each
//! embedded `.lnk` (path, volume serial, droid GUIDs). The offset tables and the
//! `0xBABFFBAB` footer / CLSID boundary live in
//! [`forensicnomicon::jumplist`] (knowledge-only); the parsing is here.
//!
//! Input is attacker-controllable evidence: every read is bounds-checked, the
//! CFB layer is the mature `cfb` crate, declared shell-link sizes are treated as
//! unreliable (the custom-destinations splitter scans for the CLSID/footer
//! rather than trusting a length), and the path string is decoded **lossily**
//! because a `DestList` path may carry unpaired surrogates. Malformed input
//! yields [`None`] or an empty entry list, never a panic.
//!
//! # Authoritative source
//!
//! libyal `dtformats`, *Jump lists format*:
//! <https://github.com/libyal/dtformats/blob/main/documentation/Jump%20lists%20format.asciidoc>

use std::io::{Cursor, Read};

use forensicnomicon::jumplist as jl;
use forensicnomicon::shlink;

use crate::{filetime_to_unix, guid_string, parse_shell_link, ShellLink};

/// The structural reason a byte buffer could not be parsed as a Jump List.
///
/// The input is an in-memory `&[u8]`, so there is no device I/O to fail — every
/// failure here is structural ("not a valid CFB", "no DestList stream", "wrong
/// custom-destinations version"). Each variant carries the offending value so an
/// investigator who feeds a corrupt `.automaticDestinations-ms` /
/// `.customDestinations-ms` learns *why* it did not parse instead of a silent
/// [`None`].
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JumplistError {
    /// The bytes are not an OLE/CFB compound file. Automatic-destinations Jump
    /// Lists are CFB; the CFB magic is `D0 CF 11 E0 A1 B1 1A E1`. `found_magic`
    /// is the first 8 bytes that *were* present (zero-padded if shorter).
    NotCompoundFile {
        /// The first 8 bytes of the input (the CFB magic position).
        found_magic: [u8; 8],
    },
    /// The bytes are a valid CFB compound file, but it carries no `DestList`
    /// stream — so it is not an automatic-destinations Jump List.
    MissingDestListStream,
    /// The bytes are not a `customDestinations-ms` file: the 4-byte header format
    /// version is not [`forensicnomicon::jumplist::CUSTOM_DESTINATIONS_FORMAT_VERSION`].
    /// `found` is the version value that was actually read.
    BadCustomFormatVersion {
        /// The format-version value read from the header.
        found: u32,
    },
}

impl std::fmt::Display for JumplistError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotCompoundFile { found_magic } => write!(
                f,
                "not an OLE/CFB compound file (expected magic D0CF11E0A1B11AE1, \
                 found {found_magic:02X?})"
            ),
            Self::MissingDestListStream => {
                write!(f, "CFB compound file has no DestList stream")
            }
            Self::BadCustomFormatVersion { found } => write!(
                f,
                "customDestinations header format version is {found}, expected {}",
                jl::CUSTOM_DESTINATIONS_FORMAT_VERSION
            ),
        }
    }
}

impl std::error::Error for JumplistError {}

/// Which Jump List family a [`JumpList`] was parsed from.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JumpListKind {
    /// `*.automaticDestinations-ms` — a CFB compound file with a `DestList`
    /// MRU stream and one shell-link sub-stream per entry.
    Automatic,
    /// `*.customDestinations-ms` — flat category list of embedded shell links.
    Custom,
}

/// A parsed Jump List.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JumpList {
    /// Which family the list came from.
    pub kind: JumpListKind,
    /// The owning application's `AppID` (lowercase hex), when the caller passed
    /// the source filename. Resolve a friendly name with
    /// [`forensicnomicon::jumplist::appid_name`].
    pub app_id: Option<String>,
    /// The entries, in stream/category order.
    pub entries: Vec<JumpListEntry>,
}

/// One Jump List entry: an embedded shell link plus, for automatic
/// destinations, its `DestList` MRU metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JumpListEntry {
    /// The `DestList` MRU record, present only for automatic destinations.
    pub destlist: Option<DestListEntry>,
    /// The embedded `[MS-SHLLINK]` shell link.
    pub link: ShellLink,
}

/// A `DestList` stream entry — the per-target MRU metadata that accompanies an
/// embedded shell link in an automatic-destinations Jump List.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DestListEntry {
    /// `DroidVolumeIdentifier` GUID (NTFS object-id volume), canonical form.
    pub droid_volume_guid: String,
    /// `DroidFileIdentifier` GUID (NTFS object-id file), canonical form.
    pub droid_file_guid: String,
    /// `BirthDroidVolumeIdentifier` GUID (at creation), canonical form.
    pub birth_droid_volume_guid: String,
    /// `BirthDroidFileIdentifier` GUID (at creation), canonical form.
    pub birth_droid_file_guid: String,
    /// Origin hostname / NetBIOS name (ASCII, NUL padding trimmed).
    pub hostname: String,
    /// Entry number — also the name of the LNK sub-stream (lowercase hex).
    pub entry_number: u32,
    /// Last-access time, Unix epoch seconds (0 when the FILETIME was 0).
    pub last_access: i64,
    /// Whether the entry is pinned (`PinStatus >= 0`).
    pub pinned: bool,
    /// Access count — present only in the v2+ (Windows 10/11) layout.
    pub access_count: Option<u32>,
    /// The target path recorded in the `DestList` (UTF-16, decoded lossily).
    pub path: String,
}

// ── Bounds-checked little-endian readers (never panic on short input) ─────────

fn le_u16(data: &[u8], off: usize) -> u16 {
    let mut b = [0u8; 2];
    if let Some(s) = data.get(off..off + 2) {
        b.copy_from_slice(s);
    }
    u16::from_le_bytes(b)
}

fn le_u32(data: &[u8], off: usize) -> u32 {
    let mut b = [0u8; 4];
    if let Some(s) = data.get(off..off + 4) {
        b.copy_from_slice(s);
    }
    u32::from_le_bytes(b)
}

fn le_i32(data: &[u8], off: usize) -> i32 {
    le_u32(data, off) as i32
}

fn le_u64(data: &[u8], off: usize) -> u64 {
    let mut b = [0u8; 8];
    if let Some(s) = data.get(off..off + 8) {
        b.copy_from_slice(s);
    }
    u64::from_le_bytes(b)
}

/// Decode `count` UTF-16LE code units starting at `off`, **lossily** (a DestList
/// path may carry unpaired surrogates). Returns the decoded string and the
/// offset just past the consumed bytes.
fn utf16le_lossy(data: &[u8], off: usize, count: usize) -> (String, usize) {
    let byte_len = count.saturating_mul(2);
    let end = off.saturating_add(byte_len);
    let units: Vec<u16> = data
        .get(off..end)
        .unwrap_or_default()
        .chunks_exact(2)
        .map(|c| u16::from_le_bytes([c[0], c[1]]))
        .collect();
    (String::from_utf16_lossy(&units), end)
}

/// Extract the `AppID` (lowercase hex) from a Jump List filename such as
/// `1b4dd67f29cb1962.automaticDestinations-ms`.
fn appid_from_filename(name: &str) -> Option<String> {
    let stem = name
        .rsplit('/')
        .next()
        .unwrap_or(name)
        .rsplit('\\')
        .next()
        .unwrap_or(name);
    let id = stem.split('.').next().unwrap_or(stem);
    if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit()) {
        Some(id.to_ascii_lowercase())
    } else {
        None
    }
}

/// Parse a `*.automaticDestinations-ms` Jump List from its bytes.
///
/// Opens the bytes as a CFB compound file, reads the `DestList` MRU stream, and
/// for each entry opens the matching hex-named shell-link sub-stream and decodes
/// it with [`parse_shell_link`]. `app_id` is taken from `filename` when given
/// (e.g. `"1b4dd67f29cb1962.automaticDestinations-ms"`).
///
/// Returns [`None`] when the bytes are not a valid CFB compound file or carry no
/// `DestList` stream. Never panics on hostile input.
///
/// Lenient wrapper over [`parse_automatic_destinations_checked`]: the structural
/// reason for a failure is discarded. Use the checked variant to learn *why* a
/// file did not parse (bad CFB header vs missing `DestList` stream).
#[must_use]
pub fn parse_automatic_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
    parse_automatic_destinations_checked(data, filename).ok()
}

/// Parse a `*.automaticDestinations-ms` Jump List, surfacing the structural
/// reason for any failure.
///
/// - `Err(JumplistError::NotCompoundFile { found_magic })` — the bytes are not a
///   CFB compound file; `found_magic` carries the first 8 bytes that were there.
/// - `Err(JumplistError::MissingDestListStream)` — a valid CFB without a
///   `DestList` stream (so not an automatic-destinations Jump List).
/// - `Ok(JumpList)` — a parsed Jump List (its `entries` may be empty if no
///   embedded shell link decoded, but the `DestList` was present).
///
/// `app_id` is taken from `filename` when given. Never panics on hostile input.
pub fn parse_automatic_destinations_checked(
    data: &[u8],
    filename: Option<&str>,
) -> Result<JumpList, JumplistError> {
    let mut comp = cfb::CompoundFile::open(Cursor::new(data)).map_err(|_| {
        let mut found_magic = [0u8; 8];
        let n = data.len().min(8);
        found_magic[..n].copy_from_slice(&data[..n]);
        JumplistError::NotCompoundFile { found_magic }
    })?;

    // Read the whole DestList stream into memory (bounded by the CFB layer).
    let destlist = {
        let mut stream = comp
            .open_stream("DestList")
            .map_err(|_| JumplistError::MissingDestListStream)?;
        let mut buf = Vec::new();
        // read_to_end into a Vec from an opened CFB stream is infallible in
        // practice (the bytes are already in memory); ignore the Result so there
        // is no unreachable error arm to cover.
        let _ = stream.read_to_end(&mut buf);
        buf
    };

    let format_version = le_u32(&destlist, jl::DESTLIST_HEADER_FORMAT_VERSION_OFFSET);
    let extended = format_version >= 2;

    let mut entries = Vec::new();
    let mut off = jl::DESTLIST_HEADER_SIZE;
    // Bound the walk by the buffer; each iteration must make forward progress.
    while off + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET + 4 <= destlist.len() {
        let (destlist_entry, next) = parse_destlist_entry(&destlist, off, extended);
        if next <= off {
            break; // cov:unreachable: parse_destlist_entry always advances past the path
        }
        off = next;

        // The LNK sub-stream is named by the entry number in lowercase hex.
        let stream_name = format!("{:x}", destlist_entry.entry_number);
        let mut lnk = Vec::new();
        if let Ok(mut stream) = comp.open_stream(&stream_name) {
            // read_to_end into a Vec from an opened CFB stream is infallible in
            // practice; ignore the Result so there is no unreachable error arm.
            let _ = stream.read_to_end(&mut lnk);
        }
        if let Some(link) = parse_shell_link(&lnk) {
            entries.push(JumpListEntry {
                destlist: Some(destlist_entry),
                link,
            });
        }
    }

    Ok(JumpList {
        kind: JumpListKind::Automatic,
        app_id: filename.and_then(appid_from_filename),
        entries,
    })
}

/// Parse one `DestList` entry anchored at `base`. Returns the decoded entry and
/// the offset of the next entry (past the path and, for v2+, the alignment).
fn parse_destlist_entry(data: &[u8], base: usize, extended: bool) -> (DestListEntry, usize) {
    let guid_at = |field_off: usize| -> String {
        data.get(base + field_off..base + field_off + 16)
            .and_then(guid_string)
            .unwrap_or_default()
    };

    let droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_DROID_VOLUME_GUID_OFFSET);
    let droid_file_guid = guid_at(jl::DESTLIST_ENTRY_DROID_FILE_GUID_OFFSET);
    let birth_droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_VOLUME_GUID_OFFSET);
    let birth_droid_file_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_FILE_GUID_OFFSET);

    let hostname = {
        let start = base + jl::DESTLIST_ENTRY_HOSTNAME_OFFSET;
        let raw = data
            .get(start..start + jl::DESTLIST_ENTRY_HOSTNAME_SIZE)
            .unwrap_or_default();
        let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
        String::from_utf8_lossy(&raw[..end]).into_owned()
    };

    let entry_number = le_u32(data, base + jl::DESTLIST_ENTRY_ENTRY_NUMBER_OFFSET);
    let last_access = filetime_to_unix(le_u64(
        data,
        base + jl::DESTLIST_ENTRY_LAST_ACCESS_FILETIME_OFFSET,
    ));
    let pin_status = le_i32(data, base + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET);
    let pinned = pin_status >= 0;

    let (access_count, path_size_off, path_off, trailing) = if extended {
        (
            Some(le_u32(
                data,
                base + jl::DESTLIST_ENTRY_V2_ACCESS_COUNT_OFFSET,
            )),
            jl::DESTLIST_ENTRY_V2_PATH_SIZE_OFFSET,
            jl::DESTLIST_ENTRY_V2_PATH_OFFSET,
            jl::DESTLIST_ENTRY_V2_TRAILING_ALIGNMENT,
        )
    } else {
        (
            None,
            jl::DESTLIST_ENTRY_V1_PATH_SIZE_OFFSET,
            jl::DESTLIST_ENTRY_V1_PATH_OFFSET,
            0,
        )
    };

    let path_chars = le_u16(data, base + path_size_off) as usize;
    let (path, after_path) = utf16le_lossy(data, base + path_off, path_chars);
    let next = after_path.saturating_add(trailing);

    (
        DestListEntry {
            droid_volume_guid,
            droid_file_guid,
            birth_droid_volume_guid,
            birth_droid_file_guid,
            hostname,
            entry_number,
            last_access,
            pinned,
            access_count,
            path,
        },
        next,
    )
}

/// Parse a `*.customDestinations-ms` Jump List from its bytes.
///
/// Validates the flat header (`FormatVersion == 2`), then splits the embedded
/// shell links by scanning for the `[MS-SHLLINK]` CLSID and the `0xBABFFBAB`
/// footer — declared sizes are unreliable — and structurally decodes each LNK
/// with [`parse_shell_link`]. Category boundaries are not preserved in v0.2; the
/// entries are returned flat in file order.
///
/// Returns [`None`] when the header format version is not `2`.
///
/// Lenient wrapper over [`parse_custom_destinations_checked`]: the structural
/// reason for a failure is discarded.
#[must_use]
pub fn parse_custom_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
    parse_custom_destinations_checked(data, filename).ok()
}

/// Parse a `*.customDestinations-ms` Jump List, surfacing the structural reason
/// for a failure.
///
/// - `Err(JumplistError::BadCustomFormatVersion { found })` — the 4-byte header
///   format version is not the expected value; `found` is what was read.
/// - `Ok(JumpList)` — a parsed Jump List (entries in flat file order).
pub fn parse_custom_destinations_checked(
    data: &[u8],
    filename: Option<&str>,
) -> Result<JumpList, JumplistError> {
    let format_version = le_u32(data, 0);
    if format_version != jl::CUSTOM_DESTINATIONS_FORMAT_VERSION {
        return Err(JumplistError::BadCustomFormatVersion {
            found: format_version,
        });
    }

    // The 16-byte LNK CLSID, in little-endian wire order, prefixes every
    // shell-object entry. NB: the embedded LNK's *own* header also carries this
    // CLSID at its byte +4 — so a position only marks a shell-object boundary
    // when the bytes right after it begin a valid LNK header (HeaderSize 0x4C +
    // a second CLSID copy). That structural test rejects the internal header.
    let clsid_bytes = clsid_wire_bytes();
    let is_entry_prefix = |p: usize| -> bool {
        // p..p+16 is the prefix CLSID; the LNK starts at p+16 and must open with
        // HeaderSize 0x4C and the LinkCLSID at p+20.
        data.get(p..p + 16) == Some(&clsid_bytes[..])
            && le_u32(data, p + 16) == shlink::HEADER_SIZE
            && data.get(p + 20..p + 36) == Some(&clsid_bytes[..])
    };

    // Find each shell-object-entry boundary (skip 0x4C past a match so the LNK's
    // own internal CLSID copy is never mistaken for the next entry).
    let mut starts = Vec::new();
    let mut i = 12; // past the 12-byte file header
    while i + 36 <= data.len() {
        if is_entry_prefix(i) {
            starts.push(i);
            i += 16 + shlink::HEADER_SIZE as usize;
        } else {
            i += 1;
        }
    }

    let mut entries = Vec::new();
    for (idx, &prefix) in starts.iter().enumerate() {
        // The LNK data begins right after the 16-byte CLSID prefix and runs to
        // the next entry prefix, the footer signature, or end-of-buffer.
        let lnk_start = prefix + 16;
        let hard_end = starts.get(idx + 1).copied().unwrap_or(data.len());
        let end = footer_before(data, lnk_start, hard_end).unwrap_or(hard_end);
        // lnk_start = prefix + 16 and end <= data.len(), and is_entry_prefix
        // already proved prefix + 36 <= data.len(), so this range never falls
        // out of bounds — get() degrades to an empty slice rather than panic.
        let slice = data.get(lnk_start..end).unwrap_or_default();
        if let Some(link) = parse_shell_link(slice) {
            entries.push(JumpListEntry {
                destlist: None,
                link,
            });
        }
    }

    Ok(JumpList {
        kind: JumpListKind::Custom,
        app_id: filename.and_then(appid_from_filename),
        entries,
    })
}

/// Find the `0xBABFFBAB` footer signature within `start..hard_end`, returning
/// the byte offset where it begins (so the shell-link slice ends there).
fn footer_before(data: &[u8], start: usize, hard_end: usize) -> Option<usize> {
    let sig = jl::CUSTOM_DESTINATIONS_FOOTER_SIGNATURE.to_le_bytes();
    let region = data.get(start..hard_end)?;
    region.windows(4).position(|w| w == sig).map(|p| start + p)
}

/// The `[MS-SHLLINK]` CLSID rendered as its 16 little-endian wire bytes — the
/// byte sequence that prefixes each embedded shell link in a custom
/// destinations file. Derived from [`forensicnomicon::jumplist::LNK_CLSID`].
fn clsid_wire_bytes() -> [u8; 16] {
    // 00021401-0000-0000-C000-000000000046: Data1/2/3 little-endian, Data4 BE.
    let hex: String = jl::LNK_CLSID.chars().filter(|c| *c != '-').collect();
    let mut raw = [0u8; 16];
    for (i, slot) in raw.iter_mut().enumerate() {
        *slot = u8::from_str_radix(hex.get(i * 2..i * 2 + 2).unwrap_or("00"), 16).unwrap_or(0);
    }
    [
        raw[3], raw[2], raw[1], raw[0], // Data1 LE
        raw[5], raw[4], // Data2 LE
        raw[7], raw[6], // Data3 LE
        raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15], // Data4 BE
    ]
}