Skip to main content

ios_core/services/crashreport/
mod.rs

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}