Skip to main content

ios_core/services/
diagnostics.rs

1//! Diagnostics relay service.
2//!
3//! Provides access to device diagnostic info and MobileGestalt queries.
4//! Service: `com.apple.mobile.diagnostics_relay`
5//!
6//! Protocol: lockdown plist framing (4-byte BE length prefix).
7
8use serde::{Deserialize, Serialize};
9use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
10
11pub const SERVICE_NAME: &str = "com.apple.mobile.diagnostics_relay";
12
13service_error!(
14    DiagnosticsError,
15    between {
16    #[error("mobilegestalt deprecated: {0}")]
17    Deprecated(String),
18    },
19    after {},
20);
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "PascalCase")]
24pub struct BatteryDiagnostics {
25    #[serde(default)]
26    pub instant_amperage: Option<i64>,
27    #[serde(default)]
28    pub temperature: Option<i64>,
29    #[serde(default)]
30    pub voltage: Option<i64>,
31    #[serde(default)]
32    pub is_charging: Option<bool>,
33    #[serde(default)]
34    pub current_capacity: Option<i64>,
35    #[serde(default)]
36    pub design_capacity: Option<u64>,
37    #[serde(default)]
38    pub nominal_charge_capacity: Option<u64>,
39    #[serde(default)]
40    pub absolute_capacity: Option<u64>,
41    #[serde(default)]
42    pub apple_raw_current_capacity: Option<u64>,
43    #[serde(default)]
44    pub apple_raw_max_capacity: Option<u64>,
45    #[serde(default)]
46    pub cycle_count: Option<u64>,
47    #[serde(default)]
48    pub at_critical_level: Option<bool>,
49    #[serde(default)]
50    pub at_warn_level: Option<bool>,
51}
52
53#[derive(Serialize)]
54#[serde(rename_all = "PascalCase")]
55struct MobileGestaltRequest<'a> {
56    request: &'static str,
57    #[serde(rename = "MobileGestaltKeys")]
58    keys: Vec<&'a str>,
59}
60
61#[derive(Serialize)]
62#[serde(rename_all = "PascalCase")]
63struct IoRegistryRequest<'a> {
64    request: &'static str,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    entry_class: Option<&'a str>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    entry_name: Option<&'a str>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    current_plane: Option<&'a str>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct IoRegistryQuery<'a> {
75    pub entry_class: Option<&'a str>,
76    pub entry_name: Option<&'a str>,
77    pub current_plane: Option<&'a str>,
78}
79
80impl<'a> IoRegistryQuery<'a> {
81    pub fn by_class(entry_class: &'a str) -> Self {
82        Self {
83            entry_class: Some(entry_class),
84            entry_name: None,
85            current_plane: None,
86        }
87    }
88}
89
90/// Query MobileGestalt keys from the device.
91pub async fn query_mobile_gestalt<S>(
92    stream: &mut S,
93    keys: &[&str],
94) -> Result<plist::Value, DiagnosticsError>
95where
96    S: AsyncRead + AsyncWrite + Unpin + ?Sized,
97{
98    send_plist(
99        stream,
100        &MobileGestaltRequest {
101            request: "MobileGestalt",
102            keys: keys.to_vec(),
103        },
104    )
105    .await?;
106
107    let response = recv_response_dict(stream).await?;
108    let diagnostics = extract_diagnostics_payload(&response)?;
109
110    extract_mobile_gestalt_payload(diagnostics)
111}
112
113/// Query the complete diagnostics payload exposed by diagnostics_relay.
114pub async fn query_all_values<S>(stream: &mut S) -> Result<plist::Value, DiagnosticsError>
115where
116    S: AsyncRead + AsyncWrite + Unpin + ?Sized,
117{
118    #[derive(Serialize)]
119    #[serde(rename_all = "PascalCase")]
120    struct AllRequest {
121        request: &'static str,
122    }
123
124    send_plist(stream, &AllRequest { request: "All" }).await?;
125    let response = recv_response_dict(stream).await?;
126    extract_diagnostics_payload(&response)
127}
128
129/// Query the battery IORegistry block exposed by diagnostics_relay.
130pub async fn query_battery<S>(stream: &mut S) -> Result<BatteryDiagnostics, DiagnosticsError>
131where
132    S: AsyncRead + AsyncWrite + Unpin + ?Sized,
133{
134    let io_registry = query_ioregistry(stream, "IOPMPowerSource").await?;
135    plist::from_value(&io_registry).map_err(|e| DiagnosticsError::Plist(e.to_string()))
136}
137
138/// Query an arbitrary IORegistry entry class exposed by diagnostics_relay.
139pub async fn query_ioregistry<S>(
140    stream: &mut S,
141    entry_class: &str,
142) -> Result<plist::Value, DiagnosticsError>
143where
144    S: AsyncRead + AsyncWrite + Unpin + ?Sized,
145{
146    query_ioregistry_with(stream, IoRegistryQuery::by_class(entry_class)).await
147}
148
149pub async fn query_ioregistry_with<S>(
150    stream: &mut S,
151    query: IoRegistryQuery<'_>,
152) -> Result<plist::Value, DiagnosticsError>
153where
154    S: AsyncRead + AsyncWrite + Unpin + ?Sized,
155{
156    if query.entry_class.is_none() && query.entry_name.is_none() {
157        return Err(DiagnosticsError::Protocol(
158            "IORegistry query requires EntryClass or EntryName".into(),
159        ));
160    }
161
162    send_plist(
163        stream,
164        &IoRegistryRequest {
165            request: "IORegistry",
166            entry_class: query.entry_class,
167            entry_name: query.entry_name,
168            current_plane: query.current_plane,
169        },
170    )
171    .await?;
172
173    let response = recv_response_dict(stream).await?;
174    let diagnostics = extract_diagnostics_payload(&response)?;
175    let dict = diagnostics.into_dictionary().ok_or_else(|| {
176        DiagnosticsError::Protocol("diagnostics payload was not a dictionary".into())
177    })?;
178    let io_registry = dict
179        .get("IORegistry")
180        .cloned()
181        .ok_or_else(|| DiagnosticsError::Protocol("diagnostics missing IORegistry".into()))?;
182    Ok(io_registry)
183}
184
185/// Reboot the device.
186pub async fn reboot<S>(stream: &mut S) -> Result<(), DiagnosticsError>
187where
188    S: AsyncRead + AsyncWrite + Unpin + ?Sized,
189{
190    #[derive(Serialize)]
191    #[serde(rename_all = "PascalCase")]
192    struct Request {
193        request: &'static str,
194    }
195    send_plist(stream, &Request { request: "Restart" }).await?;
196    recv_plist_raw(stream).await?;
197    Ok(())
198}
199
200// ── plist framing ──────────────────────────────────────────────────────────────
201
202async fn send_plist<S, T>(stream: &mut S, value: &T) -> Result<(), DiagnosticsError>
203where
204    S: AsyncWrite + Unpin + ?Sized,
205    T: Serialize,
206{
207    let mut buf = Vec::new();
208    plist::to_writer_xml(&mut buf, value).map_err(|e| DiagnosticsError::Plist(e.to_string()))?;
209    stream.write_all(&(buf.len() as u32).to_be_bytes()).await?;
210    stream.write_all(&buf).await?;
211    stream.flush().await?;
212    Ok(())
213}
214
215async fn recv_plist_raw<S>(stream: &mut S) -> Result<Vec<u8>, DiagnosticsError>
216where
217    S: AsyncRead + Unpin + ?Sized,
218{
219    let mut len_buf = [0u8; 4];
220    stream.read_exact(&mut len_buf).await?;
221    let len = u32::from_be_bytes(len_buf) as usize;
222    // Guard against DoS via enormous length field (max 4 MiB)
223    const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
224    if len > MAX_PLIST_SIZE {
225        return Err(DiagnosticsError::Protocol(format!(
226            "plist length {len} exceeds max {MAX_PLIST_SIZE}"
227        )));
228    }
229    let mut buf = vec![0u8; len];
230    stream.read_exact(&mut buf).await?;
231    Ok(buf)
232}
233
234async fn recv_response_dict<S>(stream: &mut S) -> Result<plist::Dictionary, DiagnosticsError>
235where
236    S: AsyncRead + Unpin + ?Sized,
237{
238    let data = recv_plist_raw(stream).await?;
239    let value: plist::Value =
240        plist::from_bytes(&data).map_err(|e| DiagnosticsError::Plist(e.to_string()))?;
241    value.into_dictionary().ok_or_else(|| {
242        DiagnosticsError::Protocol("diagnostics response payload was not a dictionary".into())
243    })
244}
245
246fn extract_diagnostics_payload(
247    response: &plist::Dictionary,
248) -> Result<plist::Value, DiagnosticsError> {
249    response
250        .get("Diagnostics")
251        .cloned()
252        .ok_or_else(|| missing_diagnostics_error(response))
253}
254
255fn missing_diagnostics_error(response: &plist::Dictionary) -> DiagnosticsError {
256    let status = response
257        .get("Status")
258        .and_then(plist::Value::as_string)
259        .map(|value| format!(" (Status={value})"))
260        .unwrap_or_default();
261    let rendered = render_plist_value(&plist::Value::Dictionary(response.clone()));
262    DiagnosticsError::Protocol(format!(
263        "diagnostics response missing Diagnostics{status}: {rendered}"
264    ))
265}
266
267fn render_plist_value(value: &plist::Value) -> String {
268    serde_json::to_string(&plist_to_json(value))
269        .unwrap_or_else(|_| "<failed to render plist value>".to_string())
270}
271
272fn plist_to_json(value: &plist::Value) -> serde_json::Value {
273    match value {
274        plist::Value::Array(items) => {
275            serde_json::Value::Array(items.iter().map(plist_to_json).collect())
276        }
277        plist::Value::Boolean(value) => serde_json::Value::Bool(*value),
278        plist::Value::Data(bytes) => {
279            serde_json::Value::Array(bytes.iter().copied().map(serde_json::Value::from).collect())
280        }
281        plist::Value::Date(value) => serde_json::Value::String(value.to_xml_format()),
282        plist::Value::Dictionary(dict) => serde_json::Value::Object(
283            dict.iter()
284                .map(|(key, value)| (key.clone(), plist_to_json(value)))
285                .collect(),
286        ),
287        plist::Value::Integer(value) => value
288            .as_signed()
289            .map(serde_json::Value::from)
290            .or_else(|| value.as_unsigned().map(serde_json::Value::from))
291            .unwrap_or(serde_json::Value::Null),
292        plist::Value::Real(value) => serde_json::Value::from(*value),
293        plist::Value::String(value) => serde_json::Value::String(value.clone()),
294        plist::Value::Uid(value) => serde_json::Value::from(value.get()),
295        _ => serde_json::Value::Null,
296    }
297}
298
299fn extract_mobile_gestalt_payload(
300    diagnostics: plist::Value,
301) -> Result<plist::Value, DiagnosticsError> {
302    let Some(dict) = diagnostics.as_dictionary() else {
303        return Ok(diagnostics);
304    };
305
306    let Some(mobile_gestalt) = dict.get("MobileGestalt") else {
307        return Ok(diagnostics);
308    };
309
310    let mut inner = mobile_gestalt.as_dictionary().cloned().ok_or_else(|| {
311        DiagnosticsError::Protocol("MobileGestalt payload was not a dictionary".into())
312    })?;
313
314    if let Some(status) = inner.get("Status").and_then(|value| value.as_string()) {
315        match status {
316            "Success" => {
317                inner.remove("Status");
318            }
319            "MobileGestaltDeprecated" => {
320                return Err(DiagnosticsError::Deprecated(
321                    "diagnostics relay reports MobileGestaltDeprecated on this OS".into(),
322                ));
323            }
324            other => {
325                return Err(DiagnosticsError::Protocol(format!(
326                    "unexpected MobileGestalt status: {other}"
327                )));
328            }
329        }
330    }
331
332    Ok(plist::Value::Dictionary(inner))
333}
334
335#[cfg(test)]
336mod tests {
337    use crate::test_util::MockStream;
338
339    use super::*;
340
341    #[tokio::test]
342    async fn query_mobile_gestalt_uses_mobile_gestalt_keys_field() {
343        let mut stream =
344            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
345                "Diagnostics".to_string(),
346                plist::Value::Dictionary(plist::Dictionary::new()),
347            )])));
348
349        let _ = query_mobile_gestalt(&mut stream, &["ProductVersion"])
350            .await
351            .unwrap();
352
353        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
354        let payload = &stream.written[4..4 + len];
355        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
356        assert_eq!(dict["Request"].as_string(), Some("MobileGestalt"));
357        let keys = dict["MobileGestaltKeys"].as_array().unwrap();
358        assert_eq!(keys.len(), 1);
359        assert_eq!(keys[0].as_string(), Some("ProductVersion"));
360        assert!(!dict.contains_key("Keys"));
361    }
362
363    #[tokio::test]
364    async fn query_mobile_gestalt_returns_diagnostics_payload() {
365        let mut stream =
366            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
367                "Diagnostics".to_string(),
368                plist::Value::Dictionary(plist::Dictionary::from_iter([(
369                    "ProductVersion".to_string(),
370                    plist::Value::String("26.0".into()),
371                )])),
372            )])));
373
374        let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
375            .await
376            .unwrap();
377        let dict = value.into_dictionary().unwrap();
378        assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
379    }
380
381    #[tokio::test]
382    async fn query_mobile_gestalt_strips_nested_success_status() {
383        let mut stream =
384            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
385                "Diagnostics".to_string(),
386                plist::Value::Dictionary(plist::Dictionary::from_iter([(
387                    "MobileGestalt".to_string(),
388                    plist::Value::Dictionary(plist::Dictionary::from_iter([
389                        ("Status".to_string(), plist::Value::String("Success".into())),
390                        (
391                            "ProductVersion".to_string(),
392                            plist::Value::String("26.0".into()),
393                        ),
394                    ])),
395                )])),
396            )])));
397
398        let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
399            .await
400            .unwrap();
401        let dict = value.into_dictionary().unwrap();
402        assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
403        assert!(!dict.contains_key("Status"));
404    }
405
406    #[tokio::test]
407    async fn query_mobile_gestalt_returns_deprecated_error() {
408        let mut stream =
409            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
410                "Diagnostics".to_string(),
411                plist::Value::Dictionary(plist::Dictionary::from_iter([(
412                    "MobileGestalt".to_string(),
413                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
414                        "Status".to_string(),
415                        plist::Value::String("MobileGestaltDeprecated".into()),
416                    )])),
417                )])),
418            )])));
419
420        let err = query_mobile_gestalt(&mut stream, &["ProductVersion"])
421            .await
422            .unwrap_err();
423        assert!(matches!(err, DiagnosticsError::Deprecated(_)));
424        assert!(err.to_string().contains("deprecated"));
425    }
426
427    #[tokio::test]
428    async fn query_all_values_sends_all_request_and_returns_diagnostics_payload() {
429        let mut stream =
430            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
431                "Diagnostics".to_string(),
432                plist::Value::Dictionary(plist::Dictionary::from_iter([(
433                    "GasGauge".to_string(),
434                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
435                        "CycleCount".to_string(),
436                        plist::Value::Integer(315.into()),
437                    )])),
438                )])),
439            )])));
440
441        let value = query_all_values(&mut stream).await.unwrap();
442        let dict = value.into_dictionary().unwrap();
443        assert!(dict.contains_key("GasGauge"));
444
445        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
446        let payload = &stream.written[4..4 + len];
447        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
448        assert_eq!(dict["Request"].as_string(), Some("All"));
449    }
450
451    #[tokio::test]
452    async fn query_battery_sends_ioregistry_request_for_power_source() {
453        let mut stream =
454            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
455                "Diagnostics".to_string(),
456                plist::Value::Dictionary(plist::Dictionary::from_iter([(
457                    "IORegistry".to_string(),
458                    plist::Value::Dictionary(plist::Dictionary::new()),
459                )])),
460            )])));
461
462        let _ = query_battery(&mut stream).await.unwrap();
463
464        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
465        let payload = &stream.written[4..4 + len];
466        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
467        assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
468        assert_eq!(dict["EntryClass"].as_string(), Some("IOPMPowerSource"));
469    }
470
471    #[tokio::test]
472    async fn query_battery_extracts_ioregistry_payload() {
473        let mut stream =
474            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
475                "Diagnostics".to_string(),
476                plist::Value::Dictionary(plist::Dictionary::from_iter([(
477                    "IORegistry".to_string(),
478                    plist::Value::Dictionary(plist::Dictionary::from_iter([
479                        (
480                            "CurrentCapacity".to_string(),
481                            plist::Value::Integer(82.into()),
482                        ),
483                        ("IsCharging".to_string(), plist::Value::Boolean(true)),
484                        ("CycleCount".to_string(), plist::Value::Integer(315.into())),
485                    ])),
486                )])),
487            )])));
488
489        let battery = query_battery(&mut stream).await.unwrap();
490        assert_eq!(battery.current_capacity, Some(82));
491        assert_eq!(battery.is_charging, Some(true));
492        assert_eq!(battery.cycle_count, Some(315));
493    }
494
495    #[tokio::test]
496    async fn query_ioregistry_returns_raw_ioregistry_payload() {
497        let mut stream =
498            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
499                "Diagnostics".to_string(),
500                plist::Value::Dictionary(plist::Dictionary::from_iter([(
501                    "IORegistry".to_string(),
502                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
503                        "ProductName".to_string(),
504                        plist::Value::String("iPhone".into()),
505                    )])),
506                )])),
507            )])));
508
509        let value = query_ioregistry(&mut stream, "IOPlatformExpertDevice")
510            .await
511            .unwrap();
512        let dict = value.into_dictionary().unwrap();
513        assert_eq!(dict["ProductName"].as_string(), Some("iPhone"));
514
515        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
516        let payload = &stream.written[4..4 + len];
517        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
518        assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
519        assert_eq!(
520            dict["EntryClass"].as_string(),
521            Some("IOPlatformExpertDevice")
522        );
523        assert!(!dict.contains_key("EntryName"));
524        assert!(!dict.contains_key("CurrentPlane"));
525    }
526
527    #[tokio::test]
528    async fn query_ioregistry_with_name_and_plane_encodes_optional_fields() {
529        let mut stream =
530            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
531                "Diagnostics".to_string(),
532                plist::Value::Dictionary(plist::Dictionary::from_iter([(
533                    "IORegistry".to_string(),
534                    plist::Value::Dictionary(plist::Dictionary::new()),
535                )])),
536            )])));
537
538        let _ = query_ioregistry_with(
539            &mut stream,
540            IoRegistryQuery {
541                entry_class: None,
542                entry_name: Some("device-tree"),
543                current_plane: Some("IODeviceTree"),
544            },
545        )
546        .await
547        .unwrap();
548
549        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
550        let payload = &stream.written[4..4 + len];
551        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
552        assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
553        assert_eq!(dict["EntryName"].as_string(), Some("device-tree"));
554        assert_eq!(dict["CurrentPlane"].as_string(), Some("IODeviceTree"));
555        assert!(!dict.contains_key("EntryClass"));
556    }
557
558    #[tokio::test]
559    async fn query_ioregistry_reports_status_when_diagnostics_are_missing() {
560        let mut stream =
561            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([
562                (
563                    "Status".to_string(),
564                    plist::Value::String("LookupFailed".into()),
565                ),
566                (
567                    "Error".to_string(),
568                    plist::Value::String("Entry not found".into()),
569                ),
570            ])));
571
572        let err = query_ioregistry_with(
573            &mut stream,
574            IoRegistryQuery {
575                entry_class: Some("IO80211Interface"),
576                entry_name: None,
577                current_plane: None,
578            },
579        )
580        .await
581        .unwrap_err();
582
583        assert!(matches!(err, DiagnosticsError::Protocol(_)));
584        let message = err.to_string();
585        assert!(message.contains("LookupFailed"));
586        assert!(message.contains("Entry not found"));
587        assert!(message.contains("Diagnostics"));
588    }
589}