Skip to main content

usb_forensic/sources/
mountpoints2.rs

1//! Adapter: `peripheral-core` [`UserMount`]s (`NTUSER` MountPoints2) → USB-history
2//! [`Claim`]s.
3//!
4//! Each `MountPoints2` record is a per-user attestation that a volume (by GUID) was
5//! mounted, with the subkey's last-write as the mount time. This adapter emits that as a
6//! [`Attribute::LastConnected`] claim keyed by the **volume GUID**; the volume
7//! canonicalization then ties the GUID (via the `MountedDevices` MBR bridge) to a drive
8//! letter and its cached label, so a user's mount, the drive letter, and the volume label
9//! land on one device record.
10
11use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12use peripheral_core::mountpoints2::UserMount;
13
14/// A [`HistorySource`] over decoded per-user [`UserMount`]s.
15pub struct MountPoints2Source<'a> {
16    mounts: &'a [UserMount],
17}
18
19impl<'a> MountPoints2Source<'a> {
20    /// Wrap decoded mounts (from `peripheral_core::mountpoints2::parse_mountpoints2`).
21    #[must_use]
22    pub fn new(mounts: &'a [UserMount]) -> Self {
23        Self { mounts }
24    }
25}
26
27impl HistorySource for MountPoints2Source<'_> {
28    fn claims(&self) -> Vec<Claim> {
29        self.mounts
30            .iter()
31            .filter_map(|mount| {
32                // A mount with no recorded time carries no timeline fact.
33                let when = mount.last_mounted?;
34                Some(Claim {
35                    device: DeviceKey(mount.volume_guid.clone()),
36                    attribute: Attribute::LastConnected,
37                    value: Value::Timestamp(when),
38                    provenance: Provenance {
39                        source: SourceKind::MountPoints2,
40                        locator: mount
41                            .source
42                            .key_path
43                            .clone()
44                            .unwrap_or_else(|| mount.source.file.clone()),
45                    },
46                })
47            })
48            .collect()
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use peripheral_core::Provenance as PcProvenance;
56
57    fn mount(guid: &str, when: Option<i64>) -> UserMount {
58        UserMount {
59            volume_guid: guid.to_string(),
60            last_mounted: when,
61            source: PcProvenance {
62                file: "NTUSER.DAT".to_string(),
63                line: 0,
64                key_path: Some(format!("…\\MountPoints2\\{guid}")),
65            },
66        }
67    }
68
69    #[test]
70    fn a_timed_mount_yields_a_last_connected_claim_keyed_by_volume_guid() {
71        let mounts = [mount(
72            "{a2f2048e-d228-11e4-b630-000c29ff2429}",
73            Some(1_427_230_953),
74        )];
75        let claims = MountPoints2Source::new(&mounts).claims();
76        assert_eq!(claims.len(), 1);
77        assert_eq!(
78            claims[0].device,
79            DeviceKey("{a2f2048e-d228-11e4-b630-000c29ff2429}".to_string())
80        );
81        assert_eq!(claims[0].attribute, Attribute::LastConnected);
82        assert_eq!(claims[0].value, Value::Timestamp(1_427_230_953));
83        assert_eq!(claims[0].provenance.source, SourceKind::MountPoints2);
84    }
85
86    #[test]
87    fn a_mount_without_a_time_is_skipped() {
88        let mounts = [mount("{guid}", None)];
89        assert!(MountPoints2Source::new(&mounts).claims().is_empty());
90    }
91
92    #[test]
93    fn locator_falls_back_to_the_file_without_a_key_path() {
94        let mut m = mount("{guid}", Some(1));
95        m.source.key_path = None;
96        let mounts = [m];
97        let claims = MountPoints2Source::new(&mounts).claims();
98        assert_eq!(claims[0].provenance.locator, "NTUSER.DAT");
99    }
100}