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