usb_forensic/sources/
jumplist.rs1use crate::sources::lnk::{link_volume_serial, shell_link_claims};
11use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12use lnk_core::JumpList;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct JumpListArtifact {
18 pub source_path: String,
20 pub list: JumpList,
22}
23
24pub struct JumpListSource<'a> {
26 artifacts: &'a [JumpListArtifact],
27}
28
29impl<'a> JumpListSource<'a> {
30 #[must_use]
32 pub fn new(artifacts: &'a [JumpListArtifact]) -> Self {
33 Self { artifacts }
34 }
35}
36
37impl HistorySource for JumpListSource<'_> {
38 fn claims(&self) -> Vec<Claim> {
39 let mut out = Vec::new();
40 for artifact in self.artifacts {
41 for (idx, entry) in artifact.list.entries.iter().enumerate() {
42 let locator = format!("{}#{idx}", artifact.source_path);
43 out.extend(shell_link_claims(
44 &entry.link,
45 SourceKind::JumpList,
46 &locator,
47 ));
48
49 if let Some(destlist) = &entry.destlist {
51 if destlist.last_access != 0 {
52 if let Some(serial) = link_volume_serial(&entry.link) {
53 out.push(Claim {
54 device: DeviceKey(serial),
55 attribute: Attribute::LastConnected,
56 value: Value::Timestamp(destlist.last_access),
57 provenance: Provenance {
58 source: SourceKind::JumpList,
59 locator,
60 },
61 });
62 }
63 }
64 }
65 }
66 }
67 out
68 }
69}
70
71#[cfg(test)]
72#[allow(clippy::unwrap_used, clippy::expect_used)]
73mod tests {
74 use super::*;
75 use lnk_core::{
76 DestListEntry, JumpListEntry, JumpListKind, LinkInfo, ShellLink, ShellLinkHeader,
77 StringData, VolumeId,
78 };
79
80 fn link(serial: u32) -> ShellLink {
81 ShellLink {
82 header: ShellLinkHeader {
83 link_flags: 0,
84 file_attributes: 0,
85 creation_time: 0,
86 access_time: 0,
87 write_time: 0,
88 file_size: 0,
89 icon_index: 0,
90 show_command: 1,
91 hotkey: 0,
92 },
93 link_target_idlist: None,
94 link_info: (serial != 0).then(|| LinkInfo {
95 volume_id: Some(VolumeId {
96 drive_type: lnk_core::drive_type::REMOVABLE,
97 drive_serial_number: serial,
98 volume_label: None,
99 }),
100 local_base_path: Some("E:\\x".to_string()),
101 common_network_relative_link: None,
102 }),
103 string_data: StringData::default(),
104 tracker: None,
105 }
106 }
107
108 fn destlist(last_access: i64) -> DestListEntry {
109 DestListEntry {
110 droid_volume_guid: String::new(),
111 droid_file_guid: String::new(),
112 birth_droid_volume_guid: String::new(),
113 birth_droid_file_guid: String::new(),
114 hostname: "HOST".into(),
115 entry_number: 1,
116 last_access,
117 pinned: false,
118 access_count: None,
119 path: "E:\\x".into(),
120 }
121 }
122
123 fn jumplist(entries: Vec<JumpListEntry>) -> JumpListArtifact {
124 JumpListArtifact {
125 source_path: "1234.automaticDestinations-ms".into(),
126 list: JumpList {
127 kind: JumpListKind::Automatic,
128 app_id: Some("1234".into()),
129 entries,
130 },
131 }
132 }
133
134 #[test]
135 fn destlist_access_time_becomes_a_last_connected_claim() {
136 let arts = [jumplist(vec![JumpListEntry {
137 destlist: Some(destlist(1_700_000_000)),
138 link: link(0xDEAD_BEEF),
139 }])];
140 let claims = JumpListSource::new(&arts).claims();
141 assert!(claims.iter().any(|c| c.attribute == Attribute::VolumeSerial
142 && c.provenance.source == SourceKind::JumpList));
143 let last = claims
144 .iter()
145 .find(|c| c.attribute == Attribute::LastConnected)
146 .expect("last-connected from DestList access time");
147 assert_eq!(last.value, Value::Timestamp(1_700_000_000));
148 assert_eq!(last.device, DeviceKey("DEAD-BEEF".into()));
149 }
150
151 #[test]
152 fn zero_access_time_and_missing_serial_add_no_last_connected() {
153 let arts = [jumplist(vec![
154 JumpListEntry {
156 destlist: Some(destlist(0)),
157 link: link(0x1111_2222),
158 },
159 JumpListEntry {
161 destlist: Some(destlist(1_700_000_000)),
162 link: link(0),
163 },
164 JumpListEntry {
166 destlist: None,
167 link: link(0x3333_4444),
168 },
169 ])];
170 let claims = JumpListSource::new(&arts).claims();
171 assert!(claims
172 .iter()
173 .all(|c| c.attribute != Attribute::LastConnected));
174 }
175}