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