Skip to main content

lnk_core/
lib.rs

1//! `lnk-core` — a reader for Windows Shell Link (`.lnk`) files.
2//!
3//! Parses the `[MS-SHLLINK]` *Shell Link (.LNK) Binary File Format* into a typed
4//! [`ShellLink`]: the `ShellLinkHeader` (flags, attributes, the three target
5//! FILETIMEs, file size, icon index, show command, hotkey), the optional
6//! `LinkInfo` (the `VolumeID` drive type / **volume serial number** / label and
7//! the local base path, plus a `CommonNetworkRelativeLink` for network targets),
8//! the `StringData` block, and the `ExtraData` `TrackerDataBlock` (the origin
9//! machine NetBIOS name and the distributed-link-tracking droid GUIDs).
10//!
11//! The input is attacker-controllable evidence: parsing is bounds-checked, never
12//! panics, and never trusts a length field. No `unsafe`. Malformed headers yield
13//! [`None`] rather than a partial/garbage value. The format **constants** live in
14//! [`forensicnomicon::shlink`] (knowledge-only); the **parsing algorithm** lives
15//! here.
16//!
17//! # Authoritative source
18//!
19//! `[MS-SHLLINK]` — *Shell Link (.LNK) Binary File Format*:
20//! <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/16cb4ca1-9339-4d0c-a68d-bf1d6cc0f943>
21
22#![forbid(unsafe_code)]
23
24use forensicnomicon::shlink;
25
26mod jumplist;
27pub use jumplist::{
28    parse_automatic_destinations, parse_automatic_destinations_checked, parse_custom_destinations,
29    parse_custom_destinations_checked, DestListEntry, JumpList, JumpListEntry, JumpListKind,
30    JumplistError,
31};
32
33/// The number of 100-nanosecond intervals between the Windows FILETIME epoch
34/// (1601-01-01) and the Unix epoch (1970-01-01).
35const FILETIME_UNIX_DELTA_100NS: i64 = 116_444_736_000_000_000;
36
37/// A fully parsed Windows Shell Link.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ShellLink {
40    /// The fixed-size `ShellLinkHeader` (`[MS-SHLLINK]` §2.1).
41    pub header: ShellLinkHeader,
42    /// The raw `LinkTargetIDList` ItemID blob, when `HasLinkTargetIDList` is set.
43    ///
44    /// v0.1 keeps the PIDL as raw bytes — full ItemID decoding is the job of a
45    /// shellbag parser (`shellbag-core`), not this reader.
46    pub link_target_idlist: Option<LinkTargetIdList>,
47    /// The `LinkInfo` block, when `HasLinkInfo` is set (`[MS-SHLLINK]` §2.3).
48    pub link_info: Option<LinkInfo>,
49    /// The decoded `StringData` block (`[MS-SHLLINK]` §2.4).
50    pub string_data: StringData,
51    /// The `TrackerDataBlock` from `ExtraData`, when present (`[MS-SHLLINK]` §2.5.10).
52    pub tracker: Option<TrackerDataBlock>,
53}
54
55/// The `ShellLinkHeader` (`[MS-SHLLINK]` §2.1).
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ShellLinkHeader {
58    /// `LinkFlags` bitfield (`[MS-SHLLINK]` §2.1.1).
59    pub link_flags: u32,
60    /// `FileAttributesFlags` of the target (`[MS-SHLLINK]` §2.1.2).
61    pub file_attributes: u32,
62    /// Target creation time, Unix epoch seconds (0 when the FILETIME was 0).
63    pub creation_time: i64,
64    /// Target last-access time, Unix epoch seconds (0 when the FILETIME was 0).
65    pub access_time: i64,
66    /// Target last-write time, Unix epoch seconds (0 when the FILETIME was 0).
67    pub write_time: i64,
68    /// Target file size in bytes (low 32 bits per the spec).
69    pub file_size: u32,
70    /// Icon index.
71    pub icon_index: i32,
72    /// `ShowCommand` (e.g. `SW_SHOWNORMAL` = 1).
73    pub show_command: u32,
74    /// `HotKey` flags.
75    pub hotkey: u16,
76}
77
78impl ShellLinkHeader {
79    /// Whether `LinkFlags` bit `flag` is set.
80    #[must_use]
81    pub fn has_flag(&self, flag: u32) -> bool {
82        self.link_flags & flag != 0
83    }
84}
85
86/// The `LinkTargetIDList` (`[MS-SHLLINK]` §2.2) — the target's shell-namespace
87/// path as an `ITEMIDLIST` (PIDL). The raw blob is kept verbatim and also decoded
88/// into typed shell items + a reconstructed path via the `shellitem` primitive.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct LinkTargetIdList {
91    /// The `IDListSize`-delimited ItemID blob, raw and verbatim.
92    pub raw: Vec<u8>,
93    /// The decoded shell items (volume, folder, file entry, …). Empty when the
94    /// blob is truncated or carries no decodable item.
95    pub items: Vec<shellitem::ShellItem>,
96    /// The reconstructed shell-namespace path (e.g. `My Computer\C:\…\evil.exe`),
97    /// or `None` when no item yields a display name. This resolves the real
98    /// target even when the `LinkInfo` block is absent.
99    pub path: Option<String>,
100}
101
102/// The `LinkInfo` block (`[MS-SHLLINK]` §2.3).
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct LinkInfo {
105    /// The `VolumeID`, when the local-volume bit of `LinkInfoFlags` is set.
106    pub volume_id: Option<VolumeId>,
107    /// The local base path (ANSI), when present.
108    pub local_base_path: Option<String>,
109    /// The `CommonNetworkRelativeLink`, when the network bit is set.
110    pub common_network_relative_link: Option<CommonNetworkRelativeLink>,
111}
112
113/// The `VolumeID` (`[MS-SHLLINK]` §2.3.1).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct VolumeId {
116    /// `DriveType` (e.g. `DRIVE_REMOVABLE` = 2, `DRIVE_FIXED` = 3).
117    pub drive_type: u32,
118    /// `DriveSerialNumber` — the join key to a peripheral `DeviceConnection`'s
119    /// volume serial. Surfaced as a first-class field.
120    pub drive_serial_number: u32,
121    /// The volume label, when decodable.
122    pub volume_label: Option<String>,
123}
124
125/// `DriveType` values (`[MS-SHLLINK]` §2.3.1 / Win32 `GetDriveType`).
126pub mod drive_type {
127    /// The drive type cannot be determined.
128    pub const UNKNOWN: u32 = 0;
129    /// The root path is invalid (no volume mounted).
130    pub const NO_ROOT_DIR: u32 = 1;
131    /// A removable drive (USB stick, memory card, floppy).
132    pub const REMOVABLE: u32 = 2;
133    /// A fixed (internal) disk.
134    pub const FIXED: u32 = 3;
135    /// A remote (network) drive.
136    pub const REMOTE: u32 = 4;
137    /// An optical drive (CD/DVD).
138    pub const CDROM: u32 = 5;
139    /// A RAM disk.
140    pub const RAMDISK: u32 = 6;
141}
142
143/// The `CommonNetworkRelativeLink` (`[MS-SHLLINK]` §2.3.2).
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct CommonNetworkRelativeLink {
146    /// The UNC share / network name (e.g. `\\\\server\\share`).
147    pub net_name: Option<String>,
148    /// The local device the share was mapped to (e.g. `Z:`), when present.
149    pub device_name: Option<String>,
150}
151
152/// The decoded `StringData` block (`[MS-SHLLINK]` §2.4).
153///
154/// Each field is present only when its corresponding `LinkFlags` bit is set; the
155/// encoding follows `IsUnicode` (UTF-16LE) versus the ANSI code page.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct StringData {
158    /// `NAME_STRING` — the link description (`HasName`).
159    pub name: Option<String>,
160    /// `RELATIVE_PATH` (`HasRelativePath`).
161    pub relative_path: Option<String>,
162    /// `WORKING_DIR` (`HasWorkingDir`).
163    pub working_dir: Option<String>,
164    /// `COMMAND_LINE_ARGUMENTS` (`HasArguments`).
165    pub arguments: Option<String>,
166    /// `ICON_LOCATION` (`HasIconLocation`).
167    pub icon_location: Option<String>,
168}
169
170/// The `TrackerDataBlock` (`[MS-SHLLINK]` §2.5.10) — origin machine + droid GUIDs.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TrackerDataBlock {
173    /// The NetBIOS name of the machine the link was created on.
174    pub machine_id: String,
175    /// The volume+object `Droid` GUID pair (current).
176    pub droid: DroidGuids,
177    /// The volume+object `DroidBirth` GUID pair (at creation).
178    pub birth_droid: DroidGuids,
179}
180
181/// A `Droid` volume/object GUID pair, rendered in the canonical 8-4-4-4-12 form.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct DroidGuids {
184    /// The volume identifier GUID.
185    pub volume: String,
186    /// The object (file) identifier GUID.
187    pub object: String,
188}
189
190// ── Bounds-checked little-endian readers (never panic on short input) ─────────
191
192fn le_u16(data: &[u8], off: usize) -> u16 {
193    let mut b = [0u8; 2];
194    if let Some(s) = data.get(off..off + 2) {
195        b.copy_from_slice(s);
196    }
197    u16::from_le_bytes(b)
198}
199
200fn le_u32(data: &[u8], off: usize) -> u32 {
201    let mut b = [0u8; 4];
202    if let Some(s) = data.get(off..off + 4) {
203        b.copy_from_slice(s);
204    }
205    u32::from_le_bytes(b)
206}
207
208fn le_i32(data: &[u8], off: usize) -> i32 {
209    le_u32(data, off) as i32
210}
211
212fn le_u64(data: &[u8], off: usize) -> u64 {
213    let mut b = [0u8; 8];
214    if let Some(s) = data.get(off..off + 8) {
215        b.copy_from_slice(s);
216    }
217    u64::from_le_bytes(b)
218}
219
220/// Convert a Windows FILETIME (100-ns ticks since 1601) to Unix epoch seconds.
221/// A zero FILETIME (the "not set" sentinel) maps to 0.
222fn filetime_to_unix(ft: u64) -> i64 {
223    if ft == 0 {
224        return 0;
225    }
226    ((ft as i64) - FILETIME_UNIX_DELTA_100NS) / 10_000_000
227}
228
229/// Format the `LinkCLSID` 16 bytes as the canonical 8-4-4-4-12 GUID string.
230///
231/// The first three components are little-endian; the last two are big-endian
232/// (Microsoft GUID wire order).
233fn guid_string(b: &[u8]) -> Option<String> {
234    let g = b.get(0..16)?;
235    Some(format!(
236        "{:08X}-{:04X}-{:04X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
237        u32::from_le_bytes([g[0], g[1], g[2], g[3]]),
238        u16::from_le_bytes([g[4], g[5]]),
239        u16::from_le_bytes([g[6], g[7]]),
240        g[8],
241        g[9],
242        g[10],
243        g[11],
244        g[12],
245        g[13],
246        g[14],
247        g[15],
248    ))
249}
250
251/// Read a NUL-terminated ANSI string starting at `off` (lossy UTF-8).
252fn ansi_z(data: &[u8], off: usize) -> Option<String> {
253    let slice = data.get(off..)?;
254    let end = slice.iter().position(|&c| c == 0).unwrap_or(slice.len());
255    Some(String::from_utf8_lossy(&slice[..end]).into_owned())
256}
257
258/// Read a NUL-terminated UTF-16LE string starting at `off`.
259fn unicode_z(data: &[u8], off: usize) -> Option<String> {
260    let slice = data.get(off..)?;
261    let mut units = Vec::new();
262    let mut i = 0;
263    while i + 1 < slice.len() {
264        let u = u16::from_le_bytes([slice[i], slice[i + 1]]);
265        if u == 0 {
266            break;
267        }
268        units.push(u);
269        i += 2;
270    }
271    Some(String::from_utf16_lossy(&units))
272}
273
274/// Parse a Shell Link from its bytes.
275///
276/// Returns [`None`] when the `ShellLinkHeader` is not a valid `[MS-SHLLINK]`
277/// header (wrong `HeaderSize` or `LinkCLSID`). Never panics on malformed,
278/// truncated, or hostile input — every field read is bounds-checked, so a
279/// short/garbled body degrades to absent sub-structures rather than a crash.
280#[must_use]
281pub fn parse_shell_link(data: &[u8]) -> Option<ShellLink> {
282    // §2.1 ShellLinkHeader — HeaderSize and LinkCLSID gate validity.
283    if le_u32(data, 0) != shlink::HEADER_SIZE {
284        return None;
285    }
286    let clsid = guid_string(data.get(4..20)?)?;
287    if clsid != shlink::LINK_CLSID {
288        return None;
289    }
290
291    let link_flags = le_u32(data, 20);
292    let file_attributes = le_u32(data, 24);
293    let creation_time = filetime_to_unix(le_u64(data, 28));
294    let access_time = filetime_to_unix(le_u64(data, 36));
295    let write_time = filetime_to_unix(le_u64(data, 44));
296    let file_size = le_u32(data, 52);
297    let icon_index = le_i32(data, 56);
298    let show_command = le_u32(data, 60);
299    let hotkey = le_u16(data, 64);
300
301    let header = ShellLinkHeader {
302        link_flags,
303        file_attributes,
304        creation_time,
305        access_time,
306        write_time,
307        file_size,
308        icon_index,
309        show_command,
310        hotkey,
311    };
312
313    // The variable-length sections begin immediately after the 0x4C header.
314    let mut off = shlink::HEADER_SIZE as usize;
315
316    // §2.2 LinkTargetIDList — IDListSize-prefixed PIDL blob. Kept raw and decoded
317    // into typed shell items + a reconstructed path via the `shellitem` primitive.
318    let link_target_idlist = if header.has_flag(shlink::LINK_FLAG_HAS_LINK_TARGET_ID_LIST) {
319        let id_list_size = le_u16(data, off) as usize;
320        let blob_start = off + 2;
321        let raw = data
322            .get(blob_start..blob_start + id_list_size)
323            .map(<[u8]>::to_vec)
324            .unwrap_or_default();
325        off = blob_start + id_list_size;
326        let items = shellitem::parse_idlist(&raw);
327        let path = if items.is_empty() {
328            None
329        } else {
330            Some(shellitem::reconstruct_path(&items))
331        };
332        Some(LinkTargetIdList { raw, items, path })
333    } else {
334        None
335    };
336
337    // §2.3 LinkInfo — its own LinkInfoSize-prefixed self-contained structure.
338    let link_info = if header.has_flag(shlink::LINK_FLAG_HAS_LINK_INFO) {
339        let info = parse_link_info(data, off);
340        // Advance past the LinkInfo by its declared size.
341        let size = le_u32(data, off) as usize;
342        off += size.max(4);
343        info
344    } else {
345        None
346    };
347
348    // §2.4 StringData — a run of size-counted strings, each honoring IsUnicode.
349    let is_unicode = header.has_flag(shlink::LINK_FLAG_IS_UNICODE);
350    let mut string_data = StringData::default();
351    for (flag, slot) in [
352        (
353            shlink::LINK_FLAG_HAS_NAME,
354            &mut string_data.name as &mut Option<String>,
355        ),
356        (
357            shlink::LINK_FLAG_HAS_RELATIVE_PATH,
358            &mut string_data.relative_path,
359        ),
360        (
361            shlink::LINK_FLAG_HAS_WORKING_DIR,
362            &mut string_data.working_dir,
363        ),
364        (shlink::LINK_FLAG_HAS_ARGUMENTS, &mut string_data.arguments),
365        (
366            shlink::LINK_FLAG_HAS_ICON_LOCATION,
367            &mut string_data.icon_location,
368        ),
369    ] {
370        if header.has_flag(flag) {
371            let (value, next) = read_sized_string(data, off, is_unicode);
372            *slot = value;
373            off = next;
374        }
375    }
376
377    // §2.5 ExtraData — a chain of {size,signature,payload} blocks, terminated by
378    // a size < 0x4. We dispatch only the TrackerDataBlock; the rest are skipped.
379    let tracker = parse_extra_data_tracker(data, off);
380
381    Some(ShellLink {
382        header,
383        link_target_idlist,
384        link_info,
385        string_data,
386        tracker,
387    })
388}
389
390/// Parse the §2.3 LinkInfo block anchored at `base`.
391fn parse_link_info(data: &[u8], base: usize) -> Option<LinkInfo> {
392    let size = le_u32(data, base) as usize;
393    if size < 0x1C {
394        return None;
395    }
396    let header_size = le_u32(data, base + 4) as usize;
397    let flags = le_u32(data, base + 8);
398    let volume_id_offset = le_u32(data, base + 12) as usize;
399    let local_base_path_offset = le_u32(data, base + 16) as usize;
400    let cnrl_offset = le_u32(data, base + 20) as usize;
401    // Optional Unicode offsets appear only when the header is >= 0x24.
402    let local_base_path_offset_unicode = if header_size >= 0x24 {
403        le_u32(data, base + 28) as usize
404    } else {
405        0
406    };
407
408    const VOLUME_ID_AND_LOCAL_BASE_PATH: u32 = 0x1;
409    const CNRL_AND_PATH_SUFFIX: u32 = 0x2;
410
411    let volume_id = if flags & VOLUME_ID_AND_LOCAL_BASE_PATH != 0 && volume_id_offset != 0 {
412        parse_volume_id(data, base + volume_id_offset)
413    } else {
414        None
415    };
416
417    let local_base_path = if flags & VOLUME_ID_AND_LOCAL_BASE_PATH != 0 {
418        if local_base_path_offset_unicode != 0 {
419            unicode_z(data, base + local_base_path_offset_unicode)
420        } else if local_base_path_offset != 0 {
421            ansi_z(data, base + local_base_path_offset)
422        } else {
423            None
424        }
425    } else {
426        None
427    };
428
429    let common_network_relative_link = if flags & CNRL_AND_PATH_SUFFIX != 0 && cnrl_offset != 0 {
430        parse_cnrl(data, base + cnrl_offset)
431    } else {
432        None
433    };
434
435    Some(LinkInfo {
436        volume_id,
437        local_base_path,
438        common_network_relative_link,
439    })
440}
441
442/// Parse the §2.3.1 VolumeID anchored at `base`.
443fn parse_volume_id(data: &[u8], base: usize) -> Option<VolumeId> {
444    let size = le_u32(data, base) as usize;
445    if size < 0x10 {
446        return None;
447    }
448    let drive_type = le_u32(data, base + 4);
449    let drive_serial_number = le_u32(data, base + 8);
450    let label_offset = le_u32(data, base + 12) as usize;
451
452    // VolumeLabelOffset == 0x14 signals the Unicode label offset lives at +0x10.
453    let volume_label = if label_offset == 0x14 {
454        let uni_off = le_u32(data, base + 16) as usize;
455        unicode_z(data, base + uni_off)
456    } else if label_offset != 0 {
457        ansi_z(data, base + label_offset)
458    } else {
459        None
460    }
461    .filter(|s| !s.is_empty());
462
463    Some(VolumeId {
464        drive_type,
465        drive_serial_number,
466        volume_label,
467    })
468}
469
470/// Parse the §2.3.2 CommonNetworkRelativeLink anchored at `base`.
471fn parse_cnrl(data: &[u8], base: usize) -> Option<CommonNetworkRelativeLink> {
472    let size = le_u32(data, base) as usize;
473    if size < 0x14 {
474        return None;
475    }
476    let flags = le_u32(data, base + 4);
477    let net_name_offset = le_u32(data, base + 8) as usize;
478    let device_name_offset = le_u32(data, base + 12) as usize;
479
480    const VALID_DEVICE: u32 = 0x1;
481
482    let net_name = if net_name_offset != 0 {
483        ansi_z(data, base + net_name_offset)
484    } else {
485        None
486    };
487    let device_name = if flags & VALID_DEVICE != 0 && device_name_offset != 0 {
488        ansi_z(data, base + device_name_offset)
489    } else {
490        None
491    };
492
493    Some(CommonNetworkRelativeLink {
494        net_name,
495        device_name,
496    })
497}
498
499/// Read a §2.4 size-counted string: a u16 CountCharacters then the chars.
500/// Returns the decoded value (when non-empty) and the offset just past it.
501fn read_sized_string(data: &[u8], off: usize, is_unicode: bool) -> (Option<String>, usize) {
502    let count = le_u16(data, off) as usize;
503    let body = off + 2;
504    if is_unicode {
505        let byte_len = count * 2;
506        let value = data
507            .get(body..body + byte_len)
508            .map(decode_utf16le)
509            .filter(|s| !s.is_empty());
510        (value, body + byte_len)
511    } else {
512        let value = data
513            .get(body..body + count)
514            .map(|s| String::from_utf8_lossy(s).into_owned())
515            .filter(|s| !s.is_empty());
516        (value, body + count)
517    }
518}
519
520fn decode_utf16le(bytes: &[u8]) -> String {
521    let units: Vec<u16> = bytes
522        .chunks_exact(2)
523        .map(|c| u16::from_le_bytes([c[0], c[1]]))
524        .collect();
525    String::from_utf16_lossy(&units)
526}
527
528/// Walk the §2.5 ExtraData chain and return the TrackerDataBlock if present.
529fn parse_extra_data_tracker(data: &[u8], start: usize) -> Option<TrackerDataBlock> {
530    let mut off = start;
531    // Bound the walk by the buffer length; a size < 0x4 terminates the chain.
532    while off + 8 <= data.len() {
533        let block_size = le_u32(data, off) as usize;
534        if (block_size as u32) < shlink::EXTRA_DATA_TERMINAL_BLOCK_SIZE {
535            break;
536        }
537        let signature = le_u32(data, off + 4);
538        if signature == shlink::EXTRA_TRACKER_DATA_BLOCK {
539            return parse_tracker_block(data, off);
540        }
541        // Advance past this block; a zero/under-size block would loop forever.
542        if block_size < 4 {
543            break; // cov:unreachable: block_size >= 0x4 guaranteed by the check above
544        }
545        off += block_size;
546    }
547    None
548}
549
550/// Parse the §2.5.10 TrackerDataBlock anchored at `base`.
551fn parse_tracker_block(data: &[u8], base: usize) -> Option<TrackerDataBlock> {
552    // Layout from base: +0 BlockSize, +4 BlockSignature, +8 Length, +12 Version,
553    // +16 MachineID[16] (ASCII, NUL-padded), +32 Droid (32 bytes = 2 GUIDs),
554    // +64 DroidBirth (32 bytes = 2 GUIDs).
555    let machine_id = ansi_z(data, base + 16)?;
556    let droid = DroidGuids {
557        volume: guid_string(data.get(base + 32..base + 48)?)?,
558        object: guid_string(data.get(base + 48..base + 64)?)?,
559    };
560    let birth_droid = DroidGuids {
561        volume: guid_string(data.get(base + 64..base + 80)?)?,
562        object: guid_string(data.get(base + 80..base + 96)?)?,
563    };
564    Some(TrackerDataBlock {
565        machine_id,
566        droid,
567        birth_droid,
568    })
569}
570
571#[cfg(test)]
572mod tests {
573    include!("tests.rs");
574}