1use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
2
3use crate::services::afc::{AfcClient, AfcError, AfcFileInfo};
4
5pub const CRASHREPORT_MOVER_SERVICE: &str = "com.apple.crashreportmover";
6pub const CRASHREPORT_COPY_MOBILE_SERVICE: &str = "com.apple.crashreportcopymobile";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct CrashReportEntry {
10 pub path: String,
11 pub size: Option<u64>,
12 pub modified: Option<String>,
13}
14
15#[derive(Debug, thiserror::Error)]
16pub enum CrashReportError {
17 #[error("IO error: {0}")]
18 Io(#[from] std::io::Error),
19 #[error("AFC error: {0}")]
20 Afc(#[from] AfcError),
21 #[error("protocol error: {0}")]
22 Protocol(String),
23 #[error("invalid pattern '{pattern}': {message}")]
24 InvalidPattern { pattern: String, message: String },
25}
26
27pub struct CrashReportClient<S> {
28 afc: AfcClient<S>,
29}
30
31impl<S: AsyncRead + AsyncWrite + Unpin> CrashReportClient<S> {
32 pub fn new(stream: S) -> Self {
33 Self {
34 afc: AfcClient::new(stream),
35 }
36 }
37
38 pub async fn list_reports(
39 &mut self,
40 pattern: Option<&str>,
41 ) -> Result<Vec<CrashReportEntry>, CrashReportError> {
42 let mut dirs = vec![".".to_string()];
43 let mut entries = Vec::new();
44 let compiled = compile_pattern(pattern.unwrap_or("*"))?;
45
46 while let Some(dir) = dirs.pop() {
47 for name in self.afc.list_dir(&dir).await? {
48 let path = join_path(&dir, &name);
49 let info = self.afc.stat_info(&path).await?;
50 if is_dir(&info) {
51 dirs.push(path);
52 continue;
53 }
54 if !compiled.matches(&name) {
55 continue;
56 }
57 entries.push(CrashReportEntry {
58 path,
59 size: info.size,
60 modified: modified_time(&info),
61 });
62 }
63 }
64
65 sort_reports(&mut entries);
66 Ok(entries)
67 }
68
69 pub async fn remove_reports(
70 &mut self,
71 pattern: Option<&str>,
72 ) -> Result<usize, CrashReportError> {
73 let reports = self.list_reports(pattern).await?;
74 for report in &reports {
75 self.afc.remove(&report.path).await?;
76 }
77 Ok(reports.len())
78 }
79
80 pub async fn read_report(&mut self, report: &str) -> Result<Vec<u8>, CrashReportError> {
81 let path = self.resolve_report_path(report).await?;
82 Ok(self.afc.read_file(&path).await?.to_vec())
83 }
84
85 async fn resolve_report_path(&mut self, report: &str) -> Result<String, CrashReportError> {
86 if report.contains('/') {
87 return Ok(normalize_report_path(report));
88 }
89
90 let reports = self.list_reports(Some("*")).await?;
91 resolve_report_path_from_entries(report, &reports)
92 }
93}
94
95pub async fn prepare_reports<S>(stream: &mut S) -> Result<(), CrashReportError>
96where
97 S: AsyncRead + Unpin,
98{
99 let mut ping = [0u8; 4];
100 stream.read_exact(&mut ping).await?;
101 if &ping != b"ping" {
102 return Err(CrashReportError::Protocol(format!(
103 "crashreport mover did not return ping: {:02x?}",
104 ping
105 )));
106 }
107 Ok(())
108}
109
110pub fn matches_pattern(path: &str, pattern: &str) -> Result<bool, CrashReportError> {
111 Ok(compile_pattern(pattern)?.matches(path_basename(path)))
112}
113
114pub fn sort_reports(entries: &mut [CrashReportEntry]) {
115 entries.sort_by(|a, b| match (&a.modified, &b.modified) {
116 (Some(a_modified), Some(b_modified)) => {
117 b_modified.cmp(a_modified).then_with(|| a.path.cmp(&b.path))
118 }
119 (Some(_), None) => std::cmp::Ordering::Less,
120 (None, Some(_)) => std::cmp::Ordering::Greater,
121 (None, None) => a.path.cmp(&b.path),
122 });
123}
124
125fn compile_pattern(pattern: &str) -> Result<Pattern, CrashReportError> {
126 validate_pattern(pattern)?;
127 Ok(Pattern(pattern.to_string()))
128}
129
130fn modified_time(info: &AfcFileInfo) -> Option<String> {
131 info.raw
132 .get("st_mtime")
133 .or_else(|| info.raw.get("st_birthtime"))
134 .map(|raw| format_human_readable_timestamp(raw))
135}
136
137fn format_human_readable_timestamp(raw: &str) -> String {
138 match RawTimestamp::parse(raw) {
139 Some(timestamp) => timestamp.format_utc(),
140 None => raw.to_string(),
141 }
142}
143
144fn is_dir(info: &AfcFileInfo) -> bool {
145 matches!(info.file_type.as_deref(), Some("S_IFDIR"))
146}
147
148fn path_basename(path: &str) -> &str {
149 path.rsplit('/').next().unwrap_or(path)
150}
151
152fn join_path(dir: &str, name: &str) -> String {
153 if dir == "." {
154 format!("./{name}")
155 } else {
156 format!("{}/{}", dir.trim_end_matches('/'), name)
157 }
158}
159
160fn normalize_report_path(report: &str) -> String {
161 if report.starts_with("./") {
162 report.to_string()
163 } else {
164 format!("./{}", report.trim_start_matches('/'))
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169struct RawTimestamp {
170 seconds: i128,
171}
172
173impl RawTimestamp {
174 fn parse(raw: &str) -> Option<Self> {
175 let value = raw.trim().parse::<i128>().ok()?;
176 for divisor in [1_000_000_000_i128, 1_000_000, 1_000, 1] {
177 let seconds = value.div_euclid(divisor);
178 if plausible_year(seconds) {
179 return Some(Self { seconds });
180 }
181 }
182
183 Some(Self { seconds: value })
184 }
185
186 fn format_utc(self) -> String {
187 let total_seconds = self.seconds;
188 let days = total_seconds.div_euclid(86_400);
189 let seconds_of_day = total_seconds.rem_euclid(86_400) as u32;
190 let (year, month, day) = civil_from_days(days);
191 let hour = seconds_of_day / 3_600;
192 let minute = (seconds_of_day % 3_600) / 60;
193 let second = seconds_of_day % 60;
194
195 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} UTC")
196 }
197}
198
199fn plausible_year(seconds: i128) -> bool {
200 let days = seconds.div_euclid(86_400);
201 let (year, _, _) = civil_from_days(days);
202 (1970..=2500).contains(&year)
203}
204
205fn civil_from_days(days: i128) -> (i128, i128, i128) {
206 let z = days + 719_468;
207 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
208 let doe = z - era * 146_097;
209 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
210 let y = yoe + era * 400;
211 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
212 let mp = (5 * doy + 2) / 153;
213 let day = doy - (153 * mp + 2) / 5 + 1;
214 let month = mp + if mp < 10 { 3 } else { -9 };
215 let year = y + if month <= 2 { 1 } else { 0 };
216
217 (year, month, day)
218}
219
220fn resolve_report_path_from_entries(
221 report: &str,
222 reports: &[CrashReportEntry],
223) -> Result<String, CrashReportError> {
224 let mut matches = reports
225 .iter()
226 .filter(|entry| path_basename(&entry.path) == report)
227 .map(|entry| entry.path.clone())
228 .collect::<Vec<_>>();
229
230 match matches.len() {
231 0 => Err(CrashReportError::Protocol(format!(
232 "crash report '{report}' not found"
233 ))),
234 1 => Ok(matches.pop().unwrap()),
235 _ => Err(CrashReportError::Protocol(format!(
236 "crash report '{report}' is ambiguous"
237 ))),
238 }
239}
240
241struct Pattern(String);
242
243impl Pattern {
244 fn matches(&self, candidate: &str) -> bool {
245 wildcard_match(self.0.as_bytes(), candidate.as_bytes())
246 }
247}
248
249fn validate_pattern(pattern: &str) -> Result<(), CrashReportError> {
250 for ch in ['[', ']', '{', '}'] {
251 if pattern.contains(ch) {
252 return Err(CrashReportError::InvalidPattern {
253 pattern: pattern.to_string(),
254 message: format!("unsupported pattern syntax '{ch}'"),
255 });
256 }
257 }
258 Ok(())
259}
260
261fn wildcard_match(pattern: &[u8], candidate: &[u8]) -> bool {
262 let mut p = 0usize;
263 let mut c = 0usize;
264 let mut star = None;
265 let mut star_match = 0usize;
266
267 while c < candidate.len() {
268 if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == candidate[c]) {
269 p += 1;
270 c += 1;
271 } else if p < pattern.len() && pattern[p] == b'*' {
272 star = Some(p);
273 p += 1;
274 star_match = c;
275 } else if let Some(star_pos) = star {
276 p = star_pos + 1;
277 star_match += 1;
278 c = star_match;
279 } else {
280 return false;
281 }
282 }
283
284 while p < pattern.len() && pattern[p] == b'*' {
285 p += 1;
286 }
287
288 p == pattern.len()
289}
290
291#[cfg(test)]
292mod tests {
293 use crate::proto::afc::{AfcHeader, AfcOpcode};
294 use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
295 use zerocopy::{FromBytes, IntoBytes};
296
297 use super::*;
298
299 #[tokio::test]
300 async fn prepare_reports_accepts_ping() {
301 let (mut client, mut server) = duplex(16);
302 tokio::spawn(async move {
303 server.write_all(b"ping").await.unwrap();
304 });
305
306 prepare_reports(&mut client).await.unwrap();
307 }
308
309 #[tokio::test]
310 async fn prepare_reports_rejects_non_ping() {
311 let (mut client, mut server) = duplex(16);
312 tokio::spawn(async move {
313 server.write_all(b"pong").await.unwrap();
314 });
315
316 let err = prepare_reports(&mut client).await.unwrap_err();
317 assert!(err.to_string().contains("ping"));
318 }
319
320 #[test]
321 fn matches_pattern_uses_basename() {
322 assert!(matches_pattern("./foo/bar/Test.ips", "*.ips").unwrap());
323 assert!(!matches_pattern("./foo/bar/Test.ips", "foo*").unwrap());
324 }
325
326 #[test]
327 fn sort_reports_prefers_modified_descending() {
328 let mut entries = vec![
329 CrashReportEntry {
330 path: "./B.ips".into(),
331 size: Some(20),
332 modified: Some("2026-04-01 10:00:00 UTC".into()),
333 },
334 CrashReportEntry {
335 path: "./A.ips".into(),
336 size: Some(10),
337 modified: Some("2026-04-02 10:00:00 UTC".into()),
338 },
339 CrashReportEntry {
340 path: "./C.ips".into(),
341 size: Some(5),
342 modified: None,
343 },
344 ];
345
346 sort_reports(&mut entries);
347 assert_eq!(entries[0].path, "./A.ips");
348 assert_eq!(entries[1].path, "./B.ips");
349 assert_eq!(entries[2].path, "./C.ips");
350 }
351
352 #[test]
353 fn modified_time_formats_raw_afc_timestamp() {
354 let info = AfcFileInfo {
355 name: Some("Example.ips".into()),
356 file_type: Some("S_IFREG".into()),
357 size: Some(1),
358 mode: None,
359 link_target: None,
360 raw: std::iter::once(("st_mtime".into(), "86400000000000".into())).collect(),
361 };
362
363 assert_eq!(modified_time(&info), Some("1970-01-02 00:00:00 UTC".into()));
364 }
365
366 #[test]
367 fn resolve_report_path_from_entries_uses_basename_match() {
368 let reports = vec![CrashReportEntry {
369 path: "./foo/Example.ips".into(),
370 size: Some(1),
371 modified: None,
372 }];
373
374 let resolved = resolve_report_path_from_entries("Example.ips", &reports).unwrap();
375 assert_eq!(resolved, "./foo/Example.ips");
376 }
377
378 #[test]
379 fn resolve_report_path_from_entries_rejects_ambiguous_basename() {
380 let reports = vec![
381 CrashReportEntry {
382 path: "./foo/Example.ips".into(),
383 size: Some(1),
384 modified: None,
385 },
386 CrashReportEntry {
387 path: "./bar/Example.ips".into(),
388 size: Some(2),
389 modified: None,
390 },
391 ];
392
393 let err = resolve_report_path_from_entries("Example.ips", &reports).unwrap_err();
394 assert!(err.to_string().contains("ambiguous"));
395 }
396
397 #[tokio::test]
398 async fn remove_reports_removes_only_matching_reports() {
399 let (client_side, mut server_side) = duplex(4096);
400 let removed_paths = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
401 let removed_paths_server = removed_paths.clone();
402
403 tokio::spawn(async move {
404 let stat_names = ["B.log", "A.ips", "C.ips"];
405 let mut removed = 0usize;
406
407 loop {
408 let mut hdr_buf = [0u8; AfcHeader::SIZE];
409 if server_side.read_exact(&mut hdr_buf).await.is_err() {
410 break;
411 }
412 let hdr = AfcHeader::ref_from_bytes(&hdr_buf).unwrap();
413 let entire_len = hdr.entire_len.get() as usize;
414 let this_len = hdr.this_len.get() as usize;
415 let header_payload_len = this_len.saturating_sub(AfcHeader::SIZE);
416 let payload_len = entire_len.saturating_sub(this_len);
417 let mut header_payload = vec![0u8; header_payload_len];
418 let mut payload = vec![0u8; payload_len];
419
420 if header_payload_len > 0 {
421 server_side.read_exact(&mut header_payload).await.unwrap();
422 }
423 if payload_len > 0 {
424 server_side.read_exact(&mut payload).await.unwrap();
425 }
426
427 match hdr.operation.get() {
428 x if x == AfcOpcode::ReadDir as u64 => {
429 assert_eq!(trim_c_string(&header_payload), ".");
430 let names = stat_names.join("\0") + "\0";
431 let resp = AfcHeader::new(
432 hdr.packet_num.get(),
433 AfcOpcode::ReadDir,
434 0,
435 names.len(),
436 );
437 server_side.write_all(resp.as_bytes()).await.unwrap();
438 server_side.write_all(names.as_bytes()).await.unwrap();
439 }
440 x if x == AfcOpcode::GetFileInfo as u64 => {
441 let path = trim_c_string(&header_payload);
442 let basename = path_basename(&path);
443 let payload = match basename {
444 "B.log" => b"st_ifmt\0S_IFREG\0st_size\x001\0".as_slice(),
445 "A.ips" => b"st_ifmt\0S_IFREG\0st_size\x001\0".as_slice(),
446 "C.ips" => b"st_ifmt\0S_IFREG\0st_size\x001\0".as_slice(),
447 other => panic!("unexpected stat path: {other}"),
448 };
449 let resp = AfcHeader::new(
450 hdr.packet_num.get(),
451 AfcOpcode::GetFileInfo,
452 0,
453 payload.len(),
454 );
455 server_side.write_all(resp.as_bytes()).await.unwrap();
456 server_side.write_all(payload).await.unwrap();
457 }
458 x if x == AfcOpcode::RemovePath as u64 => {
459 let path = trim_c_string(&header_payload);
460 removed_paths_server.lock().unwrap().push(path);
461 removed += 1;
462 let resp = AfcHeader::new(hdr.packet_num.get(), AfcOpcode::Status, 8, 0);
463 server_side.write_all(resp.as_bytes()).await.unwrap();
464 server_side.write_all(&0u64.to_le_bytes()).await.unwrap();
465 if removed == 2 {
466 break;
467 }
468 }
469 other => panic!("unexpected AFC opcode: {other}"),
470 }
471 }
472 });
473
474 let mut client = CrashReportClient::new(client_side);
475 let removed = client.remove_reports(Some("*.ips")).await.unwrap();
476
477 assert_eq!(removed, 2);
478 assert_eq!(
479 removed_paths.lock().unwrap().as_slice(),
480 &["./A.ips".to_string(), "./C.ips".to_string()]
481 );
482 }
483
484 #[tokio::test]
485 async fn remove_reports_returns_zero_for_no_matches() {
486 let (client_side, mut server_side) = duplex(4096);
487
488 tokio::spawn(async move {
489 loop {
490 let mut hdr_buf = [0u8; AfcHeader::SIZE];
491 if server_side.read_exact(&mut hdr_buf).await.is_err() {
492 break;
493 }
494 let hdr = AfcHeader::ref_from_bytes(&hdr_buf).unwrap();
495 let entire_len = hdr.entire_len.get() as usize;
496 let this_len = hdr.this_len.get() as usize;
497 let header_payload_len = this_len.saturating_sub(AfcHeader::SIZE);
498 let payload_len = entire_len.saturating_sub(this_len);
499 let mut header_payload = vec![0u8; header_payload_len];
500 let mut payload = vec![0u8; payload_len];
501
502 if header_payload_len > 0 {
503 server_side.read_exact(&mut header_payload).await.unwrap();
504 }
505 if payload_len > 0 {
506 server_side.read_exact(&mut payload).await.unwrap();
507 }
508
509 match hdr.operation.get() {
510 x if x == AfcOpcode::ReadDir as u64 => {
511 let names = b"Only.log\0".to_vec();
512 let resp = AfcHeader::new(
513 hdr.packet_num.get(),
514 AfcOpcode::ReadDir,
515 0,
516 names.len(),
517 );
518 server_side.write_all(resp.as_bytes()).await.unwrap();
519 server_side.write_all(&names).await.unwrap();
520 }
521 x if x == AfcOpcode::GetFileInfo as u64 => {
522 let payload = b"st_ifmt\0S_IFREG\0st_size\0\x31\0";
523 let resp = AfcHeader::new(
524 hdr.packet_num.get(),
525 AfcOpcode::GetFileInfo,
526 0,
527 payload.len(),
528 );
529 server_side.write_all(resp.as_bytes()).await.unwrap();
530 server_side.write_all(payload).await.unwrap();
531 }
532 other => panic!("unexpected AFC opcode: {other}"),
533 }
534 }
535 });
536
537 let mut client = CrashReportClient::new(client_side);
538 let removed = client.remove_reports(Some("*.ips")).await.unwrap();
539
540 assert_eq!(removed, 0);
541 }
542
543 fn trim_c_string(bytes: &[u8]) -> String {
544 String::from_utf8_lossy(bytes)
545 .trim_end_matches('\0')
546 .to_string()
547 }
548}