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