Skip to main content

peripheral_core/
mountpoints2.rs

1//! Per-user volume mounts: decode `MountPoints2` from a Windows `NTUSER.DAT` hive.
2//!
3//! `NTUSER\Software\Microsoft\Windows\CurrentVersion\Explorer\MountPoints2\{GUID}` records
4//! every volume the user mounted, keyed by the volume GUID. The subkey's last-written time
5//! is the last time that user mounted the volume. This is per-user attribution — *which
6//! user* connected a device — that no machine-wide source provides; the volume GUID joins
7//! (via the `MountedDevices` MBR bridge) to a drive letter and its cached volume label.
8
9use crate::Provenance;
10use std::io::Cursor;
11use winreg_core::hive::Hive;
12use winreg_core::key::Key;
13
14/// The `MountPoints2` path in an `NTUSER.DAT` (user) hive.
15const MP2_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\MountPoints2";
16
17/// One per-user volume mount: the volume GUID and the last time the user mounted it.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UserMount {
20    /// The mounted volume's GUID (lower-cased, braces kept).
21    pub volume_guid: String,
22    /// Last-mounted time (the subkey's last-written `FILETIME`), epoch seconds UTC;
23    /// `None` when the hive recorded no timestamp.
24    pub last_mounted: Option<i64>,
25    /// Where the record was decoded from.
26    pub source: Provenance,
27}
28
29/// Parse per-user `MountPoints2` volume mounts from an already-opened `NTUSER.DAT` hive.
30/// Total over a valid hive: a non-`{GUID}` mount name (a UNC/`##server#share` entry) is
31/// skipped, never panicked on. `file` is recorded as each record's [`Provenance`] file.
32#[must_use]
33pub fn parse_mountpoints2(hive: &Hive<Cursor<Vec<u8>>>, file: &str) -> Vec<UserMount> {
34    let Ok(Some(mp2)) = hive.open_key(MP2_PATH) else {
35        return Vec::new();
36    };
37    let Ok(subkeys) = mp2.subkeys() else {
38        return Vec::new(); // cov:unreachable: subkeys() only errors on hive corruption
39    };
40    let mut out = Vec::new();
41    for sub in subkeys {
42        let name = sub.name();
43        if !(name.starts_with('{') && name.ends_with('}')) {
44            continue;
45        }
46        out.push(UserMount {
47            volume_guid: name.to_ascii_lowercase(),
48            last_mounted: last_written_epoch(&sub),
49            source: Provenance {
50                file: file.to_string(),
51                line: 0,
52                key_path: Some(format!("{MP2_PATH}\\{name}")),
53            },
54        });
55    }
56    out
57}
58
59/// A key's last-written time as epoch seconds UTC; `None` when the hive recorded none.
60fn last_written_epoch(key: &Key<'_>) -> Option<i64> {
61    Some(key.last_written()?.as_second())
62}
63
64#[cfg(test)]
65#[allow(clippy::unwrap_used, clippy::expect_used)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn synthetic_mountpoints2_decodes_guid_mounts_only() {
71        // Synthetic NTUSER hive: one volume-GUID mount (key last-write epoch 1427230953)
72        // and a `##server#share` UNC entry that must be skipped.
73        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_mountpoints2.hive");
74        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
75        let mounts = parse_mountpoints2(&hive, "NTUSER.DAT");
76        assert_eq!(mounts.len(), 1);
77        assert_eq!(
78            mounts[0].volume_guid,
79            "{a2f2048e-d228-11e4-b630-000c29ff2429}"
80        );
81        assert_eq!(mounts[0].last_mounted, Some(1_427_230_953));
82        assert!(mounts[0].source.key_path.is_some());
83    }
84
85    #[test]
86    fn a_hive_without_mountpoints2_yields_nothing() {
87        // The synthetic USBSTOR SYSTEM hive has no MountPoints2 key → empty, no panic.
88        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_usb_system.hive");
89        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
90        assert!(parse_mountpoints2(&hive, "NTUSER.DAT").is_empty());
91    }
92}