usb_forensic/sources/
apple_ipod.rs1use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
12use std::io::Cursor;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AppleDevice {
17 pub serial: Option<String>,
19 pub id: String,
21 pub model: Option<String>,
23 pub last_connected: Option<i64>,
25}
26
27#[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
67fn 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
75pub struct AppleIPodSource<'a> {
77 devices: &'a [AppleDevice],
78 locator: String,
79}
80
81impl<'a> AppleIPodSource<'a> {
82 #[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 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 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 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}