Skip to main content

peripheral_core/
setupapi.rs

1//! Parser for Windows `setupapi.dev.log` (Vista+) and `setupapi.log` (XP)
2//! device-installation logs.
3//!
4//! Forensic value: a device-install section header records the exact moment a
5//! device's driver was installed — a first-connect timestamp that survives even
6//! after the registry `Enum\` keys are wiped. This module extracts the
7//! enumerator, VID/PID, iSerial, and install time into a [`DeviceConnection`].
8//!
9//! Two header grammars are handled (citation: Microsoft Learn, *SetupAPI Text
10//! Logs* / *Format of a Text Log Section Header*):
11//!
12//! - **Vista+** — description first, timestamp last inside the brackets:
13//!   `[Device Install (Hardware initiated) - USB\VID_0781&PID_5583\<serial> 2023/04/15 14:23:11.456]`
14//! - **XP** — timestamp first inside the brackets:
15//!   `[2005/05/12 12:34:56 1234.5678] Device Install - USB\...`
16//!
17//! Lines that match neither grammar are skipped; the parser never panics.
18
19use crate::{Bus, DeviceConnection, MitreRef, Provenance, Stamp};
20
21/// `MITRE` techniques narrated as *consistent with*, attached to a connection
22/// by [`DeviceConnection`] construction so downstream analyzers inherit them.
23const MITRE_DMA: MitreRef = MitreRef("T1200");
24const MITRE_EXFIL_USB: MitreRef = MitreRef("T1052.001");
25
26/// Parse a `setupapi.dev.log` / `setupapi.log` text body into one
27/// [`DeviceConnection`] per device-install section header.
28///
29/// `file` is the source filename recorded in each record's [`Provenance`].
30/// Non-matching lines are skipped; the function never panics on any input.
31#[must_use]
32pub fn parse_setupapi(text: &str, file: &str) -> Vec<DeviceConnection> {
33    let mut out = Vec::new();
34    for (idx, line) in text.lines().enumerate() {
35        // Real section headers are prefixed by a `>>>  ` (or `<<<  `) marker;
36        // strip any leading marker/whitespace run before the `[`.
37        let trimmed = line.trim().trim_start_matches(['>', '<', ' ', '\t']);
38        if !trimmed.starts_with('[') {
39            continue;
40        }
41        let Some((instance_id, install_ts)) = parse_header(trimmed) else {
42            continue;
43        };
44        let Some(conn) = build_connection(&instance_id, install_ts, file, idx + 1) else {
45            continue; // cov:unreachable: extract_instance_id guarantees a non-empty alphanumeric enumerator
46        };
47        out.push(conn);
48    }
49    out
50}
51
52/// Extract `(device_instance_id, epoch_seconds)` from a `[ … ]` section header,
53/// trying the Vista+ grammar then the XP grammar. Returns `None` for a line that
54/// is not a recognizable device-install header.
55fn parse_header(line: &str) -> Option<(String, Option<i64>)> {
56    let inner = line.strip_prefix('[')?;
57    let close = inner.find(']')?;
58    let body = &inner[..close];
59
60    // Vista+: `<description with INSTANCE\PATH> YYYY/MM/DD HH:MM:SS[.mmm]`
61    if let Some((desc, ts)) = split_trailing_timestamp(body) {
62        let instance = extract_instance_id(desc);
63        // Only keep device-install headers that actually carry a device path.
64        if let Some(instance) = instance {
65            return Some((instance, ts));
66        }
67    }
68
69    // XP: `YYYY/MM/DD HH:MM:SS <pid.tid>` then `] Device Install - <path>`.
70    if let Some((ts, _rest)) = split_leading_timestamp(body) {
71        // The device path follows the closing bracket.
72        let after = &inner[close + 1..];
73        let instance = extract_instance_id(after)?;
74        return Some((instance, ts));
75    }
76
77    None
78}
79
80/// Split a Vista+ header body into `(description, Option<epoch>)` by finding a
81/// trailing `YYYY/MM/DD HH:MM:SS[.mmm]` token. Returns `None` if no trailing
82/// timestamp is present.
83fn split_trailing_timestamp(body: &str) -> Option<(&str, Option<i64>)> {
84    // The timestamp is the last `date time` pair: split off the last two
85    // whitespace-separated tokens and test them.
86    let body = body.trim_end();
87    let mut it = body.rsplitn(3, char::is_whitespace);
88    let time = it.next()?;
89    let date = it.next()?;
90    let head = it.next()?;
91    let ts_str = format!("{date} {time}");
92    let epoch = parse_timestamp(&ts_str)?;
93    Some((head, Some(epoch)))
94}
95
96/// Split an XP header body that *begins* with a timestamp into
97/// `(Option<epoch>, rest)`. Returns `None` if the body does not start with a
98/// `YYYY/MM/DD HH:MM:SS` pair.
99fn split_leading_timestamp(body: &str) -> Option<(Option<i64>, &str)> {
100    let mut it = body.splitn(3, char::is_whitespace);
101    let date = it.next()?;
102    let time = it.next()?;
103    let rest = it.next().unwrap_or("");
104    let ts_str = format!("{date} {time}");
105    let epoch = parse_timestamp(&ts_str)?;
106    Some((Some(epoch), rest))
107}
108
109/// Find a device instance id — a `ENUM\…` token containing at least one
110/// backslash — inside a free-text description. Returns `None` if the text holds
111/// no instance-id-shaped token.
112fn extract_instance_id(text: &str) -> Option<String> {
113    // The instance id is the longest whitespace-delimited token that contains a
114    // backslash and whose first segment is an alphanumeric enumerator.
115    text.split_whitespace()
116        .filter(|tok| tok.contains('\\'))
117        .filter(|tok| {
118            tok.split('\\')
119                .next()
120                .is_some_and(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric()))
121        })
122        .max_by_key(|tok| tok.len())
123        .map(str::to_string)
124}
125
126/// Parse a `YYYY/MM/DD HH:MM:SS[.mmm]` timestamp (treated as UTC) into Unix
127/// epoch seconds, with no external date library. Returns `None` on any
128/// malformed component — the parser then skips the timestamp, never panics.
129fn parse_timestamp(s: &str) -> Option<i64> {
130    let s = s.trim();
131    let (date, rest) = s.split_once(' ')?;
132    let mut dparts = date.split('/');
133    let year: i64 = dparts.next()?.parse().ok()?;
134    let month: i64 = dparts.next()?.parse().ok()?;
135    let day: i64 = dparts.next()?.parse().ok()?;
136    if dparts.next().is_some() {
137        return None;
138    }
139    // Drop fractional seconds.
140    let time = rest.split('.').next()?;
141    let mut tparts = time.split(':');
142    let hour: i64 = tparts.next()?.parse().ok()?;
143    let min: i64 = tparts.next()?.parse().ok()?;
144    let sec: i64 = tparts.next()?.parse().ok()?;
145    if tparts.next().is_some() {
146        return None;
147    }
148    civil_to_epoch(year, month, day, hour, min, sec)
149}
150
151/// Convert a civil UTC date-time to Unix epoch seconds (Howard Hinnant's
152/// `days_from_civil` algorithm). Returns `None` for an out-of-range field.
153fn civil_to_epoch(y: i64, m: i64, d: i64, hh: i64, mm: i64, ss: i64) -> Option<i64> {
154    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
155        return None;
156    }
157    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=60).contains(&ss) {
158        return None;
159    }
160    let y = if m <= 2 { y - 1 } else { y };
161    let era = if y >= 0 { y } else { y - 399 } / 400;
162    let yoe = y - era * 400;
163    let mp = (m + 9) % 12;
164    let doy = (153 * mp + 2) / 5 + d - 1;
165    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
166    let days = era * 146_097 + doe - 719_468;
167    Some(days * 86_400 + hh * 3_600 + mm * 60 + ss)
168}
169
170/// Build a [`DeviceConnection`] from a device instance id and its install time.
171/// Returns `None` if the instance id has no enumerator segment.
172fn build_connection(
173    instance_id: &str,
174    install_epoch: Option<i64>,
175    file: &str,
176    line: usize,
177) -> Option<DeviceConnection> {
178    let mut segs = instance_id.split('\\');
179    let enumerator = segs.next()?;
180    if enumerator.is_empty() {
181        return None; // cov:unreachable: callers pass instance ids whose enumerator is non-empty alphanumeric
182    }
183    let device_id = segs.next().unwrap_or("");
184    let serial_seg = segs.next();
185
186    let bus = Bus::from_enumerator(enumerator);
187    let (vid, pid) = parse_vid_pid(device_id);
188    let serial_is_os_generated = serial_seg.is_some_and(is_os_generated_serial);
189    let device_serial = serial_seg
190        .filter(|_| !serial_is_os_generated)
191        .filter(|s| !s.is_empty())
192        .map(str::to_string);
193
194    let dma_capable = bus.is_dma_capable();
195    let mut mitre = Vec::new();
196    if dma_capable {
197        mitre.push(MITRE_DMA);
198    }
199    if bus.is_mass_storage() {
200        mitre.push(MITRE_EXFIL_USB);
201    }
202
203    Some(DeviceConnection {
204        bus,
205        device_class_guid: None,
206        vid,
207        pid,
208        device_serial,
209        serial_is_os_generated,
210        friendly_name: None,
211        device_instance_id: instance_id.to_string(),
212        first_install: install_epoch.map(Stamp::authoritative),
213        last_install: install_epoch.map(Stamp::authoritative),
214        last_arrival: None,
215        last_removal: None,
216        parent_id_prefix: None,
217        volume_guid: None,
218        drive_letter: None,
219        volume_serial: None,
220        disk_signature: None,
221        dma_capable,
222        mitre,
223        source: Provenance {
224            file: file.to_string(),
225            line,
226        },
227    })
228}
229
230/// Extract `(vid, pid)` from a `VID_xxxx&PID_xxxx[&…]` device-id segment. Either
231/// or both may be `None` for a non-USB device id.
232fn parse_vid_pid(device_id: &str) -> (Option<u16>, Option<u16>) {
233    let mut vid = None;
234    let mut pid = None;
235    for part in device_id.split('&') {
236        if let Some(hex) = part.strip_prefix("VID_") {
237            vid = u16::from_str_radix(hex_prefix(hex), 16).ok();
238        } else if let Some(hex) = part.strip_prefix("PID_") {
239            pid = u16::from_str_radix(hex_prefix(hex), 16).ok();
240        }
241    }
242    (vid, pid)
243}
244
245/// The leading hex run of `s` (stops at the first non-hex character).
246fn hex_prefix(s: &str) -> &str {
247    let end = s.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(s.len());
248    &s[..end]
249}
250
251/// The instance-id serial is OS-generated when its **second character** is `&`
252/// (the bus had no device-unique serial, so Windows synthesized one, e.g.
253/// `7&1c2c4f0a&0`). Citation: Microsoft Learn, *Instance IDs* — a bus-supplied
254/// instance id encodes either a device serial or location information.
255fn is_os_generated_serial(serial: &str) -> bool {
256    serial.chars().nth(1) == Some('&')
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::Confidence;
263
264    const VISTA_USB: &str = "[Device Install (Hardware initiated) - USB\\VID_0781&PID_5583\\1234567890AB 2023/04/15 14:23:11.456]";
265
266    #[test]
267    fn parses_vista_usb_header() {
268        let conns = parse_setupapi(VISTA_USB, "setupapi.dev.log");
269        assert_eq!(conns.len(), 1);
270        let c = &conns[0];
271        assert_eq!(c.bus, Bus::Usb);
272        assert_eq!(c.vid, Some(0x0781));
273        assert_eq!(c.pid, Some(0x5583));
274        assert_eq!(c.device_serial.as_deref(), Some("1234567890AB"));
275        assert!(!c.serial_is_os_generated);
276        assert_eq!(c.device_instance_id, "USB\\VID_0781&PID_5583\\1234567890AB");
277        assert_eq!(c.source.file, "setupapi.dev.log");
278        assert_eq!(c.source.line, 1);
279    }
280
281    #[test]
282    fn section_marker_prefix_is_stripped() {
283        // Real setupapi.dev.log headers are prefixed by a `>>>  ` marker.
284        let line =
285            ">>>  [Device Install (Hardware initiated) - USB\\VID_0781&PID_5583\\AB 2023/04/15 14:23:11.456]";
286        let conns = parse_setupapi(line, "f");
287        assert_eq!(conns.len(), 1, "the `>>>` section marker must be stripped");
288        assert_eq!(conns[0].vid, Some(0x0781));
289    }
290
291    #[test]
292    fn install_time_is_authoritative() {
293        let c = &parse_setupapi(VISTA_USB, "f")[0];
294        let s = c.first_install.expect("first_install present");
295        assert_eq!(s.confidence, Confidence::Authoritative);
296        // 2023/04/15 14:23:11 UTC = 1681568591
297        assert_eq!(s.value, 1_681_568_591);
298        assert_eq!(c.last_arrival, None); // inferred-only fields stay empty in v0.1
299        assert_eq!(c.last_removal, None);
300    }
301
302    #[test]
303    fn parses_xp_header() {
304        let xp =
305            "[2005/05/12 12:34:56 1234.5678] Device Install - USB\\VID_04E8&PID_6860\\0123456789";
306        let conns = parse_setupapi(xp, "setupapi.log");
307        assert_eq!(conns.len(), 1);
308        let c = &conns[0];
309        assert_eq!(c.vid, Some(0x04E8));
310        assert_eq!(c.pid, Some(0x6860));
311        assert_eq!(c.device_serial.as_deref(), Some("0123456789"));
312        // 2005/05/12 12:34:56 UTC = 1115901296
313        assert_eq!(c.first_install.map(|s| s.value), Some(1_115_901_296));
314    }
315
316    #[test]
317    fn os_generated_serial_is_flagged_and_not_kept_as_device_serial() {
318        // Second character `&` => Windows synthesized the serial.
319        let line = "[Device Install (Hardware initiated) - USBSTOR\\Disk&Ven_Generic&Prod_Flash\\7&1c2c4f0a&0 2024/01/02 03:04:05.000]";
320        let c = &parse_setupapi(line, "f")[0];
321        assert!(
322            c.serial_is_os_generated,
323            "2nd-char-& serial must be flagged"
324        );
325        assert_eq!(
326            c.device_serial, None,
327            "OS-generated serial must not be reported as a real iSerial"
328        );
329    }
330
331    #[test]
332    fn dma_bus_attaches_t1200_and_dma_flag() {
333        let line = "[Device Install (Hardware initiated) - 1394\\SONY&CAMERA\\0123 2024/01/02 03:04:05.000]";
334        let c = &parse_setupapi(line, "f")[0];
335        assert_eq!(c.bus, Bus::FireWire);
336        assert!(c.dma_capable);
337        assert!(c.mitre.contains(&MitreRef("T1200")));
338    }
339
340    #[test]
341    fn mass_storage_attaches_exfil_mitre() {
342        let c = &parse_setupapi(VISTA_USB, "f")[0];
343        assert!(c.mitre.contains(&MitreRef("T1052.001")));
344    }
345
346    #[test]
347    fn volume_serial_is_distinct_field_from_device_serial() {
348        // v0.1 setupapi source never populates volume_serial; the type keeps it
349        // separate from the USB device_serial so the two can't be conflated.
350        let c = &parse_setupapi(VISTA_USB, "f")[0];
351        assert!(c.device_serial.is_some());
352        assert_eq!(c.volume_serial, None);
353    }
354
355    #[test]
356    fn parse_timestamp_rejects_malformed_components() {
357        // Well-formed reference.
358        assert_eq!(
359            parse_timestamp("2023/04/15 14:23:11.456"),
360            Some(1_681_568_591)
361        );
362        // Extra date component (4th `/` segment).
363        assert_eq!(parse_timestamp("2024/01/02/03 04:05:06"), None);
364        // Extra time component (4th `:` segment).
365        assert_eq!(parse_timestamp("2024/01/02 04:05:06:07"), None);
366        // Valid date, out-of-range hour → civil range check.
367        assert_eq!(parse_timestamp("2024/01/02 25:00:00"), None);
368        assert_eq!(parse_timestamp("2024/01/02 00:60:00"), None); // bad minute
369        assert_eq!(parse_timestamp("2024/01/02 00:00:61"), None); // bad second
370                                                                  // A header whose trailing token is an unparseable timestamp matches no
371                                                                  // grammar → the line is skipped entirely (no connection, no panic).
372        let bad = "[Device Install - USB\\VID_0781&PID_5583\\X 2024/01/02 25:00:00]";
373        assert!(parse_setupapi(bad, "f").is_empty());
374    }
375
376    #[test]
377    fn non_matching_lines_are_skipped_never_panic() {
378        let junk = ">>>  [Setup online Device Install (Hardware initiated)]\n\
379                    not a header at all\n\
380                    [no closing bracket\n\
381                    \n\
382                    [Some Note Without A Path 2024/01/02 03:04:05.000]";
383        // None of these carry a device instance path → zero connections, no panic.
384        assert!(parse_setupapi(junk, "f").is_empty());
385    }
386
387    #[test]
388    fn garbled_and_empty_input_never_panics() {
389        assert!(parse_setupapi("", "f").is_empty());
390        assert!(parse_setupapi("\u{feff}\0\\\\\\[[[]]]", "f").is_empty());
391        // A header with a bad date is skipped, not panicked on.
392        assert!(parse_setupapi("[USB\\VID_0781&PID_5583\\X 9999/99/99 99:99:99]", "f").is_empty());
393    }
394
395    #[test]
396    fn missing_serial_segment_yields_none_serial() {
397        let line = "[Device Install (Hardware initiated) - PCI\\VEN_8086&DEV_1234 2024/01/02 03:04:05.000]";
398        let c = &parse_setupapi(line, "f")[0];
399        assert_eq!(c.bus, Bus::Pcie);
400        assert_eq!(c.device_serial, None);
401        assert!(!c.serial_is_os_generated);
402        assert!(c.dma_capable); // PCI is DMA-capable
403    }
404}