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(String),
24    #[error("zip error: {0}")]
25    Zip(String),
26    #[error("install error: {0}")]
27    Install(String),
28}
29
30/// Progress callback for installation status.
31pub type ProgressCallback = Box<dyn Fn(u32, &str) + Send>;
32
33/// Install an IPA via Streaming Zip Conduit.
34///
35/// `stream` must be an already-connected streaming_zip_conduit service connection.
36/// `ipa_path` is the local path to the IPA file.
37/// `progress` is an optional callback for installation progress updates.
38pub async fn install_ipa<S>(
39    stream: &mut S,
40    ipa_path: &Path,
41    progress: Option<ProgressCallback>,
42) -> Result<(), ZipConduitError>
43where
44    S: AsyncRead + AsyncWrite + Unpin,
45{
46    let filename = ipa_path
47        .file_name()
48        .and_then(|n| n.to_str())
49        .unwrap_or("app.ipa");
50
51    // 1. Read and extract the IPA entries (decompress in memory)
52    let ipa_data = tokio::fs::read(ipa_path).await?;
53    let entries = extract_zip_entries(&ipa_data)?;
54
55    // Calculate totals for ZipMetadata
56    let total_uncompressed: u64 = entries.iter().map(|e| e.data.len() as u64).sum();
57    // RecordCount = META-INF dir + ZipMetadata file + all entries
58    let record_count = 2 + entries.len() as u64;
59
60    // 2. Send InitTransfer plist
61    let init_plist = build_init_transfer(filename);
62    send_plist(stream, &init_plist).await?;
63
64    // 3. Stream ZIP data
65
66    // 3a. META-INF/ directory entry
67    write_zip_dir_entry(stream, "META-INF/").await?;
68
69    // 3b. com.apple.ZipMetadata.plist
70    let metadata = build_zip_metadata(record_count, total_uncompressed);
71    let metadata_bytes = plist_to_xml_bytes(&metadata)?;
72    write_zip_file_entry(
73        stream,
74        "META-INF/com.apple.ZipMetadata.plist",
75        &metadata_bytes,
76    )
77    .await?;
78
79    // 3c. All files/dirs from the IPA
80    for entry in &entries {
81        if entry.is_dir {
82            write_zip_dir_entry(stream, &entry.name).await?;
83        } else {
84            write_zip_file_entry(stream, &entry.name, &entry.data).await?;
85        }
86    }
87
88    // 4. Send central directory header signature as terminator
89    stream.write_all(&[0x50, 0x4b, 0x01, 0x02]).await?;
90    stream.flush().await?;
91
92    // 5. Poll for completion
93    loop {
94        let resp = recv_plist(stream).await?;
95
96        if let Some(status) = resp
97            .as_dictionary()
98            .and_then(|d| d.get("Status"))
99            .and_then(|v| v.as_string())
100        {
101            if status == "DataComplete" {
102                return Ok(());
103            }
104        }
105
106        if let Some(progress_dict) = resp
107            .as_dictionary()
108            .and_then(|d| d.get("InstallProgressDict"))
109            .and_then(|v| v.as_dictionary())
110        {
111            if let Some(error) = progress_dict.get("Error").and_then(|v| v.as_string()) {
112                let desc = progress_dict
113                    .get("ErrorDescription")
114                    .and_then(|v| v.as_string())
115                    .unwrap_or("unknown");
116                return Err(ZipConduitError::Install(format!("{error}: {desc}")));
117            }
118
119            let percent = progress_dict
120                .get("PercentComplete")
121                .and_then(|v| v.as_unsigned_integer())
122                .unwrap_or(0) as u32;
123            let status = progress_dict
124                .get("Status")
125                .and_then(|v| v.as_string())
126                .unwrap_or("Unknown");
127
128            if status == "Complete" {
129                return Ok(());
130            }
131
132            if let Some(ref cb) = progress {
133                cb(percent, status);
134            }
135        }
136    }
137}
138
139// ── ZIP entry types ─────────────────────────────────────────────────────────
140
141struct ZipEntry {
142    name: String,
143    is_dir: bool,
144    data: Vec<u8>,
145}
146
147/// Extract all entries from a ZIP file, decompressing as needed.
148fn extract_zip_entries(data: &[u8]) -> Result<Vec<ZipEntry>, ZipConduitError> {
149    let reader = std::io::Cursor::new(data);
150    let mut archive =
151        zip::ZipArchive::new(reader).map_err(|e| ZipConduitError::Zip(e.to_string()))?;
152
153    let mut entries = Vec::with_capacity(archive.len());
154    for i in 0..archive.len() {
155        let mut file = archive
156            .by_index(i)
157            .map_err(|e| ZipConduitError::Zip(e.to_string()))?;
158        let name = file.name().to_string();
159        let is_dir = file.is_dir();
160
161        let mut file_data = Vec::new();
162        if !is_dir {
163            file.read_to_end(&mut file_data)
164                .map_err(|e| ZipConduitError::Zip(format!("failed to read {name}: {e}")))?;
165        }
166
167        entries.push(ZipEntry {
168            name,
169            is_dir,
170            data: file_data,
171        });
172    }
173    Ok(entries)
174}
175
176// ── ZIP local file header writer ────────────────────────────────────────────
177
178/// Fixed timestamp values (from Xcode captures).
179const FIXED_MOD_TIME: u16 = 0xBDEF;
180const FIXED_MOD_DATE: u16 = 0x52EC;
181
182/// UT extended timestamp extra field (32 bytes, from Xcode capture).
183const EXTRA_FIELD: [u8; 32] = [
184    0x55, 0x54, 0x0D, 0x00, 0x07, 0xF3, 0xA2, 0xEC, 0x60, 0xF6, 0xA2, 0xEC, 0x60, 0xF3, 0xA2, 0xEC,
185    0x60, 0x75, 0x78, 0x0B, 0x00, 0x01, 0x04, 0xF5, 0x01, 0x00, 0x00, 0x04, 0x14, 0x00, 0x00, 0x00,
186];
187
188async fn write_zip_dir_entry<S: AsyncWrite + Unpin>(
189    stream: &mut S,
190    name: &str,
191) -> Result<(), ZipConduitError> {
192    write_local_file_header(stream, name, 0, 0, 0).await
193}
194
195async fn write_zip_file_entry<S: AsyncWrite + Unpin>(
196    stream: &mut S,
197    name: &str,
198    data: &[u8],
199) -> Result<(), ZipConduitError> {
200    let crc = crc32fast::hash(data);
201    let size = data.len() as u32;
202    write_local_file_header(stream, name, crc, size, size).await?;
203    stream.write_all(data).await?;
204    Ok(())
205}
206
207async fn write_local_file_header<S: AsyncWrite + Unpin>(
208    stream: &mut S,
209    filename: &str,
210    crc32: u32,
211    compressed_size: u32,
212    uncompressed_size: u32,
213) -> Result<(), ZipConduitError> {
214    let name_bytes = filename.as_bytes();
215    let mut header = Vec::with_capacity(30 + name_bytes.len() + EXTRA_FIELD.len());
216
217    // Local file header signature
218    header.extend_from_slice(&0x04034b50u32.to_le_bytes());
219    // Version needed to extract
220    header.extend_from_slice(&20u16.to_le_bytes());
221    // General purpose bit flags
222    header.extend_from_slice(&0u16.to_le_bytes());
223    // Compression method (0 = STORE)
224    header.extend_from_slice(&0u16.to_le_bytes());
225    // Last modified time
226    header.extend_from_slice(&FIXED_MOD_TIME.to_le_bytes());
227    // Last modified date
228    header.extend_from_slice(&FIXED_MOD_DATE.to_le_bytes());
229    // CRC-32
230    header.extend_from_slice(&crc32.to_le_bytes());
231    // Compressed size
232    header.extend_from_slice(&compressed_size.to_le_bytes());
233    // Uncompressed size
234    header.extend_from_slice(&uncompressed_size.to_le_bytes());
235    // Filename length
236    header.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
237    // Extra field length
238    header.extend_from_slice(&(EXTRA_FIELD.len() as u16).to_le_bytes());
239    // Filename
240    header.extend_from_slice(name_bytes);
241    // Extra field
242    header.extend_from_slice(&EXTRA_FIELD);
243
244    stream.write_all(&header).await?;
245    Ok(())
246}
247
248// ── Plist helpers ───────────────────────────────────────────────────────────
249
250fn build_init_transfer(filename: &str) -> plist::Value {
251    let mut dict = plist::Dictionary::new();
252    dict.insert(
253        "InstallTransferredDirectory".to_string(),
254        plist::Value::Integer(1.into()),
255    );
256    dict.insert(
257        "UserInitiatedTransfer".to_string(),
258        plist::Value::Integer(0.into()),
259    );
260    dict.insert(
261        "MediaSubdir".to_string(),
262        plist::Value::String(format!("PublicStaging/{filename}")),
263    );
264
265    let mut options = plist::Dictionary::new();
266    options.insert(
267        "InstallDeltaTypeKey".to_string(),
268        plist::Value::String("InstallDeltaTypeSparseIPAFiles".to_string()),
269    );
270    options.insert(
271        "DisableDeltaTransfer".to_string(),
272        plist::Value::Integer(1.into()),
273    );
274    options.insert(
275        "IsUserInitiated".to_string(),
276        plist::Value::Integer(1.into()),
277    );
278    options.insert("PreferWifi".to_string(), plist::Value::Integer(1.into()));
279    options.insert(
280        "PackageType".to_string(),
281        plist::Value::String("Customer".to_string()),
282    );
283    dict.insert(
284        "InstallOptionsDictionary".to_string(),
285        plist::Value::Dictionary(options),
286    );
287
288    plist::Value::Dictionary(dict)
289}
290
291fn build_zip_metadata(record_count: u64, total_uncompressed: u64) -> plist::Value {
292    let mut dict = plist::Dictionary::new();
293    dict.insert(
294        "RecordCount".to_string(),
295        plist::Value::Integer((record_count as i64).into()),
296    );
297    dict.insert(
298        "StandardDirectoryPerms".to_string(),
299        plist::Value::Integer(16877.into()), // 0o40755
300    );
301    dict.insert(
302        "StandardFilePerms".to_string(),
303        plist::Value::Integer((-32348i64).into()), // 0o37777700644 as signed
304    );
305    dict.insert(
306        "TotalUncompressedBytes".to_string(),
307        plist::Value::Integer((total_uncompressed as i64).into()),
308    );
309    dict.insert("Version".to_string(), plist::Value::Integer(2.into()));
310    plist::Value::Dictionary(dict)
311}
312
313fn plist_to_xml_bytes(value: &plist::Value) -> Result<Vec<u8>, ZipConduitError> {
314    let mut buf = Vec::new();
315    plist::to_writer_xml(&mut buf, value).map_err(|e| ZipConduitError::Plist(e.to_string()))?;
316    Ok(buf)
317}
318
319async fn send_plist<S: AsyncWrite + Unpin>(
320    stream: &mut S,
321    value: &plist::Value,
322) -> Result<(), ZipConduitError> {
323    let buf = plist_to_xml_bytes(value)?;
324    stream.write_all(&(buf.len() as u32).to_be_bytes()).await?;
325    stream.write_all(&buf).await?;
326    stream.flush().await?;
327    Ok(())
328}
329
330async fn recv_plist<S: AsyncRead + Unpin>(stream: &mut S) -> Result<plist::Value, ZipConduitError> {
331    let mut len_buf = [0u8; 4];
332    stream.read_exact(&mut len_buf).await?;
333    let len = u32::from_be_bytes(len_buf) as usize;
334    if len > 4 * 1024 * 1024 {
335        return Err(ZipConduitError::Plist(format!("plist too large: {len}")));
336    }
337    let mut buf = vec![0u8; len];
338    stream.read_exact(&mut buf).await?;
339    plist::from_bytes(&buf).map_err(|e| ZipConduitError::Plist(e.to_string()))
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn build_init_transfer_has_correct_fields() {
348        let plist = build_init_transfer("Example.ipa");
349        let dict = plist.as_dictionary().unwrap();
350        assert_eq!(
351            dict["MediaSubdir"].as_string(),
352            Some("PublicStaging/Example.ipa")
353        );
354        assert_eq!(
355            dict["InstallTransferredDirectory"].as_signed_integer(),
356            Some(1)
357        );
358        let opts = dict["InstallOptionsDictionary"].as_dictionary().unwrap();
359        assert_eq!(opts["PackageType"].as_string(), Some("Customer"));
360        assert_eq!(
361            opts["InstallDeltaTypeKey"].as_string(),
362            Some("InstallDeltaTypeSparseIPAFiles")
363        );
364    }
365
366    #[test]
367    fn build_zip_metadata_has_correct_structure() {
368        let meta = build_zip_metadata(42, 1_000_000);
369        let dict = meta.as_dictionary().unwrap();
370        assert_eq!(dict["RecordCount"].as_signed_integer(), Some(42));
371        assert_eq!(dict["Version"].as_signed_integer(), Some(2));
372        assert_eq!(
373            dict["TotalUncompressedBytes"].as_signed_integer(),
374            Some(1_000_000)
375        );
376        assert_eq!(
377            dict["StandardDirectoryPerms"].as_signed_integer(),
378            Some(16877)
379        );
380    }
381
382    #[test]
383    fn local_file_header_has_correct_signature() {
384        let rt = tokio::runtime::Builder::new_current_thread()
385            .build()
386            .unwrap();
387        rt.block_on(async {
388            let mut buf = Vec::new();
389            write_local_file_header(&mut buf, "test.txt", 0x12345678, 100, 100)
390                .await
391                .unwrap();
392            // Check signature
393            assert_eq!(&buf[0..4], &[0x50, 0x4b, 0x03, 0x04]);
394            // Check version
395            assert_eq!(u16::from_le_bytes([buf[4], buf[5]]), 20);
396            // Check compression method = STORE
397            assert_eq!(u16::from_le_bytes([buf[8], buf[9]]), 0);
398            // Check CRC
399            assert_eq!(
400                u32::from_le_bytes([buf[14], buf[15], buf[16], buf[17]]),
401                0x12345678
402            );
403        });
404    }
405
406    #[test]
407    fn extra_field_starts_with_ut_signature() {
408        // UT extended timestamp extra field ID = 0x5455
409        assert_eq!(EXTRA_FIELD[0], 0x55); // 'U'
410        assert_eq!(EXTRA_FIELD[1], 0x54); // 'T'
411    }
412}