Skip to main content

ios_core/services/mcinstall/
mod.rs

1//! Minimal MCInstall client for read-only profile inspection.
2//!
3//! Service: `com.apple.mobile.MCInstall`
4
5#[cfg(feature = "supervised-pair")]
6use openssl::pkcs12::Pkcs12;
7#[cfg(feature = "supervised-pair")]
8use openssl::pkcs7::{Pkcs7, Pkcs7Flags};
9#[cfg(feature = "supervised-pair")]
10use openssl::stack::Stack;
11use serde::Serialize;
12use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
13
14pub const SERVICE_NAME: &str = "com.apple.mobile.MCInstall";
15
16service_error!(
17    McInstallError,
18    #[error("crypto error: {0}")]
19    Crypto(String),
20);
21
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
23pub struct ProfileInfo {
24    pub identifier: String,
25    pub display_name: String,
26    pub description: Option<String>,
27    pub is_active: bool,
28    pub removal_disallowed: Option<bool>,
29    pub status: Option<String>,
30    pub uuid: Option<String>,
31    pub version: Option<u64>,
32}
33
34#[derive(Debug)]
35pub struct McInstallClient<S> {
36    stream: S,
37}
38
39impl<S: AsyncRead + AsyncWrite + Unpin> McInstallClient<S> {
40    pub fn new(stream: S) -> Self {
41        Self { stream }
42    }
43
44    pub async fn list_profiles(&mut self) -> Result<Vec<ProfileInfo>, McInstallError> {
45        let response = self.get_profile_list_raw().await?;
46        parse_profile_list(response)
47    }
48
49    pub async fn get_profile_list_raw(&mut self) -> Result<plist::Value, McInstallError> {
50        self.send_plist(&Request {
51            request_type: "GetProfileList",
52        })
53        .await?;
54
55        self.recv_plist().await
56    }
57
58    pub async fn get_cloud_configuration(&mut self) -> Result<plist::Dictionary, McInstallError> {
59        self.send_plist(&Request {
60            request_type: "GetCloudConfiguration",
61        })
62        .await?;
63
64        let response: plist::Value = self.recv_plist().await?;
65        parse_cloud_configuration(response)
66    }
67
68    pub async fn get_stored_profile_raw(
69        &mut self,
70        purpose: &str,
71    ) -> Result<plist::Value, McInstallError> {
72        let request = plist::Dictionary::from_iter([
73            (
74                "RequestType".to_string(),
75                plist::Value::String("GetStoredProfile".into()),
76            ),
77            (
78                "Purpose".to_string(),
79                plist::Value::String(purpose.to_string()),
80            ),
81        ]);
82        send_plist(&mut self.stream, &plist::Value::Dictionary(request)).await?;
83        self.recv_plist().await
84    }
85
86    pub async fn flush(&mut self) -> Result<(), McInstallError> {
87        let request = plist::Dictionary::from_iter([(
88            "RequestType".to_string(),
89            plist::Value::String("Flush".into()),
90        )]);
91        send_request(&mut self.stream, request).await
92    }
93
94    pub async fn hello_host_identifier(&mut self) -> Result<(), McInstallError> {
95        let request = plist::Dictionary::from_iter([(
96            "RequestType".to_string(),
97            plist::Value::String("HelloHostIdentifier".into()),
98        )]);
99        send_request(&mut self.stream, request).await
100    }
101
102    pub async fn set_cloud_configuration(
103        &mut self,
104        cloud_configuration: plist::Dictionary,
105    ) -> Result<(), McInstallError> {
106        let request = plist::Dictionary::from_iter([
107            (
108                "RequestType".to_string(),
109                plist::Value::String("SetCloudConfiguration".into()),
110            ),
111            (
112                "CloudConfiguration".to_string(),
113                plist::Value::Dictionary(cloud_configuration),
114            ),
115        ]);
116        send_request(&mut self.stream, request).await
117    }
118
119    pub async fn install_profile(&mut self, payload: &[u8]) -> Result<(), McInstallError> {
120        let request = plist::Dictionary::from_iter([
121            (
122                "RequestType".to_string(),
123                plist::Value::String("InstallProfile".into()),
124            ),
125            ("Payload".to_string(), plist::Value::Data(payload.to_vec())),
126        ]);
127        send_request(&mut self.stream, request).await
128    }
129
130    pub async fn install_profile_silent(
131        &mut self,
132        payload: &[u8],
133        p12_bytes: &[u8],
134        password: &str,
135    ) -> Result<(), McInstallError> {
136        #[cfg(not(feature = "supervised-pair"))]
137        {
138            let _ = (payload, p12_bytes, password);
139            return Err(McInstallError::Crypto(
140                "silent profile installation requires ios-core feature 'supervised-pair'".into(),
141            ));
142        }
143
144        #[cfg(feature = "supervised-pair")]
145        {
146            self.escalate(p12_bytes, password).await?;
147            let request = plist::Dictionary::from_iter([
148                (
149                    "RequestType".to_string(),
150                    plist::Value::String("InstallProfileSilent".into()),
151                ),
152                ("Payload".to_string(), plist::Value::Data(payload.to_vec())),
153            ]);
154            send_request(&mut self.stream, request).await
155        }
156    }
157
158    pub async fn remove_profile(&mut self, identifier: &str) -> Result<(), McInstallError> {
159        let profile_identifier = match self.get_profile_list_raw().await {
160            Ok(value) => build_remove_profile_identifier(&value, identifier)
161                .map_err(|err| McInstallError::Protocol(err.to_string()))?
162                .unwrap_or_else(|| plist::Value::String(identifier.to_string())),
163            Err(_) => plist::Value::String(identifier.to_string()),
164        };
165        let request = plist::Dictionary::from_iter([
166            (
167                "RequestType".to_string(),
168                plist::Value::String("RemoveProfile".into()),
169            ),
170            ("ProfileIdentifier".to_string(), profile_identifier),
171        ]);
172        send_request(&mut self.stream, request).await
173    }
174
175    pub async fn erase_device(
176        &mut self,
177        preserve_data_plan: bool,
178        disallow_proximity_setup: bool,
179    ) -> Result<(), McInstallError> {
180        let request = plist::Dictionary::from_iter([
181            (
182                "RequestType".to_string(),
183                plist::Value::String("EraseDevice".into()),
184            ),
185            (
186                "PreserveDataPlan".to_string(),
187                plist::Value::Boolean(preserve_data_plan),
188            ),
189            (
190                "DisallowProximitySetup".to_string(),
191                plist::Value::Boolean(disallow_proximity_setup),
192            ),
193        ]);
194        send_request_allow_eof(&mut self.stream, request).await
195    }
196
197    pub async fn escalate_unsupervised(&mut self) -> Result<(), McInstallError> {
198        let request = plist::Dictionary::from_iter([
199            (
200                "RequestType".to_string(),
201                plist::Value::String("Escalate".into()),
202            ),
203            (
204                "SupervisorCertificate".to_string(),
205                plist::Value::Data(vec![0]),
206            ),
207        ]);
208        send_request(&mut self.stream, request).await
209    }
210
211    #[cfg(feature = "supervised-pair")]
212    async fn escalate(&mut self, p12_bytes: &[u8], password: &str) -> Result<(), McInstallError> {
213        let pkcs12 =
214            Pkcs12::from_der(p12_bytes).map_err(|err| McInstallError::Crypto(err.to_string()))?;
215        let parsed = pkcs12
216            .parse2(password)
217            .map_err(|err| McInstallError::Crypto(err.to_string()))?;
218        let cert = parsed
219            .cert
220            .ok_or_else(|| McInstallError::Crypto("P12 missing certificate".into()))?;
221        let pkey = parsed
222            .pkey
223            .ok_or_else(|| McInstallError::Crypto("P12 missing private key".into()))?;
224
225        let request = plist::Dictionary::from_iter([
226            (
227                "RequestType".to_string(),
228                plist::Value::String("Escalate".into()),
229            ),
230            (
231                "SupervisorCertificate".to_string(),
232                plist::Value::Data(
233                    cert.to_der()
234                        .map_err(|err| McInstallError::Crypto(err.to_string()))?,
235                ),
236            ),
237        ]);
238        send_plist(&mut self.stream, &plist::Value::Dictionary(request)).await?;
239        let response = recv_plist(&mut self.stream).await?;
240        ensure_acknowledged(&response)?;
241        let challenge = response
242            .get("Challenge")
243            .and_then(plist::Value::as_data)
244            .ok_or_else(|| {
245                McInstallError::Protocol("MCInstall escalate response missing Challenge".into())
246            })?;
247        let certs = Stack::new().map_err(|err| McInstallError::Crypto(err.to_string()))?;
248        let signed_request = Pkcs7::sign(&cert, &pkey, &certs, challenge, Pkcs7Flags::BINARY)
249            .and_then(|pkcs7| pkcs7.to_der())
250            .map_err(|err| McInstallError::Crypto(err.to_string()))?;
251
252        let response_request = plist::Dictionary::from_iter([
253            (
254                "RequestType".to_string(),
255                plist::Value::String("EscalateResponse".into()),
256            ),
257            (
258                "SignedRequest".to_string(),
259                plist::Value::Data(signed_request),
260            ),
261        ]);
262        send_request(&mut self.stream, response_request).await?;
263
264        let proceed_request = plist::Dictionary::from_iter([(
265            "RequestType".to_string(),
266            plist::Value::String("ProceedWithKeybagMigration".into()),
267        )]);
268        send_request(&mut self.stream, proceed_request).await
269    }
270
271    async fn send_plist<T: Serialize>(&mut self, value: &T) -> Result<(), McInstallError> {
272        let mut buf = Vec::new();
273        plist::to_writer_xml(&mut buf, value)?;
274        self.stream
275            .write_all(&(buf.len() as u32).to_be_bytes())
276            .await?;
277        self.stream.write_all(&buf).await?;
278        self.stream.flush().await?;
279        Ok(())
280    }
281
282    async fn recv_plist<T>(&mut self) -> Result<T, McInstallError>
283    where
284        T: for<'de> serde::Deserialize<'de>,
285    {
286        let mut len_buf = [0u8; 4];
287        self.stream.read_exact(&mut len_buf).await?;
288        let len = u32::from_be_bytes(len_buf) as usize;
289        const MAX_PLIST_SIZE: usize = 8 * 1024 * 1024;
290        if len > MAX_PLIST_SIZE {
291            return Err(McInstallError::Protocol(format!(
292                "plist length {len} exceeds max {MAX_PLIST_SIZE}"
293            )));
294        }
295        let mut buf = vec![0u8; len];
296        self.stream.read_exact(&mut buf).await?;
297        Ok(plist::from_bytes(&buf)?)
298    }
299}
300
301#[derive(Serialize)]
302#[serde(rename_all = "PascalCase")]
303struct Request {
304    request_type: &'static str,
305}
306
307fn parse_profile_list(value: plist::Value) -> Result<Vec<ProfileInfo>, McInstallError> {
308    let dict = value.into_dictionary().ok_or_else(|| {
309        McInstallError::Protocol("MCInstall response was not a dictionary".into())
310    })?;
311
312    let ordered = dict
313        .get("OrderedIdentifiers")
314        .and_then(plist::Value::as_array)
315        .ok_or_else(|| {
316            McInstallError::Protocol("MCInstall response missing OrderedIdentifiers".into())
317        })?;
318    let manifest_root = dict
319        .get("ProfileManifest")
320        .and_then(plist::Value::as_dictionary)
321        .ok_or_else(|| {
322            McInstallError::Protocol("MCInstall response missing ProfileManifest".into())
323        })?;
324    let metadata_root = dict
325        .get("ProfileMetadata")
326        .and_then(plist::Value::as_dictionary)
327        .ok_or_else(|| {
328            McInstallError::Protocol("MCInstall response missing ProfileMetadata".into())
329        })?;
330    let status = dict
331        .get("Status")
332        .and_then(plist::Value::as_string)
333        .map(ToOwned::to_owned);
334
335    let mut profiles = Vec::with_capacity(ordered.len());
336    for identifier in ordered {
337        let identifier = identifier.as_string().ok_or_else(|| {
338            McInstallError::Protocol("OrderedIdentifiers entry was not a string".into())
339        })?;
340        let manifest = manifest_root
341            .get(identifier)
342            .and_then(plist::Value::as_dictionary)
343            .ok_or_else(|| {
344                McInstallError::Protocol(format!("ProfileManifest missing entry for {identifier}"))
345            })?;
346        let metadata = metadata_root
347            .get(identifier)
348            .and_then(plist::Value::as_dictionary)
349            .ok_or_else(|| {
350                McInstallError::Protocol(format!("ProfileMetadata missing entry for {identifier}"))
351            })?;
352
353        profiles.push(ProfileInfo {
354            identifier: identifier.to_string(),
355            display_name: metadata
356                .get("PayloadDisplayName")
357                .and_then(plist::Value::as_string)
358                .unwrap_or(identifier)
359                .to_string(),
360            description: metadata
361                .get("PayloadDescription")
362                .and_then(plist::Value::as_string)
363                .map(ToOwned::to_owned),
364            is_active: manifest
365                .get("IsActive")
366                .and_then(plist::Value::as_boolean)
367                .unwrap_or(false),
368            removal_disallowed: metadata
369                .get("PayloadRemovalDisallowed")
370                .and_then(plist::Value::as_boolean),
371            status: status.clone(),
372            uuid: metadata
373                .get("PayloadUUID")
374                .and_then(plist::Value::as_string)
375                .map(ToOwned::to_owned),
376            version: metadata
377                .get("PayloadVersion")
378                .and_then(plist::Value::as_unsigned_integer),
379        });
380    }
381    Ok(profiles)
382}
383
384fn parse_cloud_configuration(value: plist::Value) -> Result<plist::Dictionary, McInstallError> {
385    value.into_dictionary().ok_or_else(|| {
386        McInstallError::Protocol("MCInstall cloud configuration was not a dictionary".into())
387    })
388}
389
390fn build_remove_profile_identifier(
391    value: &plist::Value,
392    identifier: &str,
393) -> Result<Option<plist::Value>, plist::Error> {
394    let metadata = match value
395        .as_dictionary()
396        .and_then(|dict| dict.get("ProfileMetadata"))
397        .and_then(plist::Value::as_dictionary)
398        .and_then(|metadata| metadata.get(identifier))
399        .and_then(plist::Value::as_dictionary)
400    {
401        Some(metadata) => metadata,
402        None => return Ok(None),
403    };
404    let payload_uuid = match metadata
405        .get("PayloadUUID")
406        .and_then(plist::Value::as_string)
407    {
408        Some(uuid) => uuid,
409        None => return Ok(None),
410    };
411    let payload_version = match metadata
412        .get("PayloadVersion")
413        .and_then(plist::Value::as_unsigned_integer)
414    {
415        Some(version) => version,
416        None => return Ok(None),
417    };
418
419    let profile_identifier = plist::Value::Dictionary(plist::Dictionary::from_iter([
420        (
421            "PayloadType".to_string(),
422            plist::Value::String("Configuration".into()),
423        ),
424        (
425            "PayloadIdentifier".to_string(),
426            plist::Value::String(identifier.to_string()),
427        ),
428        (
429            "PayloadUUID".to_string(),
430            plist::Value::String(payload_uuid.to_string()),
431        ),
432        (
433            "PayloadVersion".to_string(),
434            plist::Value::Integer((payload_version as i64).into()),
435        ),
436    ]));
437    let mut buf = Vec::new();
438    plist::to_writer_xml(&mut buf, &profile_identifier)?;
439    Ok(Some(plist::Value::Data(buf)))
440}
441
442async fn send_request<S: AsyncRead + AsyncWrite + Unpin>(
443    stream: &mut S,
444    request: plist::Dictionary,
445) -> Result<(), McInstallError> {
446    send_plist(stream, &plist::Value::Dictionary(request)).await?;
447    let response = recv_plist(stream).await?;
448    ensure_acknowledged(&response)
449}
450
451async fn send_request_allow_eof<S: AsyncRead + AsyncWrite + Unpin>(
452    stream: &mut S,
453    request: plist::Dictionary,
454) -> Result<(), McInstallError> {
455    send_plist(stream, &plist::Value::Dictionary(request)).await?;
456    match recv_plist(stream).await {
457        Ok(response) => ensure_acknowledged(&response),
458        Err(McInstallError::Io(err)) if err.kind() == std::io::ErrorKind::UnexpectedEof => Ok(()),
459        Err(err) => Err(err),
460    }
461}
462
463fn ensure_acknowledged(response: &plist::Dictionary) -> Result<(), McInstallError> {
464    let status = response
465        .get("Status")
466        .and_then(plist::Value::as_string)
467        .ok_or_else(|| McInstallError::Protocol("MCInstall response missing Status".into()))?;
468    if status != "Acknowledged" {
469        let detail = response
470            .get("Error")
471            .and_then(plist::Value::as_string)
472            .map(ToOwned::to_owned)
473            .or_else(|| response.get("ErrorChain").map(|value| format!("{value:?}")))
474            .unwrap_or_else(|| status.to_string());
475        return Err(McInstallError::Protocol(format!(
476            "MCInstall request not acknowledged: {detail}"
477        )));
478    }
479    Ok(())
480}
481
482async fn send_plist<S: AsyncWrite + Unpin>(
483    stream: &mut S,
484    value: &plist::Value,
485) -> Result<(), McInstallError> {
486    let mut buf = Vec::new();
487    plist::to_writer_xml(&mut buf, value)?;
488    stream.write_all(&(buf.len() as u32).to_be_bytes()).await?;
489    stream.write_all(&buf).await?;
490    stream.flush().await?;
491    Ok(())
492}
493
494async fn recv_plist<S: AsyncRead + Unpin>(
495    stream: &mut S,
496) -> Result<plist::Dictionary, McInstallError> {
497    let mut len_buf = [0u8; 4];
498    stream.read_exact(&mut len_buf).await?;
499    let len = u32::from_be_bytes(len_buf) as usize;
500    const MAX_PLIST_SIZE: usize = 8 * 1024 * 1024;
501    if len > MAX_PLIST_SIZE {
502        return Err(McInstallError::Protocol(format!(
503            "plist length {len} exceeds max {MAX_PLIST_SIZE}"
504        )));
505    }
506    let mut buf = vec![0u8; len];
507    stream.read_exact(&mut buf).await?;
508    Ok(plist::from_bytes(&buf)?)
509}
510
511#[cfg(test)]
512mod tests {
513    use crate::test_util::MockStream;
514
515    use super::*;
516
517    #[test]
518    fn parses_ordered_profile_list() {
519        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
520            (
521                "OrderedIdentifiers".to_string(),
522                plist::Value::Array(vec![plist::Value::String("com.example.profile".into())]),
523            ),
524            (
525                "ProfileManifest".to_string(),
526                plist::Value::Dictionary(plist::Dictionary::from_iter([(
527                    "com.example.profile".to_string(),
528                    plist::Value::Dictionary(plist::Dictionary::from_iter([
529                        (
530                            "Description".to_string(),
531                            plist::Value::String("Example".into()),
532                        ),
533                        ("IsActive".to_string(), plist::Value::Boolean(true)),
534                    ])),
535                )])),
536            ),
537            (
538                "ProfileMetadata".to_string(),
539                plist::Value::Dictionary(plist::Dictionary::from_iter([(
540                    "com.example.profile".to_string(),
541                    plist::Value::Dictionary(plist::Dictionary::from_iter([
542                        (
543                            "PayloadDisplayName".to_string(),
544                            plist::Value::String("Example Profile".into()),
545                        ),
546                        (
547                            "PayloadDescription".to_string(),
548                            plist::Value::String("Example description".into()),
549                        ),
550                        (
551                            "PayloadRemovalDisallowed".to_string(),
552                            plist::Value::Boolean(false),
553                        ),
554                        (
555                            "PayloadUUID".to_string(),
556                            plist::Value::String("1234".into()),
557                        ),
558                        (
559                            "PayloadVersion".to_string(),
560                            plist::Value::Integer(1i64.into()),
561                        ),
562                    ])),
563                )])),
564            ),
565            (
566                "Status".to_string(),
567                plist::Value::String("Acknowledged".into()),
568            ),
569        ]));
570
571        let profiles = parse_profile_list(response).unwrap();
572        assert_eq!(profiles.len(), 1);
573        let profile = &profiles[0];
574        assert_eq!(profile.identifier, "com.example.profile");
575        assert_eq!(profile.display_name, "Example Profile");
576        assert_eq!(profile.description.as_deref(), Some("Example description"));
577        assert!(profile.is_active);
578        assert_eq!(profile.removal_disallowed, Some(false));
579        assert_eq!(profile.status.as_deref(), Some("Acknowledged"));
580        assert_eq!(profile.uuid.as_deref(), Some("1234"));
581        assert_eq!(profile.version, Some(1));
582    }
583
584    #[test]
585    fn cloud_configuration_requires_dictionary_response() {
586        let err = parse_cloud_configuration(plist::Value::Array(Vec::new()));
587        assert!(matches!(
588            err,
589            Err(McInstallError::Protocol(message)) if message.contains("cloud configuration")
590        ));
591    }
592
593    #[test]
594    fn parses_cloud_configuration_dictionary() {
595        let dict = plist::Dictionary::from_iter([(
596            "IsSupervised".to_string(),
597            plist::Value::Boolean(true),
598        )]);
599        let parsed = parse_cloud_configuration(plist::Value::Dictionary(dict.clone())).unwrap();
600        assert_eq!(
601            parsed
602                .get("IsSupervised")
603                .and_then(plist::Value::as_boolean),
604            Some(true)
605        );
606    }
607
608    #[tokio::test]
609    async fn install_profile_sends_payload_request() {
610        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
611            "Status".to_string(),
612            plist::Value::String("Acknowledged".into()),
613        )]));
614        let mut stream = MockStream::with_response(response);
615        let mut client = McInstallClient::new(&mut stream);
616
617        client.install_profile(b"<plist/>").await.unwrap();
618
619        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
620        let payload = &stream.written[4..4 + len];
621        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
622        assert_eq!(
623            dict.get("RequestType").and_then(plist::Value::as_string),
624            Some("InstallProfile")
625        );
626        assert_eq!(
627            dict.get("Payload").and_then(plist::Value::as_data),
628            Some(&b"<plist/>"[..])
629        );
630    }
631
632    #[tokio::test]
633    async fn remove_profile_sends_identifier_request() {
634        let profile_list = plist::Value::Dictionary(plist::Dictionary::from_iter([
635            (
636                "OrderedIdentifiers".to_string(),
637                plist::Value::Array(Vec::new()),
638            ),
639            (
640                "ProfileManifest".to_string(),
641                plist::Value::Dictionary(plist::Dictionary::new()),
642            ),
643            (
644                "ProfileMetadata".to_string(),
645                plist::Value::Dictionary(plist::Dictionary::new()),
646            ),
647            (
648                "Status".to_string(),
649                plist::Value::String("Acknowledged".into()),
650            ),
651        ]));
652        let remove_response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
653            "Status".to_string(),
654            plist::Value::String("Acknowledged".into()),
655        )]));
656        let mut stream = MockStream::with_responses(vec![profile_list, remove_response]);
657        let mut client = McInstallClient::new(&mut stream);
658
659        client.remove_profile("com.example.profile").await.unwrap();
660
661        let first_len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
662        let offset = 4 + first_len;
663        let len =
664            u32::from_be_bytes(stream.written[offset..offset + 4].try_into().unwrap()) as usize;
665        let payload = &stream.written[offset + 4..offset + 4 + len];
666        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
667        assert_eq!(
668            dict.get("RequestType").and_then(plist::Value::as_string),
669            Some("RemoveProfile")
670        );
671        assert_eq!(
672            dict.get("ProfileIdentifier")
673                .and_then(plist::Value::as_string),
674            Some("com.example.profile")
675        );
676    }
677
678    #[tokio::test]
679    async fn remove_profile_uses_metadata_backed_identifier_when_available() {
680        let profile_list = plist::Value::Dictionary(plist::Dictionary::from_iter([
681            (
682                "OrderedIdentifiers".to_string(),
683                plist::Value::Array(vec![plist::Value::String("com.example.profile".into())]),
684            ),
685            (
686                "ProfileManifest".to_string(),
687                plist::Value::Dictionary(plist::Dictionary::from_iter([(
688                    "com.example.profile".to_string(),
689                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
690                        "IsActive".to_string(),
691                        plist::Value::Boolean(true),
692                    )])),
693                )])),
694            ),
695            (
696                "ProfileMetadata".to_string(),
697                plist::Value::Dictionary(plist::Dictionary::from_iter([(
698                    "com.example.profile".to_string(),
699                    plist::Value::Dictionary(plist::Dictionary::from_iter([
700                        (
701                            "PayloadUUID".to_string(),
702                            plist::Value::String("1234-5678".into()),
703                        ),
704                        (
705                            "PayloadVersion".to_string(),
706                            plist::Value::Integer(7.into()),
707                        ),
708                    ])),
709                )])),
710            ),
711            (
712                "Status".to_string(),
713                plist::Value::String("Acknowledged".into()),
714            ),
715        ]));
716        let remove_response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
717            "Status".to_string(),
718            plist::Value::String("Acknowledged".into()),
719        )]));
720        let mut stream = MockStream::with_responses(vec![profile_list, remove_response]);
721        let mut client = McInstallClient::new(&mut stream);
722
723        client.remove_profile("com.example.profile").await.unwrap();
724
725        let first_len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
726        let second_offset = 4 + first_len;
727        let second_len = u32::from_be_bytes(
728            stream.written[second_offset..second_offset + 4]
729                .try_into()
730                .unwrap(),
731        ) as usize;
732        let second_payload = &stream.written[second_offset + 4..second_offset + 4 + second_len];
733        let second_request: plist::Dictionary = plist::from_bytes(second_payload).unwrap();
734        let profile_identifier = second_request
735            .get("ProfileIdentifier")
736            .and_then(plist::Value::as_data)
737            .expect("metadata-backed profile identifier should be plist data");
738        let identifier_plist = plist::Value::from_reader(std::io::Cursor::new(profile_identifier))
739            .unwrap()
740            .into_dictionary()
741            .unwrap();
742        assert_eq!(
743            identifier_plist
744                .get("PayloadIdentifier")
745                .and_then(plist::Value::as_string),
746            Some("com.example.profile")
747        );
748        assert_eq!(
749            identifier_plist
750                .get("PayloadUUID")
751                .and_then(plist::Value::as_string),
752            Some("1234-5678")
753        );
754        assert_eq!(
755            identifier_plist
756                .get("PayloadVersion")
757                .and_then(plist::Value::as_unsigned_integer),
758            Some(7)
759        );
760        assert_eq!(
761            identifier_plist
762                .get("PayloadType")
763                .and_then(plist::Value::as_string),
764            Some("Configuration")
765        );
766    }
767
768    #[tokio::test]
769    async fn get_profile_list_raw_preserves_unparsed_fields() {
770        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
771            (
772                "OrderedIdentifiers".to_string(),
773                plist::Value::Array(vec![plist::Value::String("com.example.profile".into())]),
774            ),
775            (
776                "ProfileManifest".to_string(),
777                plist::Value::Dictionary(plist::Dictionary::from_iter([(
778                    "com.example.profile".to_string(),
779                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
780                        "IsActive".to_string(),
781                        plist::Value::Boolean(true),
782                    )])),
783                )])),
784            ),
785            (
786                "ProfileMetadata".to_string(),
787                plist::Value::Dictionary(plist::Dictionary::from_iter([(
788                    "com.example.profile".to_string(),
789                    plist::Value::Dictionary(plist::Dictionary::from_iter([(
790                        "PayloadDisplayName".to_string(),
791                        plist::Value::String("Example".into()),
792                    )])),
793                )])),
794            ),
795            (
796                "Unhandled".to_string(),
797                plist::Value::String("preserved".into()),
798            ),
799            (
800                "Status".to_string(),
801                plist::Value::String("Acknowledged".into()),
802            ),
803        ]));
804        let mut stream = MockStream::with_response(response);
805        let mut client = McInstallClient::new(&mut stream);
806
807        let raw = client.get_profile_list_raw().await.unwrap();
808        let dict = raw.as_dictionary().unwrap();
809        assert_eq!(dict["Unhandled"].as_string(), Some("preserved"));
810    }
811
812    #[tokio::test]
813    async fn get_stored_profile_raw_includes_requested_purpose() {
814        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
815            (
816                "Status".to_string(),
817                plist::Value::String("Acknowledged".into()),
818            ),
819            (
820                "ProfileData".to_string(),
821                plist::Value::Data(b"<plist/>".to_vec()),
822            ),
823        ]));
824        let mut stream = MockStream::with_response(response);
825        let mut client = McInstallClient::new(&mut stream);
826
827        let raw = client
828            .get_stored_profile_raw("PostSetupInstallation")
829            .await
830            .unwrap();
831        let dict = raw.as_dictionary().unwrap();
832        assert_eq!(dict["Status"].as_string(), Some("Acknowledged"));
833        assert_eq!(dict["ProfileData"].as_data(), Some(&b"<plist/>"[..]));
834
835        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
836        let payload = &stream.written[4..4 + len];
837        let sent: plist::Dictionary = plist::from_bytes(payload).unwrap();
838        assert_eq!(
839            sent.get("RequestType").and_then(plist::Value::as_string),
840            Some("GetStoredProfile")
841        );
842        assert_eq!(
843            sent.get("Purpose").and_then(plist::Value::as_string),
844            Some("PostSetupInstallation")
845        );
846    }
847
848    #[tokio::test]
849    async fn flush_sends_flush_request() {
850        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
851            "Status".to_string(),
852            plist::Value::String("Acknowledged".into()),
853        )]));
854        let mut stream = MockStream::with_response(response);
855        let mut client = McInstallClient::new(&mut stream);
856
857        client.flush().await.unwrap();
858
859        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
860        let payload = &stream.written[4..4 + len];
861        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
862        assert_eq!(
863            dict.get("RequestType").and_then(plist::Value::as_string),
864            Some("Flush")
865        );
866    }
867
868    #[tokio::test]
869    async fn hello_host_identifier_sends_request_type() {
870        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
871            "Status".to_string(),
872            plist::Value::String("Acknowledged".into()),
873        )]));
874        let mut stream = MockStream::with_response(response);
875        let mut client = McInstallClient::new(&mut stream);
876
877        client.hello_host_identifier().await.unwrap();
878
879        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
880        let payload = &stream.written[4..4 + len];
881        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
882        assert_eq!(
883            dict.get("RequestType").and_then(plist::Value::as_string),
884            Some("HelloHostIdentifier")
885        );
886    }
887
888    #[tokio::test]
889    async fn set_cloud_configuration_sends_payload() {
890        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
891            "Status".to_string(),
892            plist::Value::String("Acknowledged".into()),
893        )]));
894        let mut stream = MockStream::with_response(response);
895        let mut client = McInstallClient::new(&mut stream);
896        let cloud_configuration = plist::Dictionary::from_iter([
897            ("AllowPairing".to_string(), plist::Value::Boolean(true)),
898            (
899                "SkipSetup".to_string(),
900                plist::Value::Array(vec![plist::Value::String("WiFi".into())]),
901            ),
902        ]);
903
904        client
905            .set_cloud_configuration(cloud_configuration.clone())
906            .await
907            .unwrap();
908
909        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
910        let payload = &stream.written[4..4 + len];
911        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
912        assert_eq!(
913            dict.get("RequestType").and_then(plist::Value::as_string),
914            Some("SetCloudConfiguration")
915        );
916        assert_eq!(
917            dict.get("CloudConfiguration")
918                .and_then(plist::Value::as_dictionary),
919            Some(&cloud_configuration)
920        );
921    }
922
923    #[tokio::test]
924    async fn escalate_unsupervised_uses_zero_byte_certificate() {
925        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
926            "Status".to_string(),
927            plist::Value::String("Acknowledged".into()),
928        )]));
929        let mut stream = MockStream::with_response(response);
930        let mut client = McInstallClient::new(&mut stream);
931
932        client.escalate_unsupervised().await.unwrap();
933
934        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
935        let payload = &stream.written[4..4 + len];
936        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
937        assert_eq!(
938            dict.get("RequestType").and_then(plist::Value::as_string),
939            Some("Escalate")
940        );
941        assert_eq!(
942            dict.get("SupervisorCertificate")
943                .and_then(plist::Value::as_data),
944            Some(&b"\x00"[..])
945        );
946    }
947
948    #[tokio::test]
949    async fn erase_device_sends_expected_flags() {
950        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
951            "Status".to_string(),
952            plist::Value::String("Acknowledged".into()),
953        )]));
954        let mut stream = MockStream::with_response(response);
955        let mut client = McInstallClient::new(&mut stream);
956
957        client.erase_device(true, false).await.unwrap();
958
959        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
960        let payload = &stream.written[4..4 + len];
961        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
962        assert_eq!(
963            dict.get("RequestType").and_then(plist::Value::as_string),
964            Some("EraseDevice")
965        );
966        assert_eq!(
967            dict.get("PreserveDataPlan")
968                .and_then(plist::Value::as_boolean),
969            Some(true)
970        );
971        assert_eq!(
972            dict.get("DisallowProximitySetup")
973                .and_then(plist::Value::as_boolean),
974            Some(false)
975        );
976    }
977}