usb_forensic/sources/
macos_unified_log.rs1#![allow(clippy::doc_markdown)] use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UsbEnumeration {
20 pub vid: u16,
22 pub pid: u16,
24 pub name: String,
26 pub when: i64,
28}
29
30#[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
55fn 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 let after_paren = rest.split('(').nth(1)?;
64 let name = after_paren.split('/').next()?.trim().to_string();
65 Some((vid, pid, name))
66}
67
68fn parse_log_timestamp(ts: &str) -> Option<i64> {
71 let (date, rest) = ts.split_once(' ')?;
72 let sign = rest.rfind(['+', '-'])?;
74 let (time, off) = rest.split_at(sign);
75 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
85pub struct MacUnifiedLogSource<'a> {
87 events: &'a [UsbEnumeration],
88 locator: String,
89}
90
91impl<'a> MacUnifiedLogSource<'a> {
92 #[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 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 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 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 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); }
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)); assert_eq!(last.value, Value::Timestamp(1_783_853_644)); 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}