usb_forensic/sources/
macos_usb.rs1#![allow(clippy::doc_markdown)] use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct MacUsbDevice {
16 pub serial: Option<String>,
18 pub name: String,
20 pub vid: Option<u16>,
22 pub pid: Option<u16>,
24 pub manufacturer: Option<String>,
26 pub is_mass_storage: bool,
28}
29
30#[must_use]
35pub fn parse_system_profiler(json: &[u8]) -> Vec<MacUsbDevice> {
36 let Ok(root) = serde_json::from_slice::<serde_json::Value>(json) else {
37 return Vec::new();
38 };
39 let mut out = Vec::new();
40 if let Some(items) = root.get("SPUSBDataType").and_then(|v| v.as_array()) {
41 for item in items {
42 walk(item, &mut out);
43 }
44 }
45 out
46}
47
48fn walk(node: &serde_json::Value, out: &mut Vec<MacUsbDevice>) {
50 let str_field = |k: &str| node.get(k).and_then(|v| v.as_str()).map(str::to_owned);
51 if node.get("product_id").is_some() || node.get("serial_num").is_some() {
53 out.push(MacUsbDevice {
54 serial: str_field("serial_num"),
55 name: str_field("_name").unwrap_or_default(),
56 vid: node.get("vendor_id").and_then(|v| parse_hex_id(v.as_str())),
57 pid: node
58 .get("product_id")
59 .and_then(|v| parse_hex_id(v.as_str())),
60 manufacturer: str_field("manufacturer"),
61 is_mass_storage: node.get("Media").and_then(|v| v.as_array()).is_some(),
62 });
63 }
64 if let Some(children) = node.get("_items").and_then(|v| v.as_array()) {
65 for child in children {
66 walk(child, out);
67 }
68 }
69}
70
71fn parse_hex_id(s: Option<&str>) -> Option<u16> {
74 let tok = s?.split_whitespace().next()?;
75 let hex = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X"))?;
76 u16::from_str_radix(hex, 16).ok()
77}
78
79pub struct MacUsbSource<'a> {
81 devices: &'a [MacUsbDevice],
82 locator: String,
83}
84
85impl<'a> MacUsbSource<'a> {
86 #[must_use]
88 pub fn new(devices: &'a [MacUsbDevice], locator: impl Into<String>) -> Self {
89 Self {
90 devices,
91 locator: locator.into(),
92 }
93 }
94}
95
96impl HistorySource for MacUsbSource<'_> {
97 fn claims(&self) -> Vec<Claim> {
98 let mut out = Vec::new();
99 for dev in self.devices {
100 let device = DeviceKey(dev.serial.clone().unwrap_or_else(|| dev.name.clone()));
102 let provenance = Provenance {
103 source: SourceKind::MacosUsb,
104 locator: self.locator.clone(),
105 };
106 if !dev.name.is_empty() {
107 out.push(Claim {
108 device: device.clone(),
109 attribute: Attribute::VolumeName,
110 value: Value::Text(dev.name.clone()),
111 provenance: provenance.clone(),
112 });
113 }
114 if dev.is_mass_storage {
115 out.push(Claim {
116 device,
117 attribute: Attribute::DeviceClass,
118 value: Value::Text("MassStorage".to_string()),
119 provenance,
120 });
121 }
122 }
123 out
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 const JSON: &str = r#"{"SPUSBDataType":[
134 {"_name":"USB31Bus","host_controller":"AppleT8132USBXHCI","_items":[
135 {"_name":"USB3.0 Hub","product_id":"0x2513","_items":[
136 {"_name":"Cruzer Blade","serial_num":"4C531001234","product_id":"0x5567",
137 "vendor_id":"0x0781 (SanDisk Corporation)","manufacturer":"SanDisk",
138 "Media":[{"bsd_name":"disk4","size":"16 GB"}]}
139 ]}
140 ]}
141 ]}"#;
142
143 #[test]
144 fn walks_the_tree_and_extracts_the_storage_device() {
145 let devs = parse_system_profiler(JSON.as_bytes());
146 let stick = devs
147 .iter()
148 .find(|d| d.serial.as_deref() == Some("4C531001234"))
149 .expect("storage device present");
150 assert_eq!(stick.name, "Cruzer Blade");
151 assert_eq!(stick.vid, Some(0x0781)); assert_eq!(stick.pid, Some(0x5567));
153 assert_eq!(stick.manufacturer.as_deref(), Some("SanDisk"));
154 assert!(stick.is_mass_storage);
155 let hub = devs.iter().find(|d| d.name == "USB3.0 Hub").expect("hub");
157 assert!(!hub.is_mass_storage);
158 assert_eq!(hub.serial, None);
159 }
160
161 #[test]
162 fn a_real_empty_bus_capture_yields_no_devices() {
163 let empty = r#"{"SPUSBDataType":[
166 {"_name":"USB31Bus","host_controller":"AppleT8132USBXHCI"},
167 {"_name":"USB31Bus","host_controller":"AppleT8132USBXHCI"}
168 ]}"#;
169 assert!(parse_system_profiler(empty.as_bytes()).is_empty());
170 }
171
172 #[test]
173 fn non_json_or_missing_key_yields_nothing() {
174 assert!(parse_system_profiler(b"not json").is_empty());
175 assert!(parse_system_profiler(br#"{"Other":[]}"#).is_empty());
176 }
177
178 #[test]
179 fn parse_hex_id_reads_the_leading_hex_token_only() {
180 assert_eq!(parse_hex_id(Some("0x05ac")), Some(0x05ac));
181 assert_eq!(
182 parse_hex_id(Some("0x0781 (SanDisk Corporation)")),
183 Some(0x0781)
184 );
185 assert_eq!(parse_hex_id(Some("garbage")), None);
186 assert_eq!(parse_hex_id(None), None);
187 }
188
189 #[test]
190 fn source_emits_name_and_mass_storage_claims_keyed_by_serial() {
191 let devs = parse_system_profiler(JSON.as_bytes());
192 let claims = MacUsbSource::new(&devs, "mac-usb.json").claims();
193 let stick_claims: Vec<_> = claims
194 .iter()
195 .filter(|c| c.device == DeviceKey("4C531001234".to_string()))
196 .collect();
197 assert!(stick_claims
198 .iter()
199 .any(|c| c.attribute == Attribute::VolumeName
200 && c.value == Value::Text("Cruzer Blade".to_string())));
201 assert!(stick_claims
202 .iter()
203 .any(|c| c.attribute == Attribute::DeviceClass
204 && c.value == Value::Text("MassStorage".to_string())));
205 assert_eq!(stick_claims[0].provenance.source, SourceKind::MacosUsb);
206 }
207}