Skip to main content

usb_forensic/sources/
macos_unified_log.rs

1//! Source: macOS unified-log USB enumeration events (`log show --style json`) →
2//! USB-history [`Claim`]s — the macOS *connection history* (with times).
3//!
4//! The macOS unified log records a `AppleUSBHostPort::enumerateDeviceComplete` message
5//! each time a USB device is enumerated (connected), e.g.
6//! `enumerated 0x0781/55ab/0100 ( SanDisk 3.2Gen1 / 1) at 5 Gbps`, carrying the device's
7//! VID/PID, its product name, and — as the event timestamp — the moment it was connected.
8//! Unlike the live `system_profiler` snapshot, this yields *when* a device was attached and
9//! recovers every connection, so a device removed before imaging is still seen. This reader
10//! parses those events into first/last-connected times per device.
11
12#![allow(clippy::doc_markdown)] // macOS proper nouns (AppleUSBHostPort, …)
13
14use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
15use std::collections::BTreeMap;
16
17/// A USB device enumeration event decoded from one unified-log message.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UsbEnumeration {
20    /// USB vendor id.
21    pub vid: u16,
22    /// USB product id.
23    pub pid: u16,
24    /// The device's product name, trimmed.
25    pub name: String,
26    /// The enumeration (connect) time, epoch seconds UTC.
27    pub when: i64,
28}
29
30/// Parse `log show --style json` output into USB enumeration events. Only
31/// `enumerateDeviceComplete` messages carrying an `enumerated 0x<vid>/<pid>/<bcd> ( <name>`
32/// payload are decoded; every other log line is skipped. Robust: non-JSON, or a log with no
33/// USB events, yields an empty result rather than a panic.
34#[must_use]
35pub fn parse_unified_log(json: &[u8]) -> Vec<UsbEnumeration> {
36    let Ok(events) = serde_json::from_slice::<Vec<serde_json::Value>>(json) else {
37        return Vec::new();
38    };
39    events
40        .iter()
41        .filter_map(|e| {
42            let msg = e.get("eventMessage")?.as_str()?;
43            let (vid, pid, name) = parse_enumeration_message(msg)?;
44            let when = parse_log_timestamp(e.get("timestamp")?.as_str()?)?;
45            Some(UsbEnumeration {
46                vid,
47                pid,
48                name,
49                when,
50            })
51        })
52        .collect()
53}
54
55/// Extract `(vid, pid, name)` from an `enumerateDeviceComplete` message. The payload is
56/// `enumerated 0x<vid>/<pid>/<bcd> ( <name> / <n>) at …`; `None` for any other message.
57fn parse_enumeration_message(msg: &str) -> Option<(u16, u16, String)> {
58    let rest = msg.split("enumerated 0x").nth(1)?;
59    let mut ids = rest.splitn(3, '/');
60    let vid = u16::from_str_radix(ids.next()?.trim(), 16).ok()?;
61    let pid = u16::from_str_radix(ids.next()?.trim(), 16).ok()?;
62    // The product name sits between the first "( " and the following " / ".
63    let after_paren = rest.split('(').nth(1)?;
64    let name = after_paren.split('/').next()?.trim().to_string();
65    Some((vid, pid, name))
66}
67
68/// Parse a unified-log timestamp (`2026-07-12 18:51:45.843302+0800`) to epoch seconds.
69/// Normalizes the space separator and the `+HHMM` offset to RFC 3339 for `jiff`.
70fn parse_log_timestamp(ts: &str) -> Option<i64> {
71    let (date, rest) = ts.split_once(' ')?;
72    // rest is `HH:MM:SS.ffffff±HHMM`; find the offset sign to insert the colon.
73    let sign = rest.rfind(['+', '-'])?;
74    let (time, off) = rest.split_at(sign);
75    // off is `+0800` → `+08:00`.
76    if off.len() != 5 {
77        return None;
78    }
79    let rfc = format!("{date}T{time}{}:{}", &off[..3], &off[3..]);
80    rfc.parse::<jiff::Timestamp>()
81        .ok()
82        .map(jiff::Timestamp::as_second)
83}
84
85/// A [`HistorySource`] over decoded USB enumeration events.
86pub struct MacUnifiedLogSource<'a> {
87    events: &'a [UsbEnumeration],
88    locator: String,
89}
90
91impl<'a> MacUnifiedLogSource<'a> {
92    /// Wrap decoded enumeration events with the locator of the log capture.
93    #[must_use]
94    pub fn new(events: &'a [UsbEnumeration], locator: impl Into<String>) -> Self {
95        Self {
96            events,
97            locator: locator.into(),
98        }
99    }
100}
101
102impl HistorySource for MacUnifiedLogSource<'_> {
103    fn claims(&self) -> Vec<Claim> {
104        // Aggregate every enumeration of the same device (by VID/PID) into its first and
105        // last connect time — the connection history a live snapshot cannot provide.
106        let mut by_device: BTreeMap<(u16, u16), (i64, i64, String)> = BTreeMap::new();
107        for e in self.events {
108            let entry = by_device
109                .entry((e.vid, e.pid))
110                .or_insert((e.when, e.when, e.name.clone()));
111            entry.0 = entry.0.min(e.when);
112            entry.1 = entry.1.max(e.when);
113        }
114        let mut out = Vec::new();
115        for ((vid, pid), (first, last, name)) in by_device {
116            let device = DeviceKey(format!("usb-{vid:04X}-{pid:04X}"));
117            let provenance = Provenance {
118                source: SourceKind::MacosUnifiedLog,
119                locator: self.locator.clone(),
120            };
121            let claim = |attribute, value| Claim {
122                device: device.clone(),
123                attribute,
124                value,
125                provenance: provenance.clone(),
126            };
127            out.push(claim(Attribute::FirstConnected, Value::Timestamp(first)));
128            out.push(claim(Attribute::LastConnected, Value::Timestamp(last)));
129            if !name.is_empty() {
130                out.push(claim(Attribute::VolumeName, Value::Text(name)));
131            }
132        }
133        out
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // The real message + timestamp shape captured from a Mac (SanDisk stick, this project).
142    const MSG: &str = "usb-drd0-port-ss@00200000: AppleUSBHostPort::enumerateDeviceComplete_block_invoke: enumerated 0x0781/55ab/0100 ( SanDisk 3.2Gen1 / 1) at 5 Gbps";
143
144    fn log_json(events: &[(&str, &str)]) -> Vec<u8> {
145        let items: Vec<String> = events
146            .iter()
147            .map(|(ts, msg)| {
148                format!(
149                    r#"{{"timestamp":"{ts}","eventMessage":{}}}"#,
150                    serde_json::to_string(msg).unwrap()
151                )
152            })
153            .collect();
154        format!("[{}]", items.join(",")).into_bytes()
155    }
156
157    #[test]
158    fn parse_enumeration_message_extracts_vid_pid_name() {
159        let (vid, pid, name) = parse_enumeration_message(MSG).expect("enumeration");
160        assert_eq!(vid, 0x0781);
161        assert_eq!(pid, 0x55ab);
162        assert_eq!(name, "SanDisk 3.2Gen1");
163        // a non-enumeration message yields None.
164        assert_eq!(parse_enumeration_message("some other log line"), None);
165    }
166
167    #[test]
168    fn parse_log_timestamp_handles_the_real_offset_format() {
169        // 2026-07-12 18:51:45 +0800 = 2026-07-12 10:51:45 UTC = epoch 1_783_853_505
170        // (verified with an independent oracle: Python `datetime.fromisoformat`).
171        assert_eq!(
172            parse_log_timestamp("2026-07-12 18:51:45.843302+0800"),
173            Some(1_783_853_505)
174        );
175        assert_eq!(parse_log_timestamp("garbage"), None);
176        assert_eq!(parse_log_timestamp("2026-07-12 18:51:45+08"), None); // short offset
177    }
178
179    #[test]
180    fn multiple_enumerations_aggregate_to_first_and_last_connected() {
181        let json = log_json(&[
182            ("2026-07-12 18:51:45.843302+0800", MSG),
183            ("2026-07-12 18:54:04.537000+0800", MSG),
184        ]);
185        let events = parse_unified_log(&json);
186        assert_eq!(events.len(), 2);
187        let claims = MacUnifiedLogSource::new(&events, "log.json").claims();
188        let first = claims
189            .iter()
190            .find(|c| c.attribute == Attribute::FirstConnected)
191            .expect("first-connected");
192        let last = claims
193            .iter()
194            .find(|c| c.attribute == Attribute::LastConnected)
195            .expect("last-connected");
196        assert_eq!(first.value, Value::Timestamp(1_783_853_505)); // 18:51:45 +0800
197        assert_eq!(last.value, Value::Timestamp(1_783_853_644)); // 18:54:04 +0800
198        assert_eq!(first.device, DeviceKey("usb-0781-55AB".to_string()));
199        assert_eq!(first.provenance.source, SourceKind::MacosUnifiedLog);
200    }
201
202    #[test]
203    fn non_json_or_no_usb_events_yields_nothing() {
204        assert!(parse_unified_log(b"not json").is_empty());
205        let other = log_json(&[("2026-07-12 18:51:45.0+0800", "unrelated kernel message")]);
206        assert!(parse_unified_log(&other).is_empty());
207    }
208}