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}