Skip to main content

usb_forensic/sources/
apple_ipod.rs

1//! Source: macOS `com.apple.iPod.plist` → USB-history [`Claim`]s — the macOS counterpart
2//! to the Windows registry USB history.
3//!
4//! `~/Library/Preferences/com.apple.iPod.plist` durably records every Apple device
5//! (iPhone/iPad/iPod) connected over USB to a Mac. Under `Devices`, each entry is keyed by
6//! its ID and carries the device serial, model, and — as `Connected` — the last-connected
7//! timestamp (recorded by macOS, so authoritative). This source parses that plist (binary
8//! or XML) and emits a [`Attribute::LastConnected`] claim per device, keyed by its serial,
9//! plus its model as a [`Attribute::VolumeName`]-style human label.
10
11use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12use std::io::Cursor;
13
14/// One Apple device connection decoded from `com.apple.iPod.plist`.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AppleDevice {
17    /// The device's serial number, when present.
18    pub serial: Option<String>,
19    /// The device ID (the `Devices` dictionary key) — the fallback identity.
20    pub id: String,
21    /// The model / class (`iPad14,6` / `iPad`), when present.
22    pub model: Option<String>,
23    /// Last-connected time (`Connected`), epoch seconds UTC, when present.
24    pub last_connected: Option<i64>,
25}
26
27/// Parse `com.apple.iPod.plist` bytes into Apple-device connections. Robust: a non-plist,
28/// or a plist lacking the `Devices` shape, yields an empty result rather than a panic.
29#[must_use]
30pub fn parse_ipod_plist(bytes: &[u8]) -> Vec<AppleDevice> {
31    let Ok(root) = plist::Value::from_reader(Cursor::new(bytes)) else {
32        return Vec::new();
33    };
34    let Some(devices) = root
35        .as_dictionary()
36        .and_then(|d| d.get("Devices"))
37        .and_then(plist::Value::as_dictionary)
38    else {
39        return Vec::new();
40    };
41    devices
42        .iter()
43        .filter_map(|(id, entry)| {
44            let dict = entry.as_dictionary()?;
45            let s = |k: &str| {
46                dict.get(k)
47                    .and_then(plist::Value::as_string)
48                    .map(str::to_owned)
49            };
50            Some(AppleDevice {
51                serial: s("Serial Number"),
52                id: dict
53                    .get("ID")
54                    .and_then(plist::Value::as_string)
55                    .unwrap_or(id)
56                    .to_string(),
57                model: s("Product Type").or_else(|| s("Device Class")),
58                last_connected: dict
59                    .get("Connected")
60                    .and_then(plist::Value::as_date)
61                    .map(|d| system_time_to_epoch(d.into())),
62            })
63        })
64        .collect()
65}
66
67/// Convert a plist `SystemTime` to Unix epoch seconds (pre-1970 saturates to 0).
68fn system_time_to_epoch(t: std::time::SystemTime) -> i64 {
69    match t.duration_since(std::time::UNIX_EPOCH) {
70        Ok(d) => d.as_secs() as i64,
71        Err(_) => 0,
72    }
73}
74
75/// A [`HistorySource`] over decoded Apple-device connections, with the source locator.
76pub struct AppleIPodSource<'a> {
77    devices: &'a [AppleDevice],
78    locator: String,
79}
80
81impl<'a> AppleIPodSource<'a> {
82    /// Wrap decoded Apple devices with the on-disk locator of the plist they came from.
83    #[must_use]
84    pub fn new(devices: &'a [AppleDevice], locator: impl Into<String>) -> Self {
85        Self {
86            devices,
87            locator: locator.into(),
88        }
89    }
90}
91
92impl HistorySource for AppleIPodSource<'_> {
93    fn claims(&self) -> Vec<Claim> {
94        let mut out = Vec::new();
95        for dev in self.devices {
96            // Key by the device serial when present (the cross-source identity), else the ID.
97            let device = DeviceKey(dev.serial.clone().unwrap_or_else(|| dev.id.clone()));
98            let provenance = Provenance {
99                source: SourceKind::AppleIPod,
100                locator: format!("{}#Devices/{}", self.locator, dev.id),
101            };
102            if let Some(when) = dev.last_connected {
103                out.push(Claim {
104                    device: device.clone(),
105                    attribute: Attribute::LastConnected,
106                    value: Value::Timestamp(when),
107                    provenance: provenance.clone(),
108                });
109            }
110            if let Some(model) = &dev.model {
111                out.push(Claim {
112                    device,
113                    attribute: Attribute::VolumeName,
114                    value: Value::Text(model.clone()),
115                    provenance,
116                });
117            }
118        }
119        out
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    const XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
128<plist version="1.0"><dict><key>Devices</key><dict>
129  <key>0004486C1A03401E</key><dict>
130    <key>Device Class</key><string>iPad</string>
131    <key>Product Type</key><string>iPad14,6</string>
132    <key>Serial Number</key><string>TESTSERIAL9</string>
133    <key>ID</key><string>0004486C1A03401E</string>
134    <key>Connected</key><date>2023-07-04T00:50:36Z</date>
135  </dict>
136  <key>NOSERIAL</key><dict><key>Device Class</key><string>iPhone</string></dict>
137</dict></dict></plist>"#;
138
139    #[test]
140    fn parse_extracts_serial_model_and_last_connected() {
141        let devs = parse_ipod_plist(XML.as_bytes());
142        let ipad = devs
143            .iter()
144            .find(|d| d.serial.as_deref() == Some("TESTSERIAL9"))
145            .expect("device present");
146        assert_eq!(ipad.model.as_deref(), Some("iPad14,6"));
147        assert_eq!(ipad.last_connected, Some(1_688_431_836));
148    }
149
150    #[test]
151    fn a_non_plist_or_wrong_shape_yields_nothing() {
152        assert!(parse_ipod_plist(b"not a plist").is_empty());
153        let other = r#"<?xml version="1.0"?><plist version="1.0"><dict><key>X</key><string>y</string></dict></plist>"#;
154        assert!(parse_ipod_plist(other.as_bytes()).is_empty());
155    }
156
157    #[test]
158    fn a_pre_1970_connected_date_saturates_to_epoch_zero() {
159        // Defensive: a clock-wrong device with a pre-epoch `Connected` date yields 0, not a
160        // panic or a negative wraparound.
161        let xml = r#"<?xml version="1.0"?><plist version="1.0"><dict><key>Devices</key><dict>
162          <key>OLD</key><dict><key>Serial Number</key><string>S</string>
163          <key>Connected</key><date>1969-01-01T00:00:00Z</date></dict>
164        </dict></dict></plist>"#;
165        let devs = parse_ipod_plist(xml.as_bytes());
166        assert_eq!(devs[0].last_connected, Some(0));
167    }
168
169    #[test]
170    fn source_emits_last_connected_and_model_keyed_by_serial() {
171        let devs = parse_ipod_plist(XML.as_bytes());
172        let claims = AppleIPodSource::new(&devs, "com.apple.iPod.plist").claims();
173        let lc = claims
174            .iter()
175            .find(|c| c.attribute == Attribute::LastConnected)
176            .expect("last-connected claim");
177        assert_eq!(lc.device, DeviceKey("TESTSERIAL9".to_string()));
178        assert_eq!(lc.value, Value::Timestamp(1_688_431_836));
179        assert_eq!(lc.provenance.source, SourceKind::AppleIPod);
180        assert!(lc.provenance.locator.contains("Devices/0004486C1A03401E"));
181        let name = claims
182            .iter()
183            .find(|c| c.attribute == Attribute::VolumeName)
184            .expect("model claim");
185        assert_eq!(name.value, Value::Text("iPad14,6".to_string()));
186    }
187
188    #[test]
189    fn a_serialless_device_is_keyed_by_its_id_and_still_emits_its_model() {
190        let devs = parse_ipod_plist(XML.as_bytes());
191        let claims = AppleIPodSource::new(&devs, "f").claims();
192        // NOSERIAL has a model but no serial and no Connected date → one VolumeName claim.
193        let name = claims
194            .iter()
195            .find(|c| c.device == DeviceKey("NOSERIAL".to_string()))
196            .expect("serialless device present");
197        assert_eq!(name.attribute, Attribute::VolumeName);
198        assert_eq!(name.value, Value::Text("iPhone".to_string()));
199    }
200}