Skip to main content

usb_forensic/sources/
jumplist.rs

1//! Adapter: `lnk-core` Jump Lists → USB-history [`Claim`]s.
2//!
3//! A Jump List (`*.automaticDestinations-ms` / `*.customDestinations-ms`) records a
4//! per-application MRU whose entries each embed a Shell Link. This adapter reuses the
5//! LNK mapping for each embedded link (volume serial + accessed file) and, for
6//! automatic destinations, adds the `DestList` **last-access** time as a
7//! `LastConnected` signal: a file accessed on a volume at time *T* is evidence the
8//! volume was mounted at *T*. A pure mapping over already-decoded records.
9
10use crate::sources::lnk::{link_volume_serial, shell_link_claims};
11use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12use lnk_core::JumpList;
13
14/// A parsed Jump List paired with the on-disk locator it was read from (the decoded
15/// [`JumpList`] does not record its own path).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct JumpListArtifact {
18    /// Precise pointer to the source jump-list file, used in the [`Provenance`] locator.
19    pub source_path: String,
20    /// The already-decoded Jump List.
21    pub list: JumpList,
22}
23
24/// A [`HistorySource`] over parsed Jump Lists.
25pub struct JumpListSource<'a> {
26    artifacts: &'a [JumpListArtifact],
27}
28
29impl<'a> JumpListSource<'a> {
30    /// Wrap parsed Jump Lists (each paired with its source locator).
31    #[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                // DestList last-access → LastConnected for the file's volume.
50                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            // destlist present but last_access == 0 → no LastConnected
155            JumpListEntry {
156                destlist: Some(destlist(0)),
157                link: link(0x1111_2222),
158            },
159            // volume serial absent → link_volume_serial None → no claims at all
160            JumpListEntry {
161                destlist: Some(destlist(1_700_000_000)),
162                link: link(0),
163            },
164            // no destlist → skip the DestList branch
165            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}