Skip to main content

ios_core/services/apps/
zipconduit.rs

1//! Streaming Zip Conduit – fast IPA installation via `com.apple.streaming_zip_conduit`.
2//!
3//! Protocol (from go-ios `ios/zipconduit/`):
4//!   1. Send InitTransfer plist (4-byte BE length prefix)
5//!   2. Stream ZIP entries (local file headers + data, no compression, no central directory)
6//!   3. Send META-INF/ dir + com.apple.ZipMetadata.plist with record counts
7//!   4. Send central directory header signature as terminator
8//!   5. Poll for DataComplete / progress / error responses
9
10use 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
32/// Progress callback for installation status.
33pub type ProgressCallback = Box<dyn Fn(u32, &str) + Send>;
34
35/// Install an IPA via Streaming Zip Conduit.
36///
37/// `stream` must be an already-connected streaming_zip_conduit service connection.
38/// `ipa_path` is the local path to the IPA file.
39/// `progress` is an optional callback for installation progress updates.
40pub 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    // 1. Read and extract the IPA entries (decompress in memory)
54    let ipa_data = tokio::fs::read(ipa_path).await?;
55    let entries = extract_zip_entries(&ipa_data)?;
56
57    // Calculate totals for ZipMetadata
58    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    // RecordCount = META-INF dir + ZipMetadata file + all entries
66    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    // 2. Send InitTransfer plist
74    let init_plist = build_init_transfer(filename);
75    send_plist(stream, &init_plist).await?;
76
77    // 3. Stream ZIP data
78
79    // 3a. META-INF/ directory entry
80    write_zip_dir_entry(stream, "META-INF/").await?;
81
82    // 3b. com.apple.ZipMetadata.plist
83    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    // 3c. All files/dirs from the IPA
93    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    // 4. Send central directory header signature as terminator
102    stream.write_all(&[0x50, 0x4b, 0x01, 0x02]).await?;
103    stream.flush().await?;
104
105    // 5. Poll for completion
106    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
152// ── ZIP entry types ─────────────────────────────────────────────────────────
153
154struct ZipEntry {
155    name: String,
156    is_dir: bool,
157    data: Vec<u8>,
158}
159
160/// Extract all entries from a ZIP file, decompressing as needed.
161fn 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
189// ── ZIP local file header writer ────────────────────────────────────────────
190
191/// Fixed timestamp values (from Xcode captures).
192const FIXED_MOD_TIME: u16 = 0xBDEF;
193const FIXED_MOD_DATE: u16 = 0x52EC;
194
195/// UT extended timestamp extra field (32 bytes, from Xcode capture).
196const 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    // Local file header signature
233    header.extend_from_slice(&0x04034b50u32.to_le_bytes());
234    // Version needed to extract
235    header.extend_from_slice(&20u16.to_le_bytes());
236    // General purpose bit flags
237    header.extend_from_slice(&0u16.to_le_bytes());
238    // Compression method (0 = STORE)
239    header.extend_from_slice(&0u16.to_le_bytes());
240    // Last modified time
241    header.extend_from_slice(&FIXED_MOD_TIME.to_le_bytes());
242    // Last modified date
243    header.extend_from_slice(&FIXED_MOD_DATE.to_le_bytes());
244    // CRC-32
245    header.extend_from_slice(&crc32.to_le_bytes());
246    // Compressed size
247    header.extend_from_slice(&compressed_size.to_le_bytes());
248    // Uncompressed size
249    header.extend_from_slice(&uncompressed_size.to_le_bytes());
250    // Filename length
251    header.extend_from_slice(&filename_len.to_le_bytes());
252    // Extra field length
253    header.extend_from_slice(&extra_len.to_le_bytes());
254    // Filename
255    header.extend_from_slice(name_bytes);
256    // Extra field
257    header.extend_from_slice(&EXTRA_FIELD);
258
259    stream.write_all(&header).await?;
260    Ok(())
261}
262
263// ── Plist helpers ───────────────────────────────────────────────────────────
264
265fn 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()), // 0o40755
334    );
335    dict.insert(
336        "StandardFilePerms".to_string(),
337        plist::Value::Integer((-32348i64).into()), // 0o37777700644 as signed
338    );
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            // Check signature
447            assert_eq!(&buf[0..4], &[0x50, 0x4b, 0x03, 0x04]);
448            // Check version
449            assert_eq!(u16::from_le_bytes([buf[4], buf[5]]), 20);
450            // Check compression method = STORE
451            assert_eq!(u16::from_le_bytes([buf[8], buf[9]]), 0);
452            // Check CRC
453            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        // UT extended timestamp extra field ID = 0x5455
463        assert_eq!(EXTRA_FIELD[0], 0x55); // 'U'
464        assert_eq!(EXTRA_FIELD[1], 0x54); // 'T'
465    }
466}