ios_core/services/misagent/
mod.rs1use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
7
8pub const SERVICE_NAME: &str = "com.apple.misagent";
9
10#[derive(Debug, thiserror::Error)]
11pub enum MisagentError {
12 #[error("IO error: {0}")]
13 Io(#[from] std::io::Error),
14 #[error("plist error: {0}")]
15 Plist(String),
16 #[error("protocol error: {0}")]
17 Protocol(String),
18 #[error("status {0}")]
19 Status(u32),
20}
21
22#[derive(Debug, Clone)]
24pub struct Profile {
25 pub uuid: String,
26 pub name: String,
27 pub app_id: String,
28 pub expiry_date: Option<String>,
29 pub raw_data: Vec<u8>,
30}
31
32pub struct MisagentClient<S> {
34 stream: S,
35}
36
37impl<S: AsyncRead + AsyncWrite + Unpin> MisagentClient<S> {
38 pub fn new(stream: S) -> Self {
39 Self { stream }
40 }
41
42 pub async fn copy_all(&mut self) -> Result<Vec<Vec<u8>>, MisagentError> {
44 self.send_value(plist::Value::Dictionary(plist::Dictionary::from_iter([
45 (
46 "MessageType".to_string(),
47 plist::Value::String("CopyAll".into()),
48 ),
49 (
50 "ProfileType".to_string(),
51 plist::Value::String("Provisioning".into()),
52 ),
53 ])))
54 .await?;
55
56 let data = self.recv_raw().await?;
57 let val: plist::Value =
58 plist::from_bytes(&data).map_err(|e| MisagentError::Plist(e.to_string()))?;
59
60 let status = val
61 .as_dictionary()
62 .and_then(|d| d.get("Status"))
63 .and_then(|v| v.as_unsigned_integer())
64 .unwrap_or(0) as u32;
65
66 if status != 0 {
67 return Err(MisagentError::Status(status));
68 }
69
70 let profiles = val
71 .as_dictionary()
72 .and_then(|d| d.get("Payload"))
73 .and_then(|v| v.as_array())
74 .map(|arr| {
75 arr.iter()
76 .filter_map(|v| v.as_data().map(|d| d.to_vec()))
77 .collect()
78 })
79 .unwrap_or_default();
80
81 Ok(profiles)
82 }
83
84 pub async fn list_profiles(&mut self) -> Result<Vec<Profile>, MisagentError> {
86 let raw_profiles = self.copy_all().await?;
87 raw_profiles
88 .into_iter()
89 .map(|raw_data| decode_profile(&raw_data))
90 .collect()
91 }
92
93 pub async fn install(&mut self, profile_data: &[u8]) -> Result<(), MisagentError> {
95 self.send_value(plist::Value::Dictionary(plist::Dictionary::from_iter([
96 (
97 "MessageType".to_string(),
98 plist::Value::String("Install".into()),
99 ),
100 (
101 "ProfileType".to_string(),
102 plist::Value::String("Provisioning".into()),
103 ),
104 (
105 "Profile".to_string(),
106 plist::Value::Data(profile_data.to_vec()),
107 ),
108 ])))
109 .await?;
110 let data = self.recv_raw().await?;
111 let val: plist::Value =
112 plist::from_bytes(&data).map_err(|e| MisagentError::Plist(e.to_string()))?;
113 let status = val
114 .as_dictionary()
115 .and_then(|d| d.get("Status"))
116 .and_then(|v| v.as_unsigned_integer())
117 .unwrap_or(0) as u32;
118 if status != 0 {
119 return Err(MisagentError::Status(status));
120 }
121 Ok(())
122 }
123
124 pub async fn remove(&mut self, uuid: &str) -> Result<(), MisagentError> {
126 self.send_value(plist::Value::Dictionary(plist::Dictionary::from_iter([
127 (
128 "MessageType".to_string(),
129 plist::Value::String("Remove".into()),
130 ),
131 (
132 "ProfileType".to_string(),
133 plist::Value::String("Provisioning".into()),
134 ),
135 (
136 "ProfileID".to_string(),
137 plist::Value::String(uuid.to_string()),
138 ),
139 ])))
140 .await?;
141 let data = self.recv_raw().await?;
142 let val: plist::Value =
143 plist::from_bytes(&data).map_err(|e| MisagentError::Plist(e.to_string()))?;
144 let status = val
145 .as_dictionary()
146 .and_then(|d| d.get("Status"))
147 .and_then(|v| v.as_unsigned_integer())
148 .unwrap_or(0) as u32;
149 if status != 0 {
150 return Err(MisagentError::Status(status));
151 }
152 Ok(())
153 }
154
155 async fn send_value(&mut self, plist_val: plist::Value) -> Result<(), MisagentError> {
156 let mut buf = Vec::new();
157 plist::to_writer_xml(&mut buf, &plist_val)
158 .map_err(|e| MisagentError::Plist(e.to_string()))?;
159 self.stream
160 .write_all(&(buf.len() as u32).to_be_bytes())
161 .await?;
162 self.stream.write_all(&buf).await?;
163 self.stream.flush().await?;
164 Ok(())
165 }
166
167 async fn recv_raw(&mut self) -> Result<Vec<u8>, MisagentError> {
168 let mut len_buf = [0u8; 4];
169 self.stream.read_exact(&mut len_buf).await?;
170 let len = u32::from_be_bytes(len_buf) as usize;
171 const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
172 if len > MAX_PLIST_SIZE {
173 return Err(MisagentError::Io(std::io::Error::new(
174 std::io::ErrorKind::InvalidData,
175 format!("plist length {len} exceeds maximum of {MAX_PLIST_SIZE}"),
176 )));
177 }
178 let mut buf = vec![0u8; len];
179 self.stream.read_exact(&mut buf).await?;
180 Ok(buf)
181 }
182}
183
184fn decode_profile(raw_data: &[u8]) -> Result<Profile, MisagentError> {
185 let plist_bytes = embedded_plist_bytes(raw_data)?;
186 let value: plist::Value =
187 plist::from_bytes(plist_bytes).map_err(|e| MisagentError::Plist(e.to_string()))?;
188 let dict = value.into_dictionary().ok_or_else(|| {
189 MisagentError::Protocol("provisioning profile payload was not a dictionary".into())
190 })?;
191
192 let uuid = required_string(&dict, "UUID")?;
193 let name = dict
194 .get("Name")
195 .and_then(plist::Value::as_string)
196 .unwrap_or(&uuid)
197 .to_string();
198 let app_id = dict
199 .get("AppIDName")
200 .and_then(plist::Value::as_string)
201 .or_else(|| {
202 dict.get("ApplicationIdentifierPrefix")
203 .and_then(plist::Value::as_array)
204 .and_then(|arr| arr.first())
205 .and_then(plist::Value::as_string)
206 })
207 .unwrap_or("")
208 .to_string();
209 let expiry_date = dict.get("ExpirationDate").map(plist_value_to_string);
210
211 Ok(Profile {
212 uuid,
213 name,
214 app_id,
215 expiry_date,
216 raw_data: raw_data.to_vec(),
217 })
218}
219
220fn embedded_plist_bytes(raw_data: &[u8]) -> Result<&[u8], MisagentError> {
221 let start = find_bytes(raw_data, b"<?xml").or_else(|| find_bytes(raw_data, b"<plist"));
222 let end = find_bytes(raw_data, b"</plist>");
223 match (start, end) {
224 (Some(start), Some(end)) if end >= start => Ok(&raw_data[start..end + b"</plist>".len()]),
225 _ => Err(MisagentError::Protocol(
226 "could not locate embedded plist in provisioning profile".into(),
227 )),
228 }
229}
230
231fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
232 haystack
233 .windows(needle.len())
234 .position(|window| window == needle)
235}
236
237fn required_string(dict: &plist::Dictionary, key: &str) -> Result<String, MisagentError> {
238 dict.get(key)
239 .and_then(plist::Value::as_string)
240 .map(ToOwned::to_owned)
241 .ok_or_else(|| MisagentError::Protocol(format!("missing provisioning profile key {key}")))
242}
243
244fn plist_value_to_string(value: &plist::Value) -> String {
245 match value {
246 plist::Value::String(s) => s.clone(),
247 plist::Value::Date(d) => d.to_xml_format(),
248 other => format!("{other:?}"),
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use std::pin::Pin;
255 use std::task::{Context, Poll};
256
257 use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
258
259 use super::*;
260
261 #[derive(Default)]
262 struct MockStream {
263 read_buf: Vec<u8>,
264 written: Vec<u8>,
265 read_pos: usize,
266 }
267
268 impl MockStream {
269 fn with_response(value: plist::Value) -> Self {
270 let mut payload = Vec::new();
271 plist::to_writer_xml(&mut payload, &value).unwrap();
272 let mut read_buf = Vec::new();
273 read_buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
274 read_buf.extend_from_slice(&payload);
275 Self {
276 read_buf,
277 written: Vec::new(),
278 read_pos: 0,
279 }
280 }
281 }
282
283 impl AsyncRead for MockStream {
284 fn poll_read(
285 mut self: Pin<&mut Self>,
286 _cx: &mut Context<'_>,
287 buf: &mut ReadBuf<'_>,
288 ) -> Poll<std::io::Result<()>> {
289 let remaining = self.read_buf.len().saturating_sub(self.read_pos);
290 if remaining == 0 {
291 return Poll::Ready(Err(std::io::Error::new(
292 std::io::ErrorKind::UnexpectedEof,
293 "no more test data",
294 )));
295 }
296 let to_copy = remaining.min(buf.remaining());
297 let start = self.read_pos;
298 let end = start + to_copy;
299 buf.put_slice(&self.read_buf[start..end]);
300 self.read_pos = end;
301 Poll::Ready(Ok(()))
302 }
303 }
304
305 impl AsyncWrite for MockStream {
306 fn poll_write(
307 mut self: Pin<&mut Self>,
308 _cx: &mut Context<'_>,
309 buf: &[u8],
310 ) -> Poll<std::io::Result<usize>> {
311 self.written.extend_from_slice(buf);
312 Poll::Ready(Ok(buf.len()))
313 }
314
315 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
316 Poll::Ready(Ok(()))
317 }
318
319 fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
320 Poll::Ready(Ok(()))
321 }
322 }
323
324 #[test]
325 fn decode_profile_extracts_basic_metadata_from_embedded_plist() {
326 let xml = br#"garbage<?xml version="1.0" encoding="UTF-8"?>
327<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
328<plist version="1.0"><dict>
329<key>UUID</key><string>ABC-123</string>
330<key>Name</key><string>Example Dev Profile</string>
331<key>AppIDName</key><string>Example App</string>
332<key>ExpirationDate</key><date>2026-04-08T00:00:00Z</date>
333</dict></plist>trailer"#;
334
335 let profile = decode_profile(xml).unwrap();
336 assert_eq!(profile.uuid, "ABC-123");
337 assert_eq!(profile.name, "Example Dev Profile");
338 assert_eq!(profile.app_id, "Example App");
339 assert_eq!(profile.expiry_date.as_deref(), Some("2026-04-08T00:00:00Z"));
340 }
341
342 #[test]
343 fn decode_profile_errors_without_embedded_plist() {
344 let err = decode_profile(b"not-a-profile").unwrap_err();
345 assert!(
346 matches!(err, MisagentError::Protocol(message) if message.contains("embedded plist"))
347 );
348 }
349
350 #[tokio::test]
351 async fn copy_all_uses_copy_all_message_type() {
352 let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
353 ("Status".to_string(), plist::Value::Integer(0.into())),
354 ("Payload".to_string(), plist::Value::Array(Vec::new())),
355 ]));
356 let mut stream = MockStream::with_response(response);
357 let mut client = MisagentClient::new(&mut stream);
358
359 let _ = client.copy_all().await.unwrap();
360
361 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
362 let payload = &stream.written[4..4 + len];
363 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
364 assert_eq!(
365 dict.get("MessageType").and_then(plist::Value::as_string),
366 Some("CopyAll")
367 );
368 }
369
370 #[tokio::test]
371 async fn install_uses_profile_field() {
372 let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
373 "Status".to_string(),
374 plist::Value::Integer(0.into()),
375 )]));
376 let mut stream = MockStream::with_response(response);
377 let mut client = MisagentClient::new(&mut stream);
378
379 client.install(b"PROFILE-DATA").await.unwrap();
380
381 let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
382 let payload = &stream.written[4..4 + len];
383 let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
384 assert_eq!(
385 dict.get("MessageType").and_then(plist::Value::as_string),
386 Some("Install")
387 );
388 assert_eq!(
389 dict.get("Profile").and_then(plist::Value::as_data),
390 Some(&b"PROFILE-DATA"[..])
391 );
392 }
393}