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