Skip to main content

ios_core/services/diagnosticsservice/
mod.rs

1//! iOS 17+ CoreDevice diagnostics service via XPC/RSD.
2//!
3//! This module currently covers the CoreDevice sysdiagnose feature. The service
4//! returns metadata and an XPC file-transfer token; consumers that need the full
5//! archive must use the returned transfer information with a compatible data path.
6
7use indexmap::IndexMap;
8
9use crate::xpc::{XpcClient, XpcError, XpcMessage, XpcValue};
10
11/// RSD service name for CoreDevice diagnostics.
12pub const SERVICE_NAME: &str = "com.apple.coredevice.diagnosticsservice";
13
14const FEATURE_CAPTURE_SYSDIAGNOSE: &str = "com.apple.coredevice.feature.capturesysdiagnose";
15
16/// Errors returned by CoreDevice diagnostics operations.
17#[derive(Debug, thiserror::Error)]
18pub enum DiagnosticsServiceError {
19    /// Underlying XPC transport or encoding error.
20    #[error("xpc error: {0}")]
21    Xpc(#[from] XpcError),
22    /// Diagnosticsservice response did not match the expected protocol shape.
23    #[error("protocol error: {0}")]
24    Protocol(String),
25}
26
27/// Metadata returned by the CoreDevice sysdiagnose feature.
28#[derive(Debug, Clone, PartialEq)]
29pub struct SysdiagnoseResponse {
30    /// Filename preferred by the device for the sysdiagnose archive.
31    pub preferred_filename: String,
32    /// Archive size reported by CoreDevice.
33    pub file_size: u64,
34    /// Raw XPC file-transfer object for consumers that implement the transfer path.
35    pub file_transfer: XpcValue,
36}
37
38/// Client for the CoreDevice diagnostics service.
39pub struct DiagnosticsServiceClient {
40    client: XpcClient,
41    device_identifier: String,
42}
43
44impl DiagnosticsServiceClient {
45    /// Create a diagnostics client from an initialized XPC client and device identifier.
46    pub fn new(client: XpcClient, device_identifier: impl Into<String>) -> Self {
47        Self {
48            client,
49            device_identifier: device_identifier.into(),
50        }
51    }
52
53    /// Request a sysdiagnose capture.
54    ///
55    /// When `dry_run` is true, the device validates and describes the capture without
56    /// collecting the full archive.
57    pub async fn capture_sysdiagnose(
58        &mut self,
59        dry_run: bool,
60    ) -> Result<SysdiagnoseResponse, DiagnosticsServiceError> {
61        let response = self
62            .client
63            .call(build_request(
64                &self.device_identifier,
65                build_capture_sysdiagnose_input(dry_run),
66            ))
67            .await?;
68        parse_capture_sysdiagnose_response(response)
69    }
70}
71
72fn build_capture_sysdiagnose_input(is_dry_run: bool) -> XpcValue {
73    XpcValue::Dictionary(IndexMap::from([
74        (
75            "options".to_string(),
76            XpcValue::Dictionary(IndexMap::from([(
77                "collectFullLogs".to_string(),
78                XpcValue::Bool(true),
79            )])),
80        ),
81        ("isDryRun".to_string(), XpcValue::Bool(is_dry_run)),
82    ]))
83}
84
85fn build_request(device_identifier: &str, input: XpcValue) -> XpcValue {
86    crate::services::coredevice::build_request(
87        device_identifier,
88        FEATURE_CAPTURE_SYSDIAGNOSE,
89        input,
90    )
91}
92
93fn parse_capture_sysdiagnose_response(
94    response: XpcMessage,
95) -> Result<SysdiagnoseResponse, DiagnosticsServiceError> {
96    let output = crate::services::coredevice::parse_output(response)
97        .map_err(DiagnosticsServiceError::Protocol)?;
98    let dict = output.as_dict().ok_or_else(|| {
99        DiagnosticsServiceError::Protocol(format!(
100            "capture sysdiagnose output is not a dictionary: {output:?}"
101        ))
102    })?;
103    let preferred_filename = dict
104        .get("preferredFilename")
105        .and_then(XpcValue::as_str)
106        .ok_or_else(|| {
107            DiagnosticsServiceError::Protocol(format!(
108                "capture sysdiagnose output missing preferredFilename: {output:?}"
109            ))
110        })?
111        .to_string();
112    let file_transfer = dict.get("fileTransfer").cloned().ok_or_else(|| {
113        DiagnosticsServiceError::Protocol(format!(
114            "capture sysdiagnose output missing fileTransfer: {output:?}"
115        ))
116    })?;
117    let file_size = parse_file_transfer_size(&file_transfer)?;
118
119    Ok(SysdiagnoseResponse {
120        preferred_filename,
121        file_size,
122        file_transfer,
123    })
124}
125
126fn parse_file_transfer_size(value: &XpcValue) -> Result<u64, DiagnosticsServiceError> {
127    if let Some((_, transfer)) = value.as_file_transfer() {
128        return transfer
129            .as_dict()
130            .and_then(|dict| dict.get("s"))
131            .and_then(as_u64)
132            .ok_or_else(|| {
133                DiagnosticsServiceError::Protocol("fileTransfer missing transfer size field".into())
134            });
135    }
136
137    let dict = value.as_dict().ok_or_else(|| {
138        DiagnosticsServiceError::Protocol(format!("unsupported fileTransfer shape: {value:?}"))
139    })?;
140    if let Some(size) = dict.get("expectedLength").and_then(as_u64) {
141        return Ok(size);
142    }
143    dict.get("xpcFileTransfer")
144        .ok_or_else(|| {
145            DiagnosticsServiceError::Protocol(format!(
146                "fileTransfer missing expectedLength/xpcFileTransfer: {value:?}"
147            ))
148        })
149        .and_then(parse_file_transfer_size)
150}
151
152fn as_u64(value: &XpcValue) -> Option<u64> {
153    match value {
154        XpcValue::Uint64(value) => Some(*value),
155        XpcValue::Int64(value) if *value >= 0 => Some(*value as u64),
156        _ => None,
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use indexmap::IndexMap;
163
164    use crate::xpc::{XpcMessage, XpcValue};
165
166    use super::*;
167
168    #[test]
169    fn build_capture_sysdiagnose_input_matches_reference_shape() {
170        let input = build_capture_sysdiagnose_input(true);
171        let dict = input.as_dict().expect("input should be a dictionary");
172
173        assert_eq!(dict["isDryRun"], XpcValue::Bool(true));
174        let options = dict["options"].as_dict().expect("options should be a dict");
175        assert_eq!(options["collectFullLogs"], XpcValue::Bool(true));
176    }
177
178    #[test]
179    fn build_request_wraps_capture_sysdiagnose_feature() {
180        let request = build_request("DEVICE-ID", build_capture_sysdiagnose_input(true));
181        let dict = request.as_dict().expect("request should be a dictionary");
182
183        assert_eq!(
184            dict["CoreDevice.featureIdentifier"].as_str(),
185            Some(FEATURE_CAPTURE_SYSDIAGNOSE)
186        );
187        assert_eq!(
188            dict["CoreDevice.deviceIdentifier"].as_str(),
189            Some("DEVICE-ID")
190        );
191    }
192
193    #[test]
194    fn parse_capture_sysdiagnose_response_reads_metadata() {
195        let response = XpcMessage {
196            flags: 0,
197            msg_id: 1,
198            body: Some(XpcValue::Dictionary(IndexMap::from([(
199                "CoreDevice.output".to_string(),
200                XpcValue::Dictionary(IndexMap::from([
201                    (
202                        "preferredFilename".to_string(),
203                        XpcValue::String("sysdiagnose_2026.tar.gz".into()),
204                    ),
205                    (
206                        "fileTransfer".to_string(),
207                        XpcValue::Dictionary(IndexMap::from([(
208                            "expectedLength".to_string(),
209                            XpcValue::Uint64(4096),
210                        )])),
211                    ),
212                ])),
213            )]))),
214        };
215
216        let parsed = parse_capture_sysdiagnose_response(response).unwrap();
217
218        assert_eq!(parsed.preferred_filename, "sysdiagnose_2026.tar.gz");
219        assert_eq!(parsed.file_size, 4096);
220    }
221
222    #[test]
223    fn parse_capture_sysdiagnose_response_accepts_nested_xpc_file_transfer() {
224        let response = XpcMessage {
225            flags: 0,
226            msg_id: 1,
227            body: Some(XpcValue::Dictionary(IndexMap::from([(
228                "CoreDevice.output".to_string(),
229                XpcValue::Dictionary(IndexMap::from([
230                    (
231                        "preferredFilename".to_string(),
232                        XpcValue::String("sysdiagnose.tar.gz".into()),
233                    ),
234                    (
235                        "fileTransfer".to_string(),
236                        XpcValue::Dictionary(IndexMap::from([(
237                            "xpcFileTransfer".to_string(),
238                            XpcValue::FileTransfer {
239                                msg_id: 7,
240                                data: Box::new(XpcValue::Dictionary(IndexMap::from([(
241                                    "s".to_string(),
242                                    XpcValue::Int64(8192),
243                                )]))),
244                            },
245                        )])),
246                    ),
247                ])),
248            )]))),
249        };
250
251        let parsed = parse_capture_sysdiagnose_response(response).unwrap();
252
253        assert_eq!(parsed.file_size, 8192);
254    }
255}