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.
153pub(crate) fn civil_to_epoch(y: i64, m: i64, d: i64, hh: i64, mm: i64, ss: i64) -> Option<i64> {
154    // Bound the year to real dates: a malformed log can parse an arbitrarily large year,
155    // which would overflow the day/second multiplications below (fuzz-found panic).
156    if !(1..=9999).contains(&y) {
157        return None;
158    }
159    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
160        return None;
161    }
162    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=60).contains(&ss) {
163        return None;
164    }
165    let y = if m <= 2 { y - 1 } else { y };
166    let era = if y >= 0 { y } else { y - 399 } / 400;
167    let yoe = y - era * 400;
168    let mp = (m + 9) % 12;
169    let doy = (153 * mp + 2) / 5 + d - 1;
170    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
171    let days = era * 146_097 + doe - 719_468;
172    Some(days * 86_400 + hh * 3_600 + mm * 60 + ss)
173}
174
175/// Build a [`DeviceConnection`] from a device instance id and its install time.
176/// Returns `None` if the instance id has no enumerator segment.
177fn build_connection(
178    instance_id: &str,
179    install_epoch: Option<i64>,
180    file: &str,
181    line: usize,
182) -> Option<DeviceConnection> {
183    let mut segs = instance_id.split('\\');
184    let enumerator = segs.next()?;
185    if enumerator.is_empty() {
186        return None; // cov:unreachable: callers pass instance ids whose enumerator is non-empty alphanumeric
187    }
188    let device_id = segs.next().unwrap_or("");
189    let serial_seg = segs.next();
190
191    let bus = Bus::from_enumerator(enumerator);
192    let (vid, pid) = parse_vid_pid(device_id);
193    let serial_is_os_generated = serial_seg.is_some_and(is_os_generated_serial);
194    let device_serial = serial_seg
195        .filter(|_| !serial_is_os_generated)
196        .filter(|s| !s.is_empty())
197        .map(str::to_string);
198
199    let dma_capable = bus.is_dma_capable();
200    let mut mitre = Vec::new();
201    if dma_capable {
202        mitre.push(MITRE_DMA);
203    }
204    if bus.is_mass_storage() {
205        mitre.push(MITRE_EXFIL_USB);
206    }
207
208    Some(DeviceConnection {
209        bus,
210        device_class_guid: None,
211        vid,
212        pid,
213        device_serial,
214        serial_is_os_generated,
215        friendly_name: None,
216        device_instance_id: instance_id.to_string(),
217        first_install: install_epoch.map(Stamp::authoritative),
218        last_install: install_epoch.map(Stamp::authoritative),
219        last_arrival: None,
220        last_removal: None,
221        parent_id_prefix: None,
222        volume_guid: None,
223        drive_letter: None,
224        volume_serial: None,
225        disk_signature: None,
226        dma_capable,
227        mitre,
228        source: Provenance {
229            file: file.to_string(),
230            line,
231            key_path: None,
232        },
233    })
234}
235
236/// Extract `(vid, pid)` from a `VID_xxxx&PID_xxxx[&…]` device-id segment. Either
237/// or both may be `None` for a non-USB device id.
238fn parse_vid_pid(device_id: &str) -> (Option<u16>, Option<u16>) {
239    let mut vid = None;
240    let mut pid = None;
241    for part in device_id.split('&') {
242        if let Some(hex) = part.strip_prefix("VID_") {
243            vid = u16::from_str_radix(hex_prefix(hex), 16).ok();
244        } else if let Some(hex) = part.strip_prefix("PID_") {
245            pid = u16::from_str_radix(hex_prefix(hex), 16).ok();
246        }
247    }
248    (vid, pid)
249}
250
251/// The leading hex run of `s` (stops at the first non-hex character).
252fn hex_prefix(s: &str) -> &str {
253    let end = s.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(s.len());
254    &s[..end]
255}
256
257/// The instance-id serial is OS-generated when its **second character** is `&`
258/// (the bus had no device-unique serial, so Windows synthesized one, e.g.
259/// `7&1c2c4f0a&0`). Citation: Microsoft Learn, *Instance IDs* — a bus-supplied
260/// instance id encodes either a device serial or location information.
261fn is_os_generated_serial(serial: &str) -> bool {
262    serial.chars().nth(1) == Some('&')
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::Confidence;
269
270    const VISTA_USB: &str = "[Device Install (Hardware initiated) - USB\\VID_0781&PID_5583\\1234567890AB 2023/04/15 14:23:11.456]";
271
272    #[test]
273    fn parses_vista_usb_header() {
274        let conns = parse_setupapi(VISTA_USB, "setupapi.dev.log");
275        assert_eq!(conns.len(), 1);
276        let c = &conns[0];
277        assert_eq!(c.bus, Bus::Usb);
278        assert_eq!(c.vid, Some(0x0781));
279        assert_eq!(c.pid, Some(0x5583));
280        assert_eq!(c.device_serial.as_deref(), Some("1234567890AB"));
281        assert!(!c.serial_is_os_generated);
282        assert_eq!(c.device_instance_id, "USB\\VID_0781&PID_5583\\1234567890AB");
283        assert_eq!(c.source.file, "setupapi.dev.log");
284        assert_eq!(c.source.line, 1);
285    }
286
287    #[test]
288    fn section_marker_prefix_is_stripped() {
289        // Real setupapi.dev.log headers are prefixed by a `>>>  ` marker.
290        let line =
291            ">>>  [Device Install (Hardware initiated) - USB\\VID_0781&PID_5583\\AB 2023/04/15 14:23:11.456]";
292        let conns = parse_setupapi(line, "f");
293        assert_eq!(conns.len(), 1, "the `>>>` section marker must be stripped");
294        assert_eq!(conns[0].vid, Some(0x0781));
295    }
296
297    #[test]
298    fn install_time_is_authoritative() {
299        let c = &parse_setupapi(VISTA_USB, "f")[0];
300        let s = c.first_install.expect("first_install present");
301        assert_eq!(s.confidence, Confidence::Authoritative);
302        // 2023/04/15 14:23:11 UTC = 1681568591
303        assert_eq!(s.value, 1_681_568_591);
304        assert_eq!(c.last_arrival, None); // inferred-only fields stay empty in v0.1
305        assert_eq!(c.last_removal, None);
306    }
307
308    #[test]
309    fn parses_xp_header() {
310        let xp =
311            "[2005/05/12 12:34:56 1234.5678] Device Install - USB\\VID_04E8&PID_6860\\0123456789";
312        let conns = parse_setupapi(xp, "setupapi.log");
313        assert_eq!(conns.len(), 1);
314        let c = &conns[0];
315        assert_eq!(c.vid, Some(0x04E8));
316        assert_eq!(c.pid, Some(0x6860));
317        assert_eq!(c.device_serial.as_deref(), Some("0123456789"));
318        // 2005/05/12 12:34:56 UTC = 1115901296
319        assert_eq!(c.first_install.map(|s| s.value), Some(1_115_901_296));
320    }
321
322    #[test]
323    fn os_generated_serial_is_flagged_and_not_kept_as_device_serial() {
324        // Second character `&` => Windows synthesized the serial.
325        let line = "[Device Install (Hardware initiated) - USBSTOR\\Disk&Ven_Generic&Prod_Flash\\7&1c2c4f0a&0 2024/01/02 03:04:05.000]";
326        let c = &parse_setupapi(line, "f")[0];
327        assert!(
328            c.serial_is_os_generated,
329            "2nd-char-& serial must be flagged"
330        );
331        assert_eq!(
332            c.device_serial, None,
333            "OS-generated serial must not be reported as a real iSerial"
334        );
335    }
336
337    #[test]
338    fn dma_bus_attaches_t1200_and_dma_flag() {
339        let line = "[Device Install (Hardware initiated) - 1394\\SONY&CAMERA\\0123 2024/01/02 03:04:05.000]";
340        let c = &parse_setupapi(line, "f")[0];
341        assert_eq!(c.bus, Bus::FireWire);
342        assert!(c.dma_capable);
343        assert!(c.mitre.contains(&MitreRef("T1200")));
344    }
345
346    #[test]
347    fn mass_storage_attaches_exfil_mitre() {
348        let c = &parse_setupapi(VISTA_USB, "f")[0];
349        assert!(c.mitre.contains(&MitreRef("T1052.001")));
350    }
351
352    #[test]
353    fn volume_serial_is_distinct_field_from_device_serial() {
354        // v0.1 setupapi source never populates volume_serial; the type keeps it
355        // separate from the USB device_serial so the two can't be conflated.
356        let c = &parse_setupapi(VISTA_USB, "f")[0];
357        assert!(c.device_serial.is_some());
358        assert_eq!(c.volume_serial, None);
359    }
360
361    #[test]
362    fn civil_to_epoch_rejects_out_of_range_year_without_overflow() {
363        // Fuzz regression: a huge year must return None, not overflow-panic.
364        assert_eq!(civil_to_epoch(9_999_999_999, 1, 1, 0, 0, 0), None);
365        assert_eq!(civil_to_epoch(0, 1, 1, 0, 0, 0), None); // year 0 out of range
366        assert_eq!(civil_to_epoch(2023, 4, 15, 14, 23, 11), Some(1_681_568_591));
367        // still valid
368    }
369
370    #[test]
371    fn parse_timestamp_rejects_malformed_components() {
372        // Well-formed reference.
373        assert_eq!(
374            parse_timestamp("2023/04/15 14:23:11.456"),
375            Some(1_681_568_591)
376        );
377        // Extra date component (4th `/` segment).
378        assert_eq!(parse_timestamp("2024/01/02/03 04:05:06"), None);
379        // Extra time component (4th `:` segment).
380        assert_eq!(parse_timestamp("2024/01/02 04:05:06:07"), None);
381        // Valid date, out-of-range hour → civil range check.
382        assert_eq!(parse_timestamp("2024/01/02 25:00:00"), None);
383        assert_eq!(parse_timestamp("2024/01/02 00:60:00"), None); // bad minute
384        assert_eq!(parse_timestamp("2024/01/02 00:00:61"), None); // bad second
385                                                                  // A header whose trailing token is an unparseable timestamp matches no
386                                                                  // grammar → the line is skipped entirely (no connection, no panic).
387        let bad = "[Device Install - USB\\VID_0781&PID_5583\\X 2024/01/02 25:00:00]";
388        assert!(parse_setupapi(bad, "f").is_empty());
389    }
390
391    #[test]
392    fn non_matching_lines_are_skipped_never_panic() {
393        let junk = ">>>  [Setup online Device Install (Hardware initiated)]\n\
394                    not a header at all\n\
395                    [no closing bracket\n\
396                    \n\
397                    [Some Note Without A Path 2024/01/02 03:04:05.000]";
398        // None of these carry a device instance path → zero connections, no panic.
399        assert!(parse_setupapi(junk, "f").is_empty());
400    }
401
402    #[test]
403    fn garbled_and_empty_input_never_panics() {
404        assert!(parse_setupapi("", "f").is_empty());
405        assert!(parse_setupapi("\u{feff}\0\\\\\\[[[]]]", "f").is_empty());
406        // A header with a bad date is skipped, not panicked on.
407        assert!(parse_setupapi("[USB\\VID_0781&PID_5583\\X 9999/99/99 99:99:99]", "f").is_empty());
408    }
409
410    #[test]
411    fn missing_serial_segment_yields_none_serial() {
412        let line = "[Device Install (Hardware initiated) - PCI\\VEN_8086&DEV_1234 2024/01/02 03:04:05.000]";
413        let c = &parse_setupapi(line, "f")[0];
414        assert_eq!(c.bus, Bus::Pcie);
415        assert_eq!(c.device_serial, None);
416        assert!(!c.serial_is_os_generated);
417        assert!(c.dma_capable); // PCI is DMA-capable
418    }
419}