1use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct LnkArtifact {
35 pub source_path: String,
39 pub link: lnk_core::ShellLink,
41}
42
43pub struct LnkSource<'a> {
45 artifacts: &'a [LnkArtifact],
46}
47
48impl<'a> LnkSource<'a> {
49 #[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
66fn format_volume_serial(serial: u32) -> String {
71 format!("{:04X}-{:04X}", serial >> 16, serial & 0xFFFF)
72}
73
74fn 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
102pub(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
114pub(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 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 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}