Skip to main content

peripheral_core/
registry.rs

1//! Registry device source: decode USB / SCSI / USBSTOR device instances from a Windows
2//! `SYSTEM` hive into [`DeviceConnection`] records, complementing the `setupapi` source.
3//!
4//! Device instances live under `ControlSet00X\Enum\{USBSTOR,SCSI,USB}\<Ven&Prod>\<instance>`.
5//! Per-device timestamps live in the undocumented device-property subtree
6//! `Properties\{83da6326-97a6-4088-9453-a1923f573b29}\<PROP>` whose default value is a
7//! `FILETIME`: `0064` install, `0065` first-install (both documented → authoritative),
8//! `0066` last-arrival/connect, `0067` last-removal/disconnect (undocumented → inferred).
9
10use crate::{Bus, DeviceConnection, Provenance, Stamp};
11use std::io::Cursor;
12use winreg_core::hive::Hive;
13use winreg_core::key::{filetime_to_datetime, Key};
14
15/// The undocumented device-property subtree holding the install/arrival/removal
16/// `FILETIME`s (`0064`/`0065`/`0066`/`0067`).
17const TS_GUID: &str = "{83da6326-97a6-4088-9453-a1923f573b29}";
18
19/// The device enumerator classes that carry USB / removable mass-storage history,
20/// paired with the bus each implies. `SCSI` is included because virtual and UASP/USB-3
21/// disks enumerate there rather than under `USBSTOR`.
22const ENUM_CLASSES: [(&str, Bus); 3] = [
23    ("USBSTOR", Bus::Usb),
24    ("USB", Bus::Usb),
25    ("SCSI", Bus::ScsiSas),
26];
27
28/// Both control sets are walked; the same device may appear in each.
29const CONTROL_SETS: [&str; 2] = ["ControlSet001", "ControlSet002"];
30
31/// Parse USB / SCSI / USBSTOR device instances from an already-opened `SYSTEM` hive.
32///
33/// The caller opens the hive (a bootstrap step that must fail loudly on its own); this
34/// function walks it and is total over a valid hive — a malformed subkey is skipped, not
35/// panicked on. `file` is recorded as the [`Provenance`] file (the
36/// hive name, e.g. `SYSTEM`); each record also carries its full key path.
37#[must_use]
38pub fn parse_registry(hive: &Hive<Cursor<Vec<u8>>>, file: &str) -> Vec<DeviceConnection> {
39    let mut out = Vec::new();
40    for cs in CONTROL_SETS {
41        for (class, bus) in ENUM_CLASSES {
42            let base = format!("{cs}\\Enum\\{class}");
43            let Ok(Some(class_key)) = hive.open_key(&base) else {
44                continue;
45            };
46            let Ok(vendors) = class_key.subkeys() else {
47                continue; // cov:unreachable: subkeys() only errors on hive corruption; a valid hive yields Ok
48            };
49            for vendor in vendors {
50                let ven_name = vendor.name();
51                let Ok(instances) = vendor.subkeys() else {
52                    continue; // cov:unreachable: subkeys() only errors on hive corruption; a valid hive yields Ok
53                };
54                for inst in instances {
55                    let inst_name = inst.name();
56                    let key_path = format!("{base}\\{ven_name}\\{inst_name}");
57                    out.push(build_connection(
58                        &inst, class, bus, &ven_name, &inst_name, file, key_path,
59                    ));
60                }
61            }
62        }
63    }
64    apply_mounted_devices(hive, &mut out);
65    out
66}
67
68/// Enrich connections with drive letters decoded from the `MountedDevices` key.
69///
70/// `MountedDevices` maps a mount name — `\DosDevices\X:` (a drive letter) or
71/// `\??\Volume{guid}` (a volume, no letter) — to a REG_BINARY value that is either a
72/// 12-byte MBR record (disk signature + partition offset) or a UTF-16LE device path
73/// `\??\<CLASS>#<Ven&Prod>#<instance>#{guid}`. The device-path form names a device
74/// instance directly, so a `\DosDevices\X:` entry pointing at a device path gives a
75/// drive-letter ↔ device join. MBR records and volume-GUID names carry no drive letter;
76/// the MBR disk-signature join needs the device-side signature from the
77/// Partition/Diagnostic log (a separate source) and is not attempted here.
78fn apply_mounted_devices(hive: &Hive<Cursor<Vec<u8>>>, conns: &mut [DeviceConnection]) {
79    let Ok(Some(md)) = hive.open_key("MountedDevices") else {
80        return;
81    };
82    let Ok(values) = md.values() else {
83        return; // cov:unreachable: values() only errors on hive corruption; a valid hive yields Ok
84    };
85    for value in values {
86        let Some(letter) = dos_drive_letter(&value.name()) else {
87            continue;
88        };
89        let Ok(raw) = value.raw_data() else {
90            continue; // cov:unreachable: raw_data() only errors on hive corruption
91        };
92        let Some(instance) = device_path_instance(&raw) else {
93            continue;
94        };
95        let suffix = format!("\\{instance}");
96        for conn in conns.iter_mut() {
97            if conn.device_instance_id == instance || conn.device_instance_id.ends_with(&suffix) {
98                conn.drive_letter = Some(letter);
99            }
100        }
101    }
102}
103
104/// Extract the drive letter from a `\DosDevices\X:` mount name, upper-cased. Any other
105/// name (a volume GUID, a malformed name) yields `None`.
106fn dos_drive_letter(name: &str) -> Option<char> {
107    let tail = name.strip_prefix("\\DosDevices\\")?;
108    let mut chars = tail.chars();
109    let letter = chars.next()?;
110    if letter.is_ascii_alphabetic() && chars.next() == Some(':') && chars.next().is_none() {
111        Some(letter.to_ascii_uppercase())
112    } else {
113        None
114    }
115}
116
117/// Decode a `MountedDevices` REG_BINARY value as a UTF-16LE device path and return the
118/// device instance component (`\??\<CLASS>#<Ven&Prod>#<instance>#{guid}` → `<instance>`).
119/// Returns `None` for a 12-byte MBR record, a non-device-path string, or malformed bytes.
120fn device_path_instance(raw: &[u8]) -> Option<String> {
121    if raw.len() < 8 || raw.len() % 2 != 0 {
122        return None;
123    }
124    let units: Vec<u16> = raw
125        .chunks_exact(2)
126        .map(|c| u16::from_le_bytes([c[0], c[1]]))
127        .collect();
128    let decoded = String::from_utf16(&units).ok()?;
129    let path = decoded.strip_prefix("\\??\\")?;
130    let mut parts: Vec<&str> = path.split('#').collect();
131    // Drop the trailing `{GUID}` interface-class component when present.
132    if parts
133        .last()
134        .is_some_and(|p| p.starts_with('{') && p.ends_with('}'))
135    {
136        parts.pop();
137    }
138    let instance = *parts.last()?;
139    if instance.is_empty() || parts.len() < 2 {
140        return None;
141    }
142    Some(instance.to_string())
143}
144
145/// Build one [`DeviceConnection`] from a decoded device-instance key.
146/// The MTP class-driver service name; a device with this `Service` is a portable/media
147/// endpoint (phone/tablet/camera) speaking MTP/PTP, not mass storage.
148const MTP_SERVICE: &str = "WUDFWpdMtp";
149
150/// Reclassify a USB-enumerated device as [`Bus::Mtp`] when its `Service` is the MTP class
151/// driver (`WUDFWpdMtp`, case-insensitive). Only a USB-bus device is overridden — the MTP
152/// service appears under `Enum\USB`; any other service or bus is returned unchanged.
153fn mtp_override(service: Option<&str>, bus: Bus) -> Bus {
154    if bus == Bus::Usb && service.is_some_and(|s| s.eq_ignore_ascii_case(MTP_SERVICE)) {
155        Bus::Mtp
156    } else {
157        bus
158    }
159}
160
161fn build_connection(
162    inst: &Key<'_>,
163    class: &str,
164    bus: Bus,
165    ven_name: &str,
166    inst_name: &str,
167    file: &str,
168    key_path: String,
169) -> DeviceConnection {
170    let (vid, pid) = parse_vid_pid(ven_name);
171    // A phone/tablet/camera speaking MTP enumerates under USB but is not mass storage; its
172    // Service value flags it (documented WUDFWpdMtp), so reclassify the bus.
173    let bus = mtp_override(value_string(inst, "Service").as_deref(), bus);
174    // Windows synthesizes an instance id whose 2nd character is `&` when the device
175    // exposed no real iSerial — attribution is then weaker.
176    let serial_is_os_generated = inst_name.as_bytes().get(1) == Some(&b'&');
177
178    let ts = inst
179        .subkey("Properties")
180        .ok()
181        .flatten()
182        .and_then(|p| p.subkey(TS_GUID).ok().flatten());
183    let filetime = |prop: u32| ts.as_ref().and_then(|k| read_filetime(k, prop));
184    // 0x64/0x65 are documented install dates (authoritative); 0x66/0x67 are the
185    // undocumented last-arrival/removal properties (inferred).
186    let first_install = filetime(0x64)
187        .or_else(|| filetime(0x65))
188        .map(Stamp::authoritative);
189    let last_arrival = filetime(0x66).map(Stamp::inferred);
190    let last_removal = filetime(0x67).map(Stamp::inferred);
191
192    DeviceConnection {
193        bus,
194        device_class_guid: None,
195        vid,
196        pid,
197        device_serial: (!inst_name.is_empty()).then(|| inst_name.to_string()),
198        serial_is_os_generated,
199        friendly_name: value_string(inst, "FriendlyName"),
200        device_instance_id: format!("{class}\\{ven_name}\\{inst_name}"),
201        first_install,
202        last_install: None,
203        last_arrival,
204        last_removal,
205        parent_id_prefix: value_string(inst, "ParentIdPrefix"),
206        volume_guid: None,
207        drive_letter: None,
208        volume_serial: None,
209        disk_signature: None,
210        dma_capable: bus.is_dma_capable(),
211        mitre: Vec::new(),
212        source: Provenance {
213            file: file.to_string(),
214            line: 0,
215            key_path: Some(key_path),
216        },
217    }
218}
219
220/// Read a key's named string value, `None` if absent, unreadable, or empty.
221fn value_string(key: &Key<'_>, name: &str) -> Option<String> {
222    key.value(name)
223        .ok()
224        .flatten()
225        .and_then(|v| v.as_string().ok())
226        .filter(|s| !s.is_empty())
227}
228
229/// Read the device-property `FILETIME` numbered `prop` (e.g. `0x64`) as Unix epoch
230/// seconds, handling both Windows device-property-store layouts:
231///
232/// - **Windows 8+/Server 2012+:** the property subkey is named with 4 hex digits
233///   (`0064`) and the `FILETIME` is its default (unnamed) value.
234/// - **Windows 7:** the subkey is named with 8 hex digits (`00000064`) and the
235///   `FILETIME` is the `Data` value of a nested `00000000` leaf key.
236///
237/// The property is matched by its numeric value so any zero-padding resolves, and both
238/// value locations are tried. Verified Tier-1 against the Szechuan (Server 2012 R2) and
239/// NIST CFReDS Data-Leakage (Windows 7) hives.
240fn read_filetime(guid_key: &Key<'_>, prop: u32) -> Option<i64> {
241    let prop_key = find_prop_subkey(guid_key, prop)?;
242    let raw = prop_key
243        .value("")
244        .ok()
245        .flatten()
246        .and_then(|v| v.raw_data().ok())
247        .or_else(|| {
248            // Win7 nested layout: `<prop>\00000000` with the FILETIME in `Data`.
249            let leaf = find_prop_subkey(&prop_key, 0)?;
250            leaf.value("Data")
251                .ok()
252                .flatten()
253                .and_then(|v| v.raw_data().ok())
254        })?;
255    let bytes: [u8; 8] = raw.get(..8)?.try_into().ok()?;
256    let ts = filetime_to_datetime(u64::from_le_bytes(bytes))?;
257    Some(ts.as_second())
258}
259
260/// Find a device-property subkey by its numeric hex value, tolerating any zero-padding
261/// (`0064` and `00000064` both match `0x64`).
262fn find_prop_subkey<'a>(key: &Key<'a>, num: u32) -> Option<Key<'a>> {
263    key.subkeys()
264        .ok()?
265        .into_iter()
266        .find(|k| u32::from_str_radix(&k.name(), 16).ok() == Some(num))
267}
268
269/// Extract `(vid, pid)` from a `VID_xxxx&PID_xxxx` enumerator key name (USB only).
270fn parse_vid_pid(name: &str) -> (Option<u16>, Option<u16>) {
271    let hex4 = |tag: &str| {
272        name.split('&').find_map(|seg| {
273            let h = seg.strip_prefix(tag)?;
274            u16::from_str_radix(h.get(..4)?, 16).ok()
275        })
276    };
277    (hex4("VID_"), hex4("PID_"))
278}
279
280#[cfg(test)]
281#[allow(clippy::unwrap_used, clippy::expect_used)]
282mod tests {
283    use super::*;
284    use crate::Bus;
285
286    // The real-artifact validation against the Szechuan SYSTEM hive + regipy oracle
287    // lives in `core/tests/registry_real_hive.rs` (an integration test, env-gated), so
288    // it is excluded from `--lib` line coverage while still proving correctness on real
289    // data. The tests here exercise the walker deterministically from a synthetic hive.
290
291    /// Deterministic coverage fixture (Tier-3): a `winreg-testutil`-built SYSTEM hive
292    /// with three device instances covering every branch. Decoder *correctness* is
293    /// validated at Tier-1 by `vmware_scsi_disk_matches_regipy_ground_truth` against the
294    /// real hive + regipy oracle; this test exercises the walker deterministically in CI.
295    #[test]
296    fn synthetic_hive_exercises_every_branch() {
297        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_usb_system.hive");
298        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid synthetic REGF");
299        let conns = parse_registry(&hive, "SYNTHETIC");
300        let by = |needle: &str| {
301            conns
302                .iter()
303                .find(|c| c.device_instance_id.contains(needle))
304                .expect("device present")
305        };
306
307        // SCSI: 0064 first-install, 0066 last-arrival, FriendlyName, OS-generated serial.
308        let scsi = by("Disk&Ven_Test&Prod_Disk");
309        assert_eq!(scsi.bus, Bus::ScsiSas);
310        assert_eq!(scsi.friendly_name.as_deref(), Some("Test Virtual Disk"));
311        assert_eq!(
312            scsi.first_install.as_ref().map(|s| s.value),
313            Some(1_600_357_894)
314        );
315        assert_eq!(
316            scsi.last_arrival.as_ref().map(|s| s.value),
317            Some(1_600_478_558)
318        );
319        assert_eq!(scsi.last_removal, None);
320        assert!(scsi.serial_is_os_generated);
321        assert!(scsi.source.key_path.is_some());
322
323        // USBSTOR: first-install via the 0065 fallback, 0067 last-removal, no FriendlyName.
324        let usbstor = by("Disk&Ven_Gen&Prod_Flash");
325        assert_eq!(usbstor.bus, Bus::Usb);
326        assert_eq!(
327            usbstor.first_install.as_ref().map(|s| s.value),
328            Some(1_500_000_000)
329        );
330        assert_eq!(
331            usbstor.last_removal.as_ref().map(|s| s.value),
332            Some(1_500_009_999)
333        );
334        assert_eq!(usbstor.friendly_name, None);
335
336        // USB: VID/PID extraction, a real (not OS-generated) iSerial.
337        let usb = by("VID_0781&PID_5583");
338        assert_eq!(usb.bus, Bus::Usb);
339        assert_eq!(usb.vid, Some(0x0781));
340        assert_eq!(usb.pid, Some(0x5583));
341        assert_eq!(usb.device_serial.as_deref(), Some("0123456789AB"));
342        assert!(!usb.serial_is_os_generated);
343    }
344
345    #[test]
346    fn mtp_service_reclassifies_the_bus_to_mtp() {
347        // A device whose Enum\USB `Service` is the MTP class driver (`WUDFWpdMtp`) is a
348        // portable/media endpoint (phone/tablet/camera), not mass storage — surfaced as
349        // Bus::Mtp even though it enumerates under the USB class. Documented rule; a device
350        // with any other service keeps its enumerator-derived bus.
351        assert_eq!(mtp_override(Some("WUDFWpdMtp"), Bus::Usb), Bus::Mtp);
352        assert_eq!(mtp_override(Some("wudfwpdmtp"), Bus::Usb), Bus::Mtp); // case-insensitive
353        assert_eq!(mtp_override(Some("USBSTOR"), Bus::Usb), Bus::Usb);
354        assert_eq!(mtp_override(None, Bus::Usb), Bus::Usb);
355        // It never overrides a non-USB bus (an MTP service only appears under USB).
356        assert_eq!(mtp_override(Some("WUDFWpdMtp"), Bus::ScsiSas), Bus::ScsiSas);
357    }
358
359    #[test]
360    fn parse_vid_pid_handles_absent_and_malformed() {
361        assert_eq!(
362            parse_vid_pid("VID_0781&PID_5583"),
363            (Some(0x0781), Some(0x5583))
364        );
365        assert_eq!(parse_vid_pid("Disk&Ven_Gen&Prod_Flash"), (None, None));
366        // present prefix but too short / non-hex → None, never a panic.
367        assert_eq!(parse_vid_pid("VID_07&PID_ZZZZ"), (None, None));
368    }
369
370    /// UTF-16LE encode a device path the way `MountedDevices` stores it (REG_BINARY).
371    fn u16le(s: &str) -> Vec<u8> {
372        s.encode_utf16().flat_map(u16::to_le_bytes).collect()
373    }
374
375    #[test]
376    fn dos_drive_letter_extracts_only_well_formed_names() {
377        assert_eq!(dos_drive_letter("\\DosDevices\\E:"), Some('E'));
378        assert_eq!(dos_drive_letter("\\DosDevices\\c:"), Some('C')); // upper-cased
379                                                                     // a volume-GUID mount name carries no drive letter.
380        assert_eq!(dos_drive_letter("\\??\\Volume{1234}"), None);
381        // malformed DosDevices names never panic, never yield a letter.
382        assert_eq!(dos_drive_letter("\\DosDevices\\"), None);
383        assert_eq!(dos_drive_letter("\\DosDevices\\EE:"), None);
384        assert_eq!(dos_drive_letter("\\DosDevices\\1:"), None);
385    }
386
387    #[test]
388    fn device_path_instance_extracts_the_instance_or_rejects() {
389        let dev =
390            "\\??\\SCSI#Disk&Ven_Test&Prod_X#5&join123&0#{53f5630d-b6bf-11d0-94f2-00a0c91efb8b}";
391        assert_eq!(
392            device_path_instance(&u16le(dev)).as_deref(),
393            Some("5&join123&0")
394        );
395        // a device path without a trailing {GUID} component still yields the instance.
396        assert_eq!(
397            device_path_instance(&u16le("\\??\\USBSTOR#Disk&Ven#INST42")).as_deref(),
398            Some("INST42")
399        );
400        // a 12-byte MBR record (disk signature + offset) is not a device path.
401        assert_eq!(
402            device_path_instance(&[0x11, 0x22, 0x33, 0x44, 0, 0, 0, 0, 0, 0, 0, 0]),
403            None
404        );
405        // too short / odd length / not a \??\ path / single component → None, no panic.
406        assert_eq!(device_path_instance(&[0, 0]), None);
407        assert_eq!(device_path_instance(&[1, 2, 3]), None);
408        assert_eq!(device_path_instance(&u16le("C:\\not-a-device-path")), None);
409        assert_eq!(device_path_instance(&u16le("\\??\\onlyonepart")), None);
410        // a lone UTF-16 surrogate must be rejected, not panic.
411        assert_eq!(
412            device_path_instance(&[0x00, 0xD8, 0x00, 0xD8, 0x00, 0x00, 0x00, 0x00]),
413            None
414        );
415    }
416
417    #[test]
418    fn mounted_devices_join_sets_drive_letter_on_the_matching_device() {
419        // Synthetic SYSTEM hive: one SCSI instance + a MountedDevices key mapping
420        // \DosDevices\E: → that instance's device path, plus an MBR record and a
421        // volume-GUID path that must NOT produce a drive letter.
422        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_mounted_devices.hive");
423        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
424        let conns = parse_registry(&hive, "SYNTHETIC");
425        let dev = conns
426            .iter()
427            .find(|c| c.device_instance_id.ends_with("5&join123&0"))
428            .expect("device present");
429        assert_eq!(dev.drive_letter, Some('E'));
430    }
431
432    #[test]
433    fn win7_nested_property_layout_filetime_is_decoded() {
434        // Synthetic Windows-7-layout hive: the install FILETIME lives at
435        // Properties\{GUID}\00000064\00000000 in a `Data` value (8-hex property name +
436        // nested leaf), not the modern 0064-default-value layout. Deterministic CI cover
437        // for the Win7 branch of `read_filetime`; the same decode is validated Tier-1 on
438        // the real NIST CFReDS hive in `tests/registry_real_hive.rs`.
439        const HIVE: &[u8] = include_bytes!("../../tests/data/synthetic_win7_props.hive");
440        let hive = Hive::from_bytes(HIVE.to_vec()).expect("valid REGF");
441        let conns = parse_registry(&hive, "SYNTHETIC");
442        let dev = conns
443            .iter()
444            .find(|c| c.device_instance_id.ends_with("7&win7serial&0"))
445            .expect("Win7 device present");
446        assert_eq!(
447            dev.first_install.as_ref().map(|s| s.value),
448            Some(1_427_135_471),
449            "install FILETIME decoded from the nested 00000064\\00000000\\Data leaf"
450        );
451    }
452}