Skip to main content

usb_forensic/sources/
lnk.rs

1//! Adapter: `lnk-core` Shell Links → USB-history [`Claim`]s (the volume-serial join).
2//!
3//! A Windows Shell Link (`.lnk`) records the target file's path together with the
4//! `VolumeID` `DriveSerialNumber` of the volume the target lived on. That drive
5//! serial is a **volume** serial, not a device/instance serial — so a `.lnk`
6//! cannot on its own name a USB device. What it *can* do is tie a **file access**
7//! to a **volume**; the correlation engine then reconciles that volume serial with
8//! the device that carried it (a volume serial reported by `MountedDevices`, the
9//! Partition/Diagnostic event log, or `VolumeInfoCache`). This adapter emits that
10//! join material and nothing more:
11//!
12//! - a [`Attribute::VolumeSerial`] claim carrying the canonical volume serial, so
13//!   the value is discoverable and matchable against other sources' serials, and
14//! - a [`Attribute::AccessedFile`] claim carrying the target path,
15//!
16//! both keyed by a [`DeviceKey`] holding the **volume** serial. See the module
17//! `blockers` note: this `DeviceKey` is a volume-serial identity, which the core must
18//! reconcile into a device identity — it is deliberately not a device serial.
19//!
20//! This is a pure mapping: it consumes [`lnk_core::ShellLink`] values the reader has
21//! already decoded and never touches raw bytes. Verified against `lnk-core` 0.4.0
22//! (`ShellLink` / `LinkInfo` / `VolumeId` / `CommonNetworkRelativeLink` /
23//! `LinkTargetIdList`, all public-field structs).
24
25use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
26
27/// A parsed Shell Link paired with the on-disk locator of the `.lnk` it came from.
28///
29/// The reader's [`lnk_core::ShellLink`] carries no record of which file it was
30/// parsed from, but a [`Provenance`] locator must point back at the artifact — so
31/// the caller pairs each decoded link with the path (or jump-list stream pointer)
32/// it was read from. This is the one field the decoded structure cannot supply.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct LnkArtifact {
35    /// Precise pointer to the source `.lnk` (e.g. a full path, or
36    /// `jumplist:<appid>#<n>` for an embedded link). Used verbatim as the
37    /// [`Provenance::locator`].
38    pub source_path: String,
39    /// The already-decoded Shell Link.
40    pub link: lnk_core::ShellLink,
41}
42
43/// A [`HistorySource`] over a set of parsed Shell Links.
44pub struct LnkSource<'a> {
45    artifacts: &'a [LnkArtifact],
46}
47
48impl<'a> LnkSource<'a> {
49    /// Wrap parsed Shell Links (each paired with its source locator).
50    #[must_use]
51    pub fn new(artifacts: &'a [LnkArtifact]) -> Self {
52        Self { artifacts }
53    }
54}
55
56impl HistorySource for LnkSource<'_> {
57    fn claims(&self) -> Vec<Claim> {
58        let mut out = Vec::new();
59        for artifact in self.artifacts {
60            push_artifact_claims(artifact, &mut out);
61        }
62        out
63    }
64}
65
66/// Render a Windows volume serial the way `dir` / `vol` display it: two uppercase
67/// hex halves joined by a dash, zero-padded (e.g. `0xDEADBEEF` → `DEAD-BEEF`,
68/// `0x00000001` → `0000-0001`). A stable canonical form so the same volume serial
69/// from any source produces the same string to join on.
70fn format_volume_serial(serial: u32) -> String {
71    format!("{:04X}-{:04X}", serial >> 16, serial & 0xFFFF)
72}
73
74/// Resolve the Shell Link's target path: the local base path when present, else the
75/// network (UNC) name, else the reconstructed `LinkTargetIDList` path. Returns
76/// `None` when no non-empty target can be recovered.
77fn target_path(link: &lnk_core::ShellLink) -> Option<String> {
78    let from_info = link.link_info.as_ref().and_then(|info| {
79        info.local_base_path.clone().or_else(|| {
80            info.common_network_relative_link
81                .as_ref()
82                .and_then(|cnrl| cnrl.net_name.clone())
83        })
84    });
85    from_info
86        .or_else(|| {
87            link.link_target_idlist
88                .as_ref()
89                .and_then(|t| t.path.clone())
90        })
91        .filter(|p| !p.is_empty())
92}
93
94fn push_artifact_claims(artifact: &LnkArtifact, out: &mut Vec<Claim>) {
95    out.extend(shell_link_claims(
96        &artifact.link,
97        SourceKind::Lnk,
98        &artifact.source_path,
99    ));
100}
101
102/// The canonical volume serial (`DEAD-BEEF`) of a Shell Link's `VolumeID`, or `None`
103/// when the link has no volume id or the serial is the `0` unset sentinel.
104pub(crate) fn link_volume_serial(link: &lnk_core::ShellLink) -> Option<String> {
105    let serial = link
106        .link_info
107        .as_ref()?
108        .volume_id
109        .as_ref()?
110        .drive_serial_number;
111    (serial != 0).then(|| format_volume_serial(serial))
112}
113
114/// Map one decoded Shell Link into its volume-serial-join claims, tagged with the given
115/// source and locator. Shared by the LNK and jump-list adapters (a jump-list entry
116/// embeds a Shell Link). A link contributes only when it carries a `VolumeID` with a
117/// non-zero drive serial (serial `0` is the "unset" sentinel — no volume to key on, so
118/// it is skipped rather than emitting a bogus `0000-0000` device).
119pub(crate) fn shell_link_claims(
120    link: &lnk_core::ShellLink,
121    source: SourceKind,
122    locator: &str,
123) -> Vec<Claim> {
124    let mut out = Vec::new();
125    let Some(serial_str) = link_volume_serial(link) else {
126        return out;
127    };
128    let device = DeviceKey(serial_str.clone());
129    let provenance = Provenance {
130        source,
131        locator: locator.to_string(),
132    };
133
134    out.push(Claim {
135        device: device.clone(),
136        attribute: Attribute::VolumeSerial,
137        value: Value::Text(serial_str),
138        provenance: provenance.clone(),
139    });
140    if let Some(path) = target_path(link) {
141        out.push(Claim {
142            device,
143            attribute: Attribute::AccessedFile,
144            value: Value::Text(path),
145            provenance,
146        });
147    }
148    out
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used)]
153mod tests {
154    use super::*;
155    use lnk_core::{
156        CommonNetworkRelativeLink, LinkInfo, LinkTargetIdList, ShellLink, ShellLinkHeader,
157        StringData, VolumeId,
158    };
159
160    fn header() -> ShellLinkHeader {
161        ShellLinkHeader {
162            link_flags: 0,
163            file_attributes: 0,
164            creation_time: 0,
165            access_time: 0,
166            write_time: 0,
167            file_size: 0,
168            icon_index: 0,
169            show_command: 1,
170            hotkey: 0,
171        }
172    }
173
174    fn link(
175        volume_id: Option<VolumeId>,
176        local_base_path: Option<&str>,
177        cnrl: Option<CommonNetworkRelativeLink>,
178        idlist_path: Option<&str>,
179    ) -> ShellLink {
180        let link_info = if volume_id.is_some() || local_base_path.is_some() || cnrl.is_some() {
181            Some(LinkInfo {
182                volume_id,
183                local_base_path: local_base_path.map(ToString::to_string),
184                common_network_relative_link: cnrl,
185            })
186        } else {
187            None
188        };
189        ShellLink {
190            header: header(),
191            link_target_idlist: idlist_path.map(|p| LinkTargetIdList {
192                raw: Vec::new(),
193                items: Vec::new(),
194                path: Some(p.to_string()),
195            }),
196            link_info,
197            string_data: StringData::default(),
198            tracker: None,
199        }
200    }
201
202    fn removable(serial: u32) -> VolumeId {
203        VolumeId {
204            drive_type: lnk_core::drive_type::REMOVABLE,
205            drive_serial_number: serial,
206            volume_label: None,
207        }
208    }
209
210    fn artifact(source_path: &str, link: ShellLink) -> LnkArtifact {
211        LnkArtifact {
212            source_path: source_path.to_string(),
213            link,
214        }
215    }
216
217    fn claims_for(source_path: &str, link: ShellLink) -> Vec<Claim> {
218        let arts = [artifact(source_path, link)];
219        LnkSource::new(&arts).claims()
220    }
221
222    #[test]
223    fn volume_serial_is_canonical_padded_hex() {
224        assert_eq!(format_volume_serial(0xDEAD_BEEF), "DEAD-BEEF");
225        assert_eq!(format_volume_serial(1), "0000-0001");
226        assert_eq!(format_volume_serial(0), "0000-0000");
227    }
228
229    #[test]
230    fn local_target_yields_volume_serial_and_accessed_file() {
231        let claims = claims_for(
232            "C:\\Users\\a\\Recent\\secret.lnk",
233            link(
234                Some(removable(0xDEAD_BEEF)),
235                Some("E:\\secret.docx"),
236                None,
237                None,
238            ),
239        );
240        assert_eq!(claims.len(), 2);
241
242        let vs = &claims[0];
243        assert_eq!(vs.device, DeviceKey("DEAD-BEEF".to_string()));
244        assert_eq!(vs.attribute, Attribute::VolumeSerial);
245        assert_eq!(vs.value, Value::Text("DEAD-BEEF".to_string()));
246        assert_eq!(vs.provenance.source, SourceKind::Lnk);
247        assert_eq!(vs.provenance.locator, "C:\\Users\\a\\Recent\\secret.lnk");
248
249        let af = &claims[1];
250        assert_eq!(af.device, DeviceKey("DEAD-BEEF".to_string()));
251        assert_eq!(af.attribute, Attribute::AccessedFile);
252        assert_eq!(af.value, Value::Text("E:\\secret.docx".to_string()));
253        assert_eq!(af.provenance.source, SourceKind::Lnk);
254    }
255
256    #[test]
257    fn serial_without_resolvable_path_yields_only_volume_serial() {
258        let claims = claims_for(
259            "x.lnk",
260            link(Some(removable(0x1234_5678)), None, None, None),
261        );
262        assert_eq!(claims.len(), 1);
263        assert_eq!(claims[0].attribute, Attribute::VolumeSerial);
264        assert_eq!(claims[0].value, Value::Text("1234-5678".to_string()));
265    }
266
267    #[test]
268    fn empty_local_base_path_is_not_a_target() {
269        // A present-but-empty base path must not become a pathless AccessedFile.
270        let claims = claims_for(
271            "x.lnk",
272            link(Some(removable(0x0000_0001)), Some(""), None, None),
273        );
274        assert_eq!(claims.len(), 1);
275        assert_eq!(claims[0].attribute, Attribute::VolumeSerial);
276    }
277
278    #[test]
279    fn network_name_used_as_target_when_no_local_path() {
280        let cnrl = CommonNetworkRelativeLink {
281            net_name: Some("\\\\server\\share".to_string()),
282            device_name: None,
283        };
284        let claims = claims_for(
285            "x.lnk",
286            link(Some(removable(0xABCD_0000)), None, Some(cnrl), None),
287        );
288        assert_eq!(claims.len(), 2);
289        assert_eq!(claims[1].attribute, Attribute::AccessedFile);
290        assert_eq!(
291            claims[1].value,
292            Value::Text("\\\\server\\share".to_string())
293        );
294    }
295
296    #[test]
297    fn cnrl_without_net_name_falls_through_to_idlist_path() {
298        let cnrl = CommonNetworkRelativeLink {
299            net_name: None,
300            device_name: Some("Z:".to_string()),
301        };
302        let claims = claims_for(
303            "x.lnk",
304            link(
305                Some(removable(0x0001_0002)),
306                None,
307                Some(cnrl),
308                Some("My Computer\\E:\\photo.jpg"),
309            ),
310        );
311        assert_eq!(claims.len(), 2);
312        assert_eq!(claims[1].attribute, Attribute::AccessedFile);
313        assert_eq!(
314            claims[1].value,
315            Value::Text("My Computer\\E:\\photo.jpg".to_string())
316        );
317    }
318
319    #[test]
320    fn zero_serial_is_skipped() {
321        let claims = claims_for(
322            "x.lnk",
323            link(Some(removable(0)), Some("E:\\f.txt"), None, None),
324        );
325        assert!(claims.is_empty());
326    }
327
328    #[test]
329    fn link_without_volume_id_is_skipped() {
330        // A LinkInfo with no VolumeID (e.g. a pure network target) has no volume
331        // serial to key on, so it contributes no device-join claim.
332        let claims = claims_for("x.lnk", link(None, Some("C:\\local.txt"), None, None));
333        assert!(claims.is_empty());
334    }
335
336    #[test]
337    fn link_without_link_info_is_skipped() {
338        let claims = claims_for("x.lnk", link(None, None, None, Some("My Computer\\C:\\x")));
339        assert!(claims.is_empty());
340    }
341
342    #[test]
343    fn multiple_artifacts_accumulate() {
344        let arts = [
345            artifact(
346                "a.lnk",
347                link(Some(removable(0x1111_2222)), Some("E:\\a"), None, None),
348            ),
349            artifact(
350                "b.lnk",
351                link(Some(removable(0x3333_4444)), Some("F:\\b"), None, None),
352            ),
353        ];
354        let claims = LnkSource::new(&arts).claims();
355        assert_eq!(claims.len(), 4);
356        assert_eq!(claims[0].device, DeviceKey("1111-2222".to_string()));
357        assert_eq!(claims[3].device, DeviceKey("3333-4444".to_string()));
358    }
359}