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    Ok(plist::from_value(&io_registry)?)
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)?;
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 = plist::from_bytes(&data)?;
240    value.into_dictionary().ok_or_else(|| {
241        DiagnosticsError::Protocol("diagnostics response payload was not a dictionary".into())
242    })
243}
244
245fn extract_diagnostics_payload(
246    response: &plist::Dictionary,
247) -> Result<plist::Value, DiagnosticsError> {
248    response
249        .get("Diagnostics")
250        .cloned()
251        .ok_or_else(|| missing_diagnostics_error(response))
252}
253
254fn missing_diagnostics_error(response: &plist::Dictionary) -> DiagnosticsError {
255    let status = response
256        .get("Status")
257        .and_then(plist::Value::as_string)
258        .map(|value| format!(" (Status={value})"))
259        .unwrap_or_default();
260    let rendered = render_plist_value(&plist::Value::Dictionary(response.clone()));
261    DiagnosticsError::Protocol(format!(
262        "diagnostics response missing Diagnostics{status}: {rendered}"
263    ))
264}
265
266fn render_plist_value(value: &plist::Value) -> String {
267    serde_json::to_string(&plist_to_json(value))
268        .unwrap_or_else(|_| "<failed to render plist value>".to_string())
269}
270
271fn plist_to_json(value: &plist::Value) -> serde_json::Value {
272    match value {
273        plist::Value::Array(items) => {
274            serde_json::Value::Array(items.iter().map(plist_to_json).collect())
275        }
276        plist::Value::Boolean(value) => serde_json::Value::Bool(*value),
277        plist::Value::Data(bytes) => {
278            serde_json::Value::Array(bytes.iter().copied().map(serde_json::Value::from).collect())
279        }
280        plist::Value::Date(value) => serde_json::Value::String(value.to_xml_format()),
281        plist::Value::Dictionary(dict) => serde_json::Value::Object(
282            dict.iter()
283                .map(|(key, value)| (key.clone(), plist_to_json(value)))
284                .collect(),
285        ),
286        plist::Value::Integer(value) => value
287            .as_signed()
288            .map(serde_json::Value::from)
289            .or_else(|| value.as_unsigned().map(serde_json::Value::from))
290            .unwrap_or(serde_json::Value::Null),
291        plist::Value::Real(value) => serde_json::Value::from(*value),
292        plist::Value::String(value) => serde_json::Value::String(value.clone()),
293        plist::Value::Uid(value) => serde_json::Value::from(value.get()),
294        _ => serde_json::Value::Null,
295    }
296}
297
298fn extract_mobile_gestalt_payload(
299    diagnostics: plist::Value,
300) -> Result<plist::Value, DiagnosticsError> {
301    let Some(dict) = diagnostics.as_dictionary() else {
302        return Ok(diagnostics);
303    };
304
305    let Some(mobile_gestalt) = dict.get("MobileGestalt") else {
306        return Ok(diagnostics);
307    };
308
309    let mut inner = mobile_gestalt.as_dictionary().cloned().ok_or_else(|| {
310        DiagnosticsError::Protocol("MobileGestalt payload was not a dictionary".into())
311    })?;
312
313    if let Some(status) = inner.get("Status").and_then(|value| value.as_string()) {
314        match status {
315            "Success" => {
316                inner.remove("Status");
317            }
318            "MobileGestaltDeprecated" => {
319                return Err(DiagnosticsError::Deprecated(
320                    "diagnostics relay reports MobileGestaltDeprecated on this OS".into(),
321                ));
322            }
323            other => {
324                return Err(DiagnosticsError::Protocol(format!(
325                    "unexpected MobileGestalt status: {other}"
326                )));
327            }
328        }
329    }
330
331    Ok(plist::Value::Dictionary(inner))
332}
333
334#[cfg(test)]
335mod tests {
336    use crate::test_util::MockStream;
337
338    use super::*;
339
340    #[tokio::test]
341    async fn query_mobile_gestalt_uses_mobile_gestalt_keys_field() {
342        let mut stream =
343            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
344                "Diagnostics".to_string(),
345                plist::Value::Dictionary(plist::Dictionary::new()),
346            )])));
347
348        let _ = query_mobile_gestalt(&mut stream, &["ProductVersion"])
349            .await
350            .unwrap();
351
352        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
353        let payload = &stream.written[4..4 + len];
354        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
355        assert_eq!(dict["Request"].as_string(), Some("MobileGestalt"));
356        let keys = dict["MobileGestaltKeys"].as_array().unwrap();
357        assert_eq!(keys.len(), 1);
358        assert_eq!(keys[0].as_string(), Some("ProductVersion"));
359        assert!(!dict.contains_key("Keys"));
360    }
361
362    #[tokio::test]
363    async fn query_mobile_gestalt_returns_diagnostics_payload() {
364        let mut stream =
365            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
366                "Diagnostics".to_string(),
367                plist::Value::Dictionary(plist::Dictionary::from_iter([(
368                    "ProductVersion".to_string(),
369                    plist::Value::String("26.0".into()),
370                )])),
371            )])));
372
373        let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
374            .await
375            .unwrap();
376        let dict = value.into_dictionary().unwrap();
377        assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
378    }
379
380    #[tokio::test]
381    async fn query_mobile_gestalt_strips_nested_success_status() {
382        let mut stream =
383            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
384                "Diagnostics".to_string(),
385                plist::Value::Dictionary(plist::Dictionary::from_iter([(
386                    "MobileGestalt".to_string(),
387                    plist::Value::Dictionary(plist::Dictionary::from_iter([
388                        ("Status".to_string(), plist::Value::String("Success".into())),
389                        (
390                            "ProductVersion".to_string(),
391                            plist::Value::String("26.0".into()),
392                        ),
393                    ])),
394                )])),
395            )])));
396
397        let value = query_mobile_gestalt(&mut stream, &["ProductVersion"])
398            .await
399            .unwrap();
400        let dict = value.into_dictionary().unwrap();
401        assert_eq!(dict["ProductVersion"].as_string(), Some("26.0"));
402        assert!(!dict.contains_key("Status"));
403    }
404
405    #[tokio::test]
406    async fn query_mobile_gestalt_returns_deprecated_error() {
407        let mut stream =
408            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
409                "Diagnostics".to_string(),
410                plist::Value::Dictionary(plist::Dictionary::from_iter([(
411                    "MobileGestalt".to_string(),
412                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
413                        "Status".to_string(),
414                        plist::Value::String("MobileGestaltDeprecated".into()),
415                    )])),
416                )])),
417            )])));
418
419        let err = query_mobile_gestalt(&mut stream, &["ProductVersion"])
420            .await
421            .unwrap_err();
422        assert!(matches!(err, DiagnosticsError::Deprecated(_)));
423        assert!(err.to_string().contains("deprecated"));
424    }
425
426    #[tokio::test]
427    async fn query_all_values_sends_all_request_and_returns_diagnostics_payload() {
428        let mut stream =
429            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
430                "Diagnostics".to_string(),
431                plist::Value::Dictionary(plist::Dictionary::from_iter([(
432                    "GasGauge".to_string(),
433                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
434                        "CycleCount".to_string(),
435                        plist::Value::Integer(315.into()),
436                    )])),
437                )])),
438            )])));
439
440        let value = query_all_values(&mut stream).await.unwrap();
441        let dict = value.into_dictionary().unwrap();
442        assert!(dict.contains_key("GasGauge"));
443
444        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
445        let payload = &stream.written[4..4 + len];
446        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
447        assert_eq!(dict["Request"].as_string(), Some("All"));
448    }
449
450    #[tokio::test]
451    async fn query_battery_sends_ioregistry_request_for_power_source() {
452        let mut stream =
453            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
454                "Diagnostics".to_string(),
455                plist::Value::Dictionary(plist::Dictionary::from_iter([(
456                    "IORegistry".to_string(),
457                    plist::Value::Dictionary(plist::Dictionary::new()),
458                )])),
459            )])));
460
461        let _ = query_battery(&mut stream).await.unwrap();
462
463        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
464        let payload = &stream.written[4..4 + len];
465        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
466        assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
467        assert_eq!(dict["EntryClass"].as_string(), Some("IOPMPowerSource"));
468    }
469
470    #[tokio::test]
471    async fn query_battery_extracts_ioregistry_payload() {
472        let mut stream =
473            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
474                "Diagnostics".to_string(),
475                plist::Value::Dictionary(plist::Dictionary::from_iter([(
476                    "IORegistry".to_string(),
477                    plist::Value::Dictionary(plist::Dictionary::from_iter([
478                        (
479                            "CurrentCapacity".to_string(),
480                            plist::Value::Integer(82.into()),
481                        ),
482                        ("IsCharging".to_string(), plist::Value::Boolean(true)),
483                        ("CycleCount".to_string(), plist::Value::Integer(315.into())),
484                    ])),
485                )])),
486            )])));
487
488        let battery = query_battery(&mut stream).await.unwrap();
489        assert_eq!(battery.current_capacity, Some(82));
490        assert_eq!(battery.is_charging, Some(true));
491        assert_eq!(battery.cycle_count, Some(315));
492    }
493
494    #[tokio::test]
495    async fn query_ioregistry_returns_raw_ioregistry_payload() {
496        let mut stream =
497            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
498                "Diagnostics".to_string(),
499                plist::Value::Dictionary(plist::Dictionary::from_iter([(
500                    "IORegistry".to_string(),
501                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
502                        "ProductName".to_string(),
503                        plist::Value::String("iPhone".into()),
504                    )])),
505                )])),
506            )])));
507
508        let value = query_ioregistry(&mut stream, "IOPlatformExpertDevice")
509            .await
510            .unwrap();
511        let dict = value.into_dictionary().unwrap();
512        assert_eq!(dict["ProductName"].as_string(), Some("iPhone"));
513
514        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
515        let payload = &stream.written[4..4 + len];
516        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
517        assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
518        assert_eq!(
519            dict["EntryClass"].as_string(),
520            Some("IOPlatformExpertDevice")
521        );
522        assert!(!dict.contains_key("EntryName"));
523        assert!(!dict.contains_key("CurrentPlane"));
524    }
525
526    #[tokio::test]
527    async fn query_ioregistry_with_name_and_plane_encodes_optional_fields() {
528        let mut stream =
529            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([(
530                "Diagnostics".to_string(),
531                plist::Value::Dictionary(plist::Dictionary::from_iter([(
532                    "IORegistry".to_string(),
533                    plist::Value::Dictionary(plist::Dictionary::new()),
534                )])),
535            )])));
536
537        let _ = query_ioregistry_with(
538            &mut stream,
539            IoRegistryQuery {
540                entry_class: None,
541                entry_name: Some("device-tree"),
542                current_plane: Some("IODeviceTree"),
543            },
544        )
545        .await
546        .unwrap();
547
548        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
549        let payload = &stream.written[4..4 + len];
550        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
551        assert_eq!(dict["Request"].as_string(), Some("IORegistry"));
552        assert_eq!(dict["EntryName"].as_string(), Some("device-tree"));
553        assert_eq!(dict["CurrentPlane"].as_string(), Some("IODeviceTree"));
554        assert!(!dict.contains_key("EntryClass"));
555    }
556
557    #[tokio::test]
558    async fn query_ioregistry_reports_status_when_diagnostics_are_missing() {
559        let mut stream =
560            MockStream::with_response(plist::Value::Dictionary(plist::Dictionary::from_iter([
561                (
562                    "Status".to_string(),
563                    plist::Value::String("LookupFailed".into()),
564                ),
565                (
566                    "Error".to_string(),
567                    plist::Value::String("Entry not found".into()),
568                ),
569            ])));
570
571        let err = query_ioregistry_with(
572            &mut stream,
573            IoRegistryQuery {
574                entry_class: Some("IO80211Interface"),
575                entry_name: None,
576                current_plane: None,
577            },
578        )
579        .await
580        .unwrap_err();
581
582        assert!(matches!(err, DiagnosticsError::Protocol(_)));
583        let message = err.to_string();
584        assert!(message.contains("LookupFailed"));
585        assert!(message.contains("Entry not found"));
586        assert!(message.contains("Diagnostics"));
587    }
588}