#![allow(clippy::doc_markdown)] use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MacUsbDevice {
pub serial: Option<String>,
pub name: String,
pub vid: Option<u16>,
pub pid: Option<u16>,
pub manufacturer: Option<String>,
pub is_mass_storage: bool,
}
#[must_use]
pub fn parse_system_profiler(json: &[u8]) -> Vec<MacUsbDevice> {
let Ok(root) = serde_json::from_slice::<serde_json::Value>(json) else {
return Vec::new();
};
let mut out = Vec::new();
if let Some(items) = root.get("SPUSBDataType").and_then(|v| v.as_array()) {
for item in items {
walk(item, &mut out);
}
}
out
}
fn walk(node: &serde_json::Value, out: &mut Vec<MacUsbDevice>) {
let str_field = |k: &str| node.get(k).and_then(|v| v.as_str()).map(str::to_owned);
if node.get("product_id").is_some() || node.get("serial_num").is_some() {
out.push(MacUsbDevice {
serial: str_field("serial_num"),
name: str_field("_name").unwrap_or_default(),
vid: node.get("vendor_id").and_then(|v| parse_hex_id(v.as_str())),
pid: node
.get("product_id")
.and_then(|v| parse_hex_id(v.as_str())),
manufacturer: str_field("manufacturer"),
is_mass_storage: node.get("Media").and_then(|v| v.as_array()).is_some(),
});
}
if let Some(children) = node.get("_items").and_then(|v| v.as_array()) {
for child in children {
walk(child, out);
}
}
}
fn parse_hex_id(s: Option<&str>) -> Option<u16> {
let tok = s?.split_whitespace().next()?;
let hex = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X"))?;
u16::from_str_radix(hex, 16).ok()
}
pub struct MacUsbSource<'a> {
devices: &'a [MacUsbDevice],
locator: String,
}
impl<'a> MacUsbSource<'a> {
#[must_use]
pub fn new(devices: &'a [MacUsbDevice], locator: impl Into<String>) -> Self {
Self {
devices,
locator: locator.into(),
}
}
}
impl HistorySource for MacUsbSource<'_> {
fn claims(&self) -> Vec<Claim> {
let mut out = Vec::new();
for dev in self.devices {
let device = DeviceKey(dev.serial.clone().unwrap_or_else(|| dev.name.clone()));
let provenance = Provenance {
source: SourceKind::MacosUsb,
locator: self.locator.clone(),
};
if !dev.name.is_empty() {
out.push(Claim {
device: device.clone(),
attribute: Attribute::VolumeName,
value: Value::Text(dev.name.clone()),
provenance: provenance.clone(),
});
}
if dev.is_mass_storage {
out.push(Claim {
device,
attribute: Attribute::DeviceClass,
value: Value::Text("MassStorage".to_string()),
provenance,
});
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
const JSON: &str = r#"{"SPUSBDataType":[
{"_name":"USB31Bus","host_controller":"AppleT8132USBXHCI","_items":[
{"_name":"USB3.0 Hub","product_id":"0x2513","_items":[
{"_name":"Cruzer Blade","serial_num":"4C531001234","product_id":"0x5567",
"vendor_id":"0x0781 (SanDisk Corporation)","manufacturer":"SanDisk",
"Media":[{"bsd_name":"disk4","size":"16 GB"}]}
]}
]}
]}"#;
#[test]
fn walks_the_tree_and_extracts_the_storage_device() {
let devs = parse_system_profiler(JSON.as_bytes());
let stick = devs
.iter()
.find(|d| d.serial.as_deref() == Some("4C531001234"))
.expect("storage device present");
assert_eq!(stick.name, "Cruzer Blade");
assert_eq!(stick.vid, Some(0x0781)); assert_eq!(stick.pid, Some(0x5567));
assert_eq!(stick.manufacturer.as_deref(), Some("SanDisk"));
assert!(stick.is_mass_storage);
let hub = devs.iter().find(|d| d.name == "USB3.0 Hub").expect("hub");
assert!(!hub.is_mass_storage);
assert_eq!(hub.serial, None);
}
#[test]
fn a_real_empty_bus_capture_yields_no_devices() {
let empty = r#"{"SPUSBDataType":[
{"_name":"USB31Bus","host_controller":"AppleT8132USBXHCI"},
{"_name":"USB31Bus","host_controller":"AppleT8132USBXHCI"}
]}"#;
assert!(parse_system_profiler(empty.as_bytes()).is_empty());
}
#[test]
fn non_json_or_missing_key_yields_nothing() {
assert!(parse_system_profiler(b"not json").is_empty());
assert!(parse_system_profiler(br#"{"Other":[]}"#).is_empty());
}
#[test]
fn parse_hex_id_reads_the_leading_hex_token_only() {
assert_eq!(parse_hex_id(Some("0x05ac")), Some(0x05ac));
assert_eq!(
parse_hex_id(Some("0x0781 (SanDisk Corporation)")),
Some(0x0781)
);
assert_eq!(parse_hex_id(Some("garbage")), None);
assert_eq!(parse_hex_id(None), None);
}
#[test]
fn source_emits_name_and_mass_storage_claims_keyed_by_serial() {
let devs = parse_system_profiler(JSON.as_bytes());
let claims = MacUsbSource::new(&devs, "mac-usb.json").claims();
let stick_claims: Vec<_> = claims
.iter()
.filter(|c| c.device == DeviceKey("4C531001234".to_string()))
.collect();
assert!(stick_claims
.iter()
.any(|c| c.attribute == Attribute::VolumeName
&& c.value == Value::Text("Cruzer Blade".to_string())));
assert!(stick_claims
.iter()
.any(|c| c.attribute == Attribute::DeviceClass
&& c.value == Value::Text("MassStorage".to_string())));
assert_eq!(stick_claims[0].provenance.source, SourceKind::MacosUsb);
}
}