Skip to main content

usb_forensic/sources/
macos_usb.rs

1//! Source: macOS `system_profiler -json SPUSBDataType` → USB-history [`Claim`]s.
2//!
3//! `system_profiler SPUSBDataType` reports the live USB device tree (the same data the
4//! IORegistry `IOUSBHostDevice` nodes expose), as a JSON tree of buses/hubs/devices. Each
5//! device carries its `serial_num`, `product_id`/`vendor_id` (as `0x….` strings),
6//! `manufacturer`, and — for mass storage — a `Media` array. This reader walks that tree
7//! and emits, per device, the model as a label and its VID/PID; it is the macOS live-triage
8//! counterpart to the Windows registry USB history.
9
10#![allow(clippy::doc_markdown)] // macOS proper nouns (system_profiler, IORegistry)
11use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12
13/// One USB device decoded from the `system_profiler` tree.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct MacUsbDevice {
16    /// The device's serial number, when reported.
17    pub serial: Option<String>,
18    /// The product / device name (`_name`).
19    pub name: String,
20    /// USB vendor id (`vendor_id`, parsed from its `0x….` form), when present.
21    pub vid: Option<u16>,
22    /// USB product id (`product_id`), when present.
23    pub pid: Option<u16>,
24    /// The manufacturer string, when reported.
25    pub manufacturer: Option<String>,
26    /// Whether the device presents mass-storage media (a `Media` array).
27    pub is_mass_storage: bool,
28}
29
30/// Parse `system_profiler -json SPUSBDataType` output into USB devices. A node is a
31/// *device* (rather than a bus/hub) when it reports a `product_id` or a `serial_num`; buses
32/// and hubs are walked through to reach the devices beneath them. Robust: non-JSON, or a
33/// tree with no devices, yields an empty result rather than a panic.
34#[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
48/// Recursively collect device nodes from a `system_profiler` USB tree node.
49fn 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    // A device node reports a product id or a serial; a bare bus/hub reports neither.
52    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
71/// Parse a `system_profiler` id like `"0x05ac"` or `"0x05ac  (Apple Inc.)"` into a `u16`.
72/// The value is the leading `0x…` hex token; a trailing vendor-name gloss is ignored.
73fn 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
79/// A [`HistorySource`] over decoded macOS USB devices, with the capture's locator.
80pub struct MacUsbSource<'a> {
81    devices: &'a [MacUsbDevice],
82    locator: String,
83}
84
85impl<'a> MacUsbSource<'a> {
86    /// Wrap decoded devices with the locator of the `system_profiler` capture.
87    #[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            // Key by serial (the cross-source identity) when present, else the device name.
101            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    /// A `system_profiler -json SPUSBDataType` capture with one bus, one hub, and a
132    /// mass-storage stick beneath it — the documented shape (a bus → `_items` → device).
133    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)); // vendor gloss ignored
152        assert_eq!(stick.pid, Some(0x5567));
153        assert_eq!(stick.manufacturer.as_deref(), Some("SanDisk"));
154        assert!(stick.is_mass_storage);
155        // The hub (product_id, no serial, no Media) is a device node but not mass storage.
156        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        // The exact shape this project captured from a real Mac with nothing plugged in:
164        // three buses, no `_items`, no device fields → zero devices, no panic.
165        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}