ios_core/services/diagnosticsservice/
mod.rs1use indexmap::IndexMap;
8
9use crate::xpc::{XpcClient, XpcError, XpcMessage, XpcValue};
10
11pub const SERVICE_NAME: &str = "com.apple.coredevice.diagnosticsservice";
13
14const FEATURE_CAPTURE_SYSDIAGNOSE: &str = "com.apple.coredevice.feature.capturesysdiagnose";
15
16#[derive(Debug, thiserror::Error)]
18pub enum DiagnosticsServiceError {
19 #[error("xpc error: {0}")]
21 Xpc(#[from] XpcError),
22 #[error("protocol error: {0}")]
24 Protocol(String),
25}
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct SysdiagnoseResponse {
30 pub preferred_filename: String,
32 pub file_size: u64,
34 pub file_transfer: XpcValue,
36}
37
38pub struct DiagnosticsServiceClient {
40 client: XpcClient,
41 device_identifier: String,
42}
43
44impl DiagnosticsServiceClient {
45 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 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}