use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LnkArtifact {
pub source_path: String,
pub link: lnk_core::ShellLink,
}
pub struct LnkSource<'a> {
artifacts: &'a [LnkArtifact],
}
impl<'a> LnkSource<'a> {
#[must_use]
pub fn new(artifacts: &'a [LnkArtifact]) -> Self {
Self { artifacts }
}
}
impl HistorySource for LnkSource<'_> {
fn claims(&self) -> Vec<Claim> {
let mut out = Vec::new();
for artifact in self.artifacts {
push_artifact_claims(artifact, &mut out);
}
out
}
}
fn format_volume_serial(serial: u32) -> String {
format!("{:04X}-{:04X}", serial >> 16, serial & 0xFFFF)
}
fn target_path(link: &lnk_core::ShellLink) -> Option<String> {
let from_info = link.link_info.as_ref().and_then(|info| {
info.local_base_path.clone().or_else(|| {
info.common_network_relative_link
.as_ref()
.and_then(|cnrl| cnrl.net_name.clone())
})
});
from_info
.or_else(|| {
link.link_target_idlist
.as_ref()
.and_then(|t| t.path.clone())
})
.filter(|p| !p.is_empty())
}
fn push_artifact_claims(artifact: &LnkArtifact, out: &mut Vec<Claim>) {
out.extend(shell_link_claims(
&artifact.link,
SourceKind::Lnk,
&artifact.source_path,
));
}
pub(crate) fn link_volume_serial(link: &lnk_core::ShellLink) -> Option<String> {
let serial = link
.link_info
.as_ref()?
.volume_id
.as_ref()?
.drive_serial_number;
(serial != 0).then(|| format_volume_serial(serial))
}
pub(crate) fn shell_link_claims(
link: &lnk_core::ShellLink,
source: SourceKind,
locator: &str,
) -> Vec<Claim> {
let mut out = Vec::new();
let Some(serial_str) = link_volume_serial(link) else {
return out;
};
let device = DeviceKey(serial_str.clone());
let provenance = Provenance {
source,
locator: locator.to_string(),
};
out.push(Claim {
device: device.clone(),
attribute: Attribute::VolumeSerial,
value: Value::Text(serial_str),
provenance: provenance.clone(),
});
if let Some(path) = target_path(link) {
out.push(Claim {
device,
attribute: Attribute::AccessedFile,
value: Value::Text(path),
provenance,
});
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use lnk_core::{
CommonNetworkRelativeLink, LinkInfo, LinkTargetIdList, ShellLink, ShellLinkHeader,
StringData, VolumeId,
};
fn header() -> ShellLinkHeader {
ShellLinkHeader {
link_flags: 0,
file_attributes: 0,
creation_time: 0,
access_time: 0,
write_time: 0,
file_size: 0,
icon_index: 0,
show_command: 1,
hotkey: 0,
}
}
fn link(
volume_id: Option<VolumeId>,
local_base_path: Option<&str>,
cnrl: Option<CommonNetworkRelativeLink>,
idlist_path: Option<&str>,
) -> ShellLink {
let link_info = if volume_id.is_some() || local_base_path.is_some() || cnrl.is_some() {
Some(LinkInfo {
volume_id,
local_base_path: local_base_path.map(ToString::to_string),
common_network_relative_link: cnrl,
})
} else {
None
};
ShellLink {
header: header(),
link_target_idlist: idlist_path.map(|p| LinkTargetIdList {
raw: Vec::new(),
items: Vec::new(),
path: Some(p.to_string()),
}),
link_info,
string_data: StringData::default(),
tracker: None,
}
}
fn removable(serial: u32) -> VolumeId {
VolumeId {
drive_type: lnk_core::drive_type::REMOVABLE,
drive_serial_number: serial,
volume_label: None,
}
}
fn artifact(source_path: &str, link: ShellLink) -> LnkArtifact {
LnkArtifact {
source_path: source_path.to_string(),
link,
}
}
fn claims_for(source_path: &str, link: ShellLink) -> Vec<Claim> {
let arts = [artifact(source_path, link)];
LnkSource::new(&arts).claims()
}
#[test]
fn volume_serial_is_canonical_padded_hex() {
assert_eq!(format_volume_serial(0xDEAD_BEEF), "DEAD-BEEF");
assert_eq!(format_volume_serial(1), "0000-0001");
assert_eq!(format_volume_serial(0), "0000-0000");
}
#[test]
fn local_target_yields_volume_serial_and_accessed_file() {
let claims = claims_for(
"C:\\Users\\a\\Recent\\secret.lnk",
link(
Some(removable(0xDEAD_BEEF)),
Some("E:\\secret.docx"),
None,
None,
),
);
assert_eq!(claims.len(), 2);
let vs = &claims[0];
assert_eq!(vs.device, DeviceKey("DEAD-BEEF".to_string()));
assert_eq!(vs.attribute, Attribute::VolumeSerial);
assert_eq!(vs.value, Value::Text("DEAD-BEEF".to_string()));
assert_eq!(vs.provenance.source, SourceKind::Lnk);
assert_eq!(vs.provenance.locator, "C:\\Users\\a\\Recent\\secret.lnk");
let af = &claims[1];
assert_eq!(af.device, DeviceKey("DEAD-BEEF".to_string()));
assert_eq!(af.attribute, Attribute::AccessedFile);
assert_eq!(af.value, Value::Text("E:\\secret.docx".to_string()));
assert_eq!(af.provenance.source, SourceKind::Lnk);
}
#[test]
fn serial_without_resolvable_path_yields_only_volume_serial() {
let claims = claims_for(
"x.lnk",
link(Some(removable(0x1234_5678)), None, None, None),
);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].attribute, Attribute::VolumeSerial);
assert_eq!(claims[0].value, Value::Text("1234-5678".to_string()));
}
#[test]
fn empty_local_base_path_is_not_a_target() {
let claims = claims_for(
"x.lnk",
link(Some(removable(0x0000_0001)), Some(""), None, None),
);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].attribute, Attribute::VolumeSerial);
}
#[test]
fn network_name_used_as_target_when_no_local_path() {
let cnrl = CommonNetworkRelativeLink {
net_name: Some("\\\\server\\share".to_string()),
device_name: None,
};
let claims = claims_for(
"x.lnk",
link(Some(removable(0xABCD_0000)), None, Some(cnrl), None),
);
assert_eq!(claims.len(), 2);
assert_eq!(claims[1].attribute, Attribute::AccessedFile);
assert_eq!(
claims[1].value,
Value::Text("\\\\server\\share".to_string())
);
}
#[test]
fn cnrl_without_net_name_falls_through_to_idlist_path() {
let cnrl = CommonNetworkRelativeLink {
net_name: None,
device_name: Some("Z:".to_string()),
};
let claims = claims_for(
"x.lnk",
link(
Some(removable(0x0001_0002)),
None,
Some(cnrl),
Some("My Computer\\E:\\photo.jpg"),
),
);
assert_eq!(claims.len(), 2);
assert_eq!(claims[1].attribute, Attribute::AccessedFile);
assert_eq!(
claims[1].value,
Value::Text("My Computer\\E:\\photo.jpg".to_string())
);
}
#[test]
fn zero_serial_is_skipped() {
let claims = claims_for(
"x.lnk",
link(Some(removable(0)), Some("E:\\f.txt"), None, None),
);
assert!(claims.is_empty());
}
#[test]
fn link_without_volume_id_is_skipped() {
let claims = claims_for("x.lnk", link(None, Some("C:\\local.txt"), None, None));
assert!(claims.is_empty());
}
#[test]
fn link_without_link_info_is_skipped() {
let claims = claims_for("x.lnk", link(None, None, None, Some("My Computer\\C:\\x")));
assert!(claims.is_empty());
}
#[test]
fn multiple_artifacts_accumulate() {
let arts = [
artifact(
"a.lnk",
link(Some(removable(0x1111_2222)), Some("E:\\a"), None, None),
),
artifact(
"b.lnk",
link(Some(removable(0x3333_4444)), Some("F:\\b"), None, None),
),
];
let claims = LnkSource::new(&arts).claims();
assert_eq!(claims.len(), 4);
assert_eq!(claims[0].device, DeviceKey("1111-2222".to_string()));
assert_eq!(claims[3].device, DeviceKey("3333-4444".to_string()));
}
}