Skip to main content

peripheral_core/
mounted_volumes.rs

1//! MBR-signature volume bridge: decode `MountedDevices` MBR records from a Windows
2//! `SYSTEM` hive.
3//!
4//! `MountedDevices` stores, for each mount name, a REG_BINARY value. A 12-byte value is an
5//! **MBR record**: a 4-byte disk signature followed by an 8-byte partition byte-offset.
6//! Both a drive letter (`\DosDevices\X:`) and a volume GUID (`\??\Volume{…}`) point at the
7//! same MBR record for one volume — so records sharing a `(disk_signature, partition_offset)`
8//! name the *same volume*. That equivalence is the bridge the correlation layer uses to
9//! join a drive-letter fact (a volume label) to a volume-GUID fact (a per-user mount).
10//!
11//! (Device-path MountedDevices entries — which name a device instance directly — are
12//! handled by the `registry` reader's drive-letter join; this module covers the MBR form.)
13
14use crate::Provenance;
15use std::io::Cursor;
16use winreg_core::hive::Hive;
17
18/// One `MountedDevices` MBR record: a mount name resolved to its disk signature and
19/// partition offset. Volumes with equal `(disk_signature, partition_offset)` are the same.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MountedVolume {
22    /// The drive letter, when the mount name was `\DosDevices\X:` (upper-cased).
23    pub drive_letter: Option<char>,
24    /// The volume GUID, when the mount name was `\??\Volume{GUID}` (lower-cased, braces).
25    pub volume_guid: Option<String>,
26    /// MBR disk signature (the first 4 bytes of the record, little-endian).
27    pub disk_signature: u32,
28    /// Partition byte-offset within the disk (the next 8 bytes, little-endian).
29    pub partition_offset: u64,
30    /// Where the record was decoded from.
31    pub source: Provenance,
32}
33
34/// Parse `MountedDevices` MBR records (drive-letter and volume-GUID mount names) from an
35/// already-opened `SYSTEM` hive. Total over a valid hive: a non-MBR value (a device path,
36/// a wrong length) or an unrecognized mount name is skipped, never panicked on.
37#[must_use]
38pub fn parse_mounted_volumes(hive: &Hive<Cursor<Vec<u8>>>, file: &str) -> Vec<MountedVolume> {
39    let Ok(Some(md)) = hive.open_key("MountedDevices") else {
40        return Vec::new();
41    };
42    let Ok(values) = md.values() else {
43        return Vec::new(); // cov:unreachable: values() only errors on hive corruption
44    };
45    let mut out = Vec::new();
46    for value in values {
47        let name = value.name();
48        let drive_letter = dos_drive_letter(&name);
49        let volume_guid = volume_guid(&name);
50        if drive_letter.is_none() && volume_guid.is_none() {
51            continue;
52        }
53        let Ok(raw) = value.raw_data() else {
54            continue; // cov:unreachable: raw_data() only errors on hive corruption
55        };
56        let Some((disk_signature, partition_offset)) = decode_mbr(&raw) else {
57            continue;
58        };
59        out.push(MountedVolume {
60            drive_letter,
61            volume_guid,
62            disk_signature,
63            partition_offset,
64            source: Provenance {
65                file: file.to_string(),
66                line: 0,
67                key_path: Some(format!("MountedDevices\\{name}")),
68            },
69        });
70    }
71    out
72}
73
74/// Extract the drive letter from a `\DosDevices\X:` mount name, upper-cased.
75fn dos_drive_letter(name: &str) -> Option<char> {
76    let tail = name.strip_prefix("\\DosDevices\\")?;
77    let mut chars = tail.chars();
78    let letter = chars.next()?;
79    (letter.is_ascii_alphabetic() && chars.next() == Some(':') && chars.next().is_none())
80        .then(|| letter.to_ascii_uppercase())
81}
82
83/// Extract the volume GUID from a `\??\Volume{GUID}` mount name (lower-cased, braces kept).
84fn volume_guid(name: &str) -> Option<String> {
85    let g = name.strip_prefix("\\??\\Volume")?;
86    (g.starts_with('{') && g.ends_with('}')).then(|| g.to_ascii_lowercase())
87}
88
89/// Decode a 12-byte MBR `MountedDevices` value into `(disk_signature, partition_offset)`.
90/// `None` for any other length (a device-path string, a truncated record).
91fn decode_mbr(raw: &[u8]) -> Option<(u32, u64)> {
92    if raw.len() != 12 {
93        return None;
94    }
95    let sig = u32::from_le_bytes(raw.get(0..4)?.try_into().ok()?);
96    let offset = u64::from_le_bytes(raw.get(4..12)?.try_into().ok()?);
97    Some((sig, offset))
98}
99
100#[cfg(test)]
101#[allow(clippy::unwrap_used, clippy::expect_used)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn dos_drive_letter_reads_only_well_formed_names() {
107        assert_eq!(dos_drive_letter("\\DosDevices\\E:"), Some('E'));
108        assert_eq!(dos_drive_letter("\\DosDevices\\c:"), Some('C'));
109        assert_eq!(dos_drive_letter("\\??\\Volume{1234}"), None);
110        assert_eq!(dos_drive_letter("\\DosDevices\\EE:"), None);
111    }
112
113    #[test]
114    fn volume_guid_reads_only_volume_names() {
115        assert_eq!(
116            volume_guid("\\??\\Volume{A2F2048E-D228-11E4-B630-000C29FF2429}").as_deref(),
117            Some("{a2f2048e-d228-11e4-b630-000c29ff2429}")
118        );
119        assert_eq!(volume_guid("\\DosDevices\\E:"), None);
120        assert_eq!(volume_guid("\\??\\Volumexyz"), None);
121    }
122
123    #[test]
124    fn decode_mbr_reads_signature_and_offset_or_rejects() {
125        // E: on the CFReDS hive: disk sig 0xE221034C, offset 0x10000.
126        assert_eq!(
127            decode_mbr(&[0x4c, 0x03, 0x21, 0xe2, 0, 0, 1, 0, 0, 0, 0, 0]),
128            Some((0xE221_034C, 0x1_0000))
129        );
130        // wrong length (a UTF-16 device path) → None, no panic.
131        assert_eq!(decode_mbr(&[0; 216]), None);
132        assert_eq!(decode_mbr(&[1, 2, 3]), None);
133    }
134
135    #[test]
136    fn synthetic_mounted_devices_hive_yields_the_mbr_volume() {
137        // The synthetic MountedDevices fixture has a 12-byte MBR record under \DosDevices\C:.
138        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_mounted_devices.hive");
139        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
140        let vols = parse_mounted_volumes(&hive, "SYNTHETIC");
141        let c = vols
142            .iter()
143            .find(|v| v.drive_letter == Some('C'))
144            .expect("C: MBR record present");
145        assert_eq!(c.disk_signature, 0x4433_2211);
146        assert!(c.source.key_path.is_some());
147        // The bogus `\GLOBAL??\BogusLink` mount name is neither a drive letter nor a
148        // volume GUID → skipped (only the C: MBR record survives; the E:/Volume entries
149        // hold device paths, not MBR records).
150        assert!(!vols
151            .iter()
152            .any(|v| v.drive_letter.is_none() && v.volume_guid.is_none()));
153        assert_eq!(vols.len(), 1);
154    }
155
156    #[test]
157    fn a_hive_without_mounted_devices_yields_nothing() {
158        // The synthetic USBSTOR SYSTEM hive has no MountedDevices key → empty, no panic.
159        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_usb_system.hive");
160        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
161        assert!(parse_mounted_volumes(&hive, "SYSTEM").is_empty());
162    }
163}