1use std::io::Read;
11use std::path::Path;
12
13use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
14
15pub const SERVICE_NAME: &str = "com.apple.streaming_zip_conduit";
16pub const RSD_SERVICE_NAME: &str = "com.apple.streaming_zip_conduit.shim.remote";
17
18#[derive(Debug, thiserror::Error)]
19pub enum ZipConduitError {
20 #[error("IO error: {0}")]
21 Io(#[from] std::io::Error),
22 #[error("plist error: {0}")]
23 Plist(#[from] plist::Error),
24 #[error("protocol error: {0}")]
25 Protocol(String),
26 #[error("zip error: {0}")]
27 Zip(String),
28 #[error("install error: {0}")]
29 Install(String),
30}
31
32pub type ProgressCallback = Box<dyn Fn(u32, &str) + Send>;
34
35pub async fn install_ipa<S>(
41 stream: &mut S,
42 ipa_path: &Path,
43 progress: Option<ProgressCallback>,
44) -> Result<(), ZipConduitError>
45where
46 S: AsyncRead + AsyncWrite + Unpin,
47{
48 let filename = ipa_path
49 .file_name()
50 .and_then(|n| n.to_str())
51 .unwrap_or("app.ipa");
52
53 let ipa_data = tokio::fs::read(ipa_path).await?;
55 let entries = extract_zip_entries(&ipa_data)?;
56
57 let total_uncompressed = entries.iter().try_fold(0u64, |acc, entry| {
59 let len = u64::try_from(entry.data.len())
60 .map_err(|_| ZipConduitError::Protocol(format!("entry too large: {}", entry.name)))?;
61 acc.checked_add(len).ok_or_else(|| {
62 ZipConduitError::Protocol("total uncompressed ZIP metadata size overflow".to_string())
63 })
64 })?;
65 let record_count = u64::try_from(entries.len())
67 .ok()
68 .and_then(|len| len.checked_add(2))
69 .ok_or_else(|| {
70 ZipConduitError::Protocol("ZIP metadata record count overflow".to_string())
71 })?;
72
73 let init_plist = build_init_transfer(filename);
75 send_plist(stream, &init_plist).await?;
76
77 write_zip_dir_entry(stream, "META-INF/").await?;
81
82 let metadata = build_zip_metadata(record_count, total_uncompressed)?;
84 let metadata_bytes = plist_to_xml_bytes(&metadata)?;
85 write_zip_file_entry(
86 stream,
87 "META-INF/com.apple.ZipMetadata.plist",
88 &metadata_bytes,
89 )
90 .await?;
91
92 for entry in &entries {
94 if entry.is_dir {
95 write_zip_dir_entry(stream, &entry.name).await?;
96 } else {
97 write_zip_file_entry(stream, &entry.name, &entry.data).await?;
98 }
99 }
100
101 stream.write_all(&[0x50, 0x4b, 0x01, 0x02]).await?;
103 stream.flush().await?;
104
105 loop {
107 let resp = recv_plist(stream).await?;
108
109 if let Some(status) = resp
110 .as_dictionary()
111 .and_then(|d| d.get("Status"))
112 .and_then(|v| v.as_string())
113 {
114 if status == "DataComplete" {
115 return Ok(());
116 }
117 }
118
119 if let Some(progress_dict) = resp
120 .as_dictionary()
121 .and_then(|d| d.get("InstallProgressDict"))
122 .and_then(|v| v.as_dictionary())
123 {
124 if let Some(error) = progress_dict.get("Error").and_then(|v| v.as_string()) {
125 let desc = progress_dict
126 .get("ErrorDescription")
127 .and_then(|v| v.as_string())
128 .unwrap_or("unknown");
129 return Err(ZipConduitError::Install(format!("{error}: {desc}")));
130 }
131
132 let percent = progress_dict
133 .get("PercentComplete")
134 .and_then(|v| v.as_unsigned_integer())
135 .unwrap_or(0) as u32;
136 let status = progress_dict
137 .get("Status")
138 .and_then(|v| v.as_string())
139 .unwrap_or("Unknown");
140
141 if status == "Complete" {
142 return Ok(());
143 }
144
145 if let Some(ref cb) = progress {
146 cb(percent, status);
147 }
148 }
149 }
150}
151
152struct ZipEntry {
155 name: String,
156 is_dir: bool,
157 data: Vec<u8>,
158}
159
160fn extract_zip_entries(data: &[u8]) -> Result<Vec<ZipEntry>, ZipConduitError> {
162 let reader = std::io::Cursor::new(data);
163 let mut archive =
164 zip::ZipArchive::new(reader).map_err(|e| ZipConduitError::Zip(e.to_string()))?;
165
166 let mut entries = Vec::with_capacity(archive.len());
167 for i in 0..archive.len() {
168 let mut file = archive
169 .by_index(i)
170 .map_err(|e| ZipConduitError::Zip(e.to_string()))?;
171 let name = file.name().to_string();
172 let is_dir = file.is_dir();
173
174 let mut file_data = Vec::new();
175 if !is_dir {
176 file.read_to_end(&mut file_data)
177 .map_err(|e| ZipConduitError::Zip(format!("failed to read {name}: {e}")))?;
178 }
179
180 entries.push(ZipEntry {
181 name,
182 is_dir,
183 data: file_data,
184 });
185 }
186 Ok(entries)
187}
188
189const FIXED_MOD_TIME: u16 = 0xBDEF;
193const FIXED_MOD_DATE: u16 = 0x52EC;
194
195const EXTRA_FIELD: [u8; 32] = [
197 0x55, 0x54, 0x0D, 0x00, 0x07, 0xF3, 0xA2, 0xEC, 0x60, 0xF6, 0xA2, 0xEC, 0x60, 0xF3, 0xA2, 0xEC,
198 0x60, 0x75, 0x78, 0x0B, 0x00, 0x01, 0x04, 0xF5, 0x01, 0x00, 0x00, 0x04, 0x14, 0x00, 0x00, 0x00,
199];
200
201async fn write_zip_dir_entry<S: AsyncWrite + Unpin>(
202 stream: &mut S,
203 name: &str,
204) -> Result<(), ZipConduitError> {
205 write_local_file_header(stream, name, 0, 0, 0).await
206}
207
208async fn write_zip_file_entry<S: AsyncWrite + Unpin>(
209 stream: &mut S,
210 name: &str,
211 data: &[u8],
212) -> Result<(), ZipConduitError> {
213 let crc = crc32fast::hash(data);
214 let size = checked_zip_u32_len("file data", data.len())?;
215 write_local_file_header(stream, name, crc, size, size).await?;
216 stream.write_all(data).await?;
217 Ok(())
218}
219
220async fn write_local_file_header<S: AsyncWrite + Unpin>(
221 stream: &mut S,
222 filename: &str,
223 crc32: u32,
224 compressed_size: u32,
225 uncompressed_size: u32,
226) -> Result<(), ZipConduitError> {
227 let name_bytes = filename.as_bytes();
228 let filename_len = checked_zip_u16_len("filename", name_bytes.len())?;
229 let extra_len = checked_zip_u16_len("extra field", EXTRA_FIELD.len())?;
230 let mut header = Vec::with_capacity(30 + name_bytes.len() + EXTRA_FIELD.len());
231
232 header.extend_from_slice(&0x04034b50u32.to_le_bytes());
234 header.extend_from_slice(&20u16.to_le_bytes());
236 header.extend_from_slice(&0u16.to_le_bytes());
238 header.extend_from_slice(&0u16.to_le_bytes());
240 header.extend_from_slice(&FIXED_MOD_TIME.to_le_bytes());
242 header.extend_from_slice(&FIXED_MOD_DATE.to_le_bytes());
244 header.extend_from_slice(&crc32.to_le_bytes());
246 header.extend_from_slice(&compressed_size.to_le_bytes());
248 header.extend_from_slice(&uncompressed_size.to_le_bytes());
250 header.extend_from_slice(&filename_len.to_le_bytes());
252 header.extend_from_slice(&extra_len.to_le_bytes());
254 header.extend_from_slice(name_bytes);
256 header.extend_from_slice(&EXTRA_FIELD);
258
259 stream.write_all(&header).await?;
260 Ok(())
261}
262
263fn checked_zip_u32_len(what: &str, len: usize) -> Result<u32, ZipConduitError> {
266 u32::try_from(len)
267 .map_err(|_| ZipConduitError::Protocol(format!("{what} exceeds ZIP u32 range: {len}")))
268}
269
270fn checked_zip_u16_len(what: &str, len: usize) -> Result<u16, ZipConduitError> {
271 u16::try_from(len)
272 .map_err(|_| ZipConduitError::Protocol(format!("{what} exceeds ZIP u16 range: {len}")))
273}
274
275fn checked_zip_i64(what: &str, value: u64) -> Result<i64, ZipConduitError> {
276 i64::try_from(value).map_err(|_| {
277 ZipConduitError::Protocol(format!("{what} exceeds plist integer range: {value}"))
278 })
279}
280
281fn build_init_transfer(filename: &str) -> plist::Value {
282 let mut dict = plist::Dictionary::new();
283 dict.insert(
284 "InstallTransferredDirectory".to_string(),
285 plist::Value::Integer(1.into()),
286 );
287 dict.insert(
288 "UserInitiatedTransfer".to_string(),
289 plist::Value::Integer(0.into()),
290 );
291 dict.insert(
292 "MediaSubdir".to_string(),
293 plist::Value::String(format!("PublicStaging/{filename}")),
294 );
295
296 let mut options = plist::Dictionary::new();
297 options.insert(
298 "InstallDeltaTypeKey".to_string(),
299 plist::Value::String("InstallDeltaTypeSparseIPAFiles".to_string()),
300 );
301 options.insert(
302 "DisableDeltaTransfer".to_string(),
303 plist::Value::Integer(1.into()),
304 );
305 options.insert(
306 "IsUserInitiated".to_string(),
307 plist::Value::Integer(1.into()),
308 );
309 options.insert("PreferWifi".to_string(), plist::Value::Integer(1.into()));
310 options.insert(
311 "PackageType".to_string(),
312 plist::Value::String("Customer".to_string()),
313 );
314 dict.insert(
315 "InstallOptionsDictionary".to_string(),
316 plist::Value::Dictionary(options),
317 );
318
319 plist::Value::Dictionary(dict)
320}
321
322fn build_zip_metadata(
323 record_count: u64,
324 total_uncompressed: u64,
325) -> Result<plist::Value, ZipConduitError> {
326 let mut dict = plist::Dictionary::new();
327 dict.insert(
328 "RecordCount".to_string(),
329 plist::Value::Integer(checked_zip_i64("RecordCount", record_count)?.into()),
330 );
331 dict.insert(
332 "StandardDirectoryPerms".to_string(),
333 plist::Value::Integer(16877.into()), );
335 dict.insert(
336 "StandardFilePerms".to_string(),
337 plist::Value::Integer((-32348i64).into()), );
339 dict.insert(
340 "TotalUncompressedBytes".to_string(),
341 plist::Value::Integer(
342 checked_zip_i64("TotalUncompressedBytes", total_uncompressed)?.into(),
343 ),
344 );
345 dict.insert("Version".to_string(), plist::Value::Integer(2.into()));
346 Ok(plist::Value::Dictionary(dict))
347}
348
349fn plist_to_xml_bytes(value: &plist::Value) -> Result<Vec<u8>, ZipConduitError> {
350 let mut buf = Vec::new();
351 plist::to_writer_xml(&mut buf, value)?;
352 Ok(buf)
353}
354
355async fn send_plist<S: AsyncWrite + Unpin>(
356 stream: &mut S,
357 value: &plist::Value,
358) -> Result<(), ZipConduitError> {
359 let buf = plist_to_xml_bytes(value)?;
360 stream.write_all(&(buf.len() as u32).to_be_bytes()).await?;
361 stream.write_all(&buf).await?;
362 stream.flush().await?;
363 Ok(())
364}
365
366async fn recv_plist<S: AsyncRead + Unpin>(stream: &mut S) -> Result<plist::Value, ZipConduitError> {
367 let mut len_buf = [0u8; 4];
368 stream.read_exact(&mut len_buf).await?;
369 let len = u32::from_be_bytes(len_buf) as usize;
370 if len > 4 * 1024 * 1024 {
371 return Err(ZipConduitError::Protocol(format!("plist too large: {len}")));
372 }
373 let mut buf = vec![0u8; len];
374 stream.read_exact(&mut buf).await?;
375 Ok(plist::from_bytes(&buf)?)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn build_init_transfer_has_correct_fields() {
384 let plist = build_init_transfer("Example.ipa");
385 let dict = plist.as_dictionary().unwrap();
386 assert_eq!(
387 dict["MediaSubdir"].as_string(),
388 Some("PublicStaging/Example.ipa")
389 );
390 assert_eq!(
391 dict["InstallTransferredDirectory"].as_signed_integer(),
392 Some(1)
393 );
394 let opts = dict["InstallOptionsDictionary"].as_dictionary().unwrap();
395 assert_eq!(opts["PackageType"].as_string(), Some("Customer"));
396 assert_eq!(
397 opts["InstallDeltaTypeKey"].as_string(),
398 Some("InstallDeltaTypeSparseIPAFiles")
399 );
400 }
401
402 #[test]
403 fn build_zip_metadata_has_correct_structure() {
404 let meta = build_zip_metadata(42, 1_000_000).unwrap();
405 let dict = meta.as_dictionary().unwrap();
406 assert_eq!(dict["RecordCount"].as_signed_integer(), Some(42));
407 assert_eq!(dict["Version"].as_signed_integer(), Some(2));
408 assert_eq!(
409 dict["TotalUncompressedBytes"].as_signed_integer(),
410 Some(1_000_000)
411 );
412 assert_eq!(
413 dict["StandardDirectoryPerms"].as_signed_integer(),
414 Some(16877)
415 );
416 }
417
418 #[test]
419 fn checked_zip_u32_len_rejects_large_file_sizes() {
420 let err = checked_zip_u32_len("file data", u32::MAX as usize + 1).unwrap_err();
421 assert!(err.to_string().contains("file data"));
422 }
423
424 #[test]
425 fn checked_zip_u16_len_rejects_long_file_names() {
426 let err = checked_zip_u16_len("filename", u16::MAX as usize + 1).unwrap_err();
427 assert!(err.to_string().contains("filename"));
428 }
429
430 #[test]
431 fn build_zip_metadata_rejects_i64_overflow() {
432 let err = build_zip_metadata(u64::MAX, 1).unwrap_err();
433 assert!(err.to_string().contains("RecordCount"));
434 }
435
436 #[test]
437 fn local_file_header_has_correct_signature() {
438 let rt = tokio::runtime::Builder::new_current_thread()
439 .build()
440 .unwrap();
441 rt.block_on(async {
442 let mut buf = Vec::new();
443 write_local_file_header(&mut buf, "test.txt", 0x12345678, 100, 100)
444 .await
445 .unwrap();
446 assert_eq!(&buf[0..4], &[0x50, 0x4b, 0x03, 0x04]);
448 assert_eq!(u16::from_le_bytes([buf[4], buf[5]]), 20);
450 assert_eq!(u16::from_le_bytes([buf[8], buf[9]]), 0);
452 assert_eq!(
454 u32::from_le_bytes([buf[14], buf[15], buf[16], buf[17]]),
455 0x12345678
456 );
457 });
458 }
459
460 #[test]
461 fn extra_field_starts_with_ut_signature() {
462 assert_eq!(EXTRA_FIELD[0], 0x55); assert_eq!(EXTRA_FIELD[1], 0x54); }
466}