Skip to main content

ios_core/services/afc/
mod.rs

1//! AFC (Apple File Conduit) protocol – direct async I/O implementation.
2//!
3//! Wire protocol reference: go-ios/ios/afc/afc.go + client.go
4//!
5//! Frame structure:
6//!   [AfcHeader: 40 bytes LE]
7//!   [header_payload: (this_len - 40) bytes]  – null-terminated paths, file handles, status etc.
8//!   [payload: (entire_len - this_len) bytes] – file data for reads/writes
9//!
10//! For ReadDir responses, the device puts filenames in the header_payload section.
11//! For FileRead responses, the data is in the payload section.
12
13use std::collections::HashMap;
14
15use crate::proto::afc::{AfcHeader, AfcOpcode, AFC_MAGIC};
16use bytes::{Bytes, BytesMut};
17use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
18use zerocopy::{FromBytes, IntoBytes};
19
20#[cfg(feature = "house_arrest")]
21pub use super::house_arrest;
22
23pub mod protocol; // kept for re-export compatibility
24
25// ── error ─────────────────────────────────────────────────────────────────────
26
27#[derive(Debug, thiserror::Error)]
28pub enum AfcError {
29    #[error("IO error: {0}")]
30    Io(#[from] std::io::Error),
31    #[error("AFC error: {0}")]
32    Status(AfcStatusCode),
33    #[error("protocol error: {0}")]
34    Protocol(String),
35}
36
37/// AFC status codes from the device (matches go-ios/ios/afc/errors.go).
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AfcStatusCode {
40    Success,
41    Unknown,
42    OperationHeaderInvalid,
43    NoResources,
44    ReadError,
45    WriteError,
46    UnknownPacketType,
47    InvalidArgument,
48    ObjectNotFound,
49    ObjectIsDir,
50    PermDenied,
51    ServiceNotConnected,
52    Timeout,
53    TooMuchData,
54    EndOfData,
55    OpNotSupported,
56    ObjectExists,
57    ObjectBusy,
58    NoSpaceLeft,
59    OpWouldBlock,
60    IoError,
61    OpInterrupted,
62    OpInProgress,
63    InternalError,
64    MuxError,
65    NoMem,
66    NotEnoughData,
67    DirNotEmpty,
68    Other(u64),
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct AfcFileInfo {
73    pub name: Option<String>,
74    pub file_type: Option<String>,
75    pub size: Option<u64>,
76    pub mode: Option<u32>,
77    pub link_target: Option<String>,
78    pub raw: HashMap<String, String>,
79}
80
81impl AfcStatusCode {
82    pub fn from_u64(code: u64) -> Self {
83        match code {
84            0 => Self::Success,
85            1 => Self::Unknown,
86            2 => Self::OperationHeaderInvalid,
87            3 => Self::NoResources,
88            4 => Self::ReadError,
89            5 => Self::WriteError,
90            6 => Self::UnknownPacketType,
91            7 => Self::InvalidArgument,
92            8 => Self::ObjectNotFound,
93            9 => Self::ObjectIsDir,
94            10 => Self::PermDenied,
95            11 => Self::ServiceNotConnected,
96            12 => Self::Timeout,
97            13 => Self::TooMuchData,
98            14 => Self::EndOfData,
99            15 => Self::OpNotSupported,
100            16 => Self::ObjectExists,
101            17 => Self::ObjectBusy,
102            18 => Self::NoSpaceLeft,
103            19 => Self::OpWouldBlock,
104            20 => Self::IoError,
105            21 => Self::OpInterrupted,
106            22 => Self::OpInProgress,
107            23 => Self::InternalError,
108            30 => Self::MuxError,
109            31 => Self::NoMem,
110            32 => Self::NotEnoughData,
111            33 => Self::DirNotEmpty,
112            _ => Self::Other(code),
113        }
114    }
115}
116
117impl std::fmt::Display for AfcStatusCode {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::Success => write!(f, "success"),
121            Self::Unknown => write!(f, "unknown error (1)"),
122            Self::OperationHeaderInvalid => write!(f, "operation header invalid (2)"),
123            Self::NoResources => write!(f, "no resources (3)"),
124            Self::ReadError => write!(f, "read error (4)"),
125            Self::WriteError => write!(f, "write error (5)"),
126            Self::UnknownPacketType => write!(f, "unknown packet type (6)"),
127            Self::InvalidArgument => write!(f, "invalid argument (7)"),
128            Self::ObjectNotFound => write!(f, "object not found (8)"),
129            Self::ObjectIsDir => write!(f, "object is directory (9)"),
130            Self::PermDenied => write!(f, "permission denied (10)"),
131            Self::ServiceNotConnected => write!(f, "service not connected (11)"),
132            Self::Timeout => write!(f, "timeout (12)"),
133            Self::TooMuchData => write!(f, "too much data (13)"),
134            Self::EndOfData => write!(f, "end of data (14)"),
135            Self::OpNotSupported => write!(f, "operation not supported (15)"),
136            Self::ObjectExists => write!(f, "object exists (16)"),
137            Self::ObjectBusy => write!(f, "object busy (17)"),
138            Self::NoSpaceLeft => write!(f, "no space left (18)"),
139            Self::OpWouldBlock => write!(f, "operation would block (19)"),
140            Self::IoError => write!(f, "I/O error (20)"),
141            Self::OpInterrupted => write!(f, "operation interrupted (21)"),
142            Self::OpInProgress => write!(f, "operation in progress (22)"),
143            Self::InternalError => write!(f, "internal error (23)"),
144            Self::MuxError => write!(f, "mux error (30)"),
145            Self::NoMem => write!(f, "no memory (31)"),
146            Self::NotEnoughData => write!(f, "not enough data (32)"),
147            Self::DirNotEmpty => write!(f, "directory not empty (33)"),
148            Self::Other(code) => write!(f, "unknown status ({code})"),
149        }
150    }
151}
152
153// ── raw packet ────────────────────────────────────────────────────────────────
154
155struct Packet {
156    #[allow(dead_code)]
157    opcode: u64,
158    /// Embedded data within the "header" section (this_len - 40 bytes).
159    /// Used for: directory listings, file info, status codes, file handles.
160    header_payload: Bytes,
161    /// Extra data after the header section (entire_len - this_len bytes).
162    /// Used for: file read data.
163    payload: Bytes,
164}
165
166// ── client ────────────────────────────────────────────────────────────────────
167
168/// AFC file-system client.
169///
170/// `S` must be a full-duplex async stream (e.g. the raw usbmux stream from lockdown).
171pub struct AfcClient<S> {
172    stream: S,
173    packet_num: u64,
174}
175
176impl<S: AsyncRead + AsyncWrite + Unpin> AfcClient<S> {
177    pub const FILE_MODE_READ_ONLY: u64 = 0x00000001;
178    pub const FILE_MODE_READ_WRITE: u64 = 0x00000002;
179    pub const FILE_MODE_WRITE_ONLY_CREATE_TRUNC: u64 = 0x00000003;
180    pub const LOCK_EXCLUSIVE: u64 = 2 | 4;
181    pub const LOCK_UNLOCK: u64 = 8 | 4;
182
183    pub fn new(stream: S) -> Self {
184        // go-ios uses atomic.Add(1) which returns 1 on first call.
185        // Devices may reject packet_num=0 for some operations.
186        Self {
187            stream,
188            packet_num: 1,
189        }
190    }
191
192    fn next_pnum(&mut self) -> u64 {
193        let n = self.packet_num;
194        self.packet_num += 1;
195        n
196    }
197
198    // ── send ─────────────────────────────────────────────────────────────────
199
200    async fn send(
201        &mut self,
202        opcode: AfcOpcode,
203        header_payload: &[u8],
204        payload: &[u8],
205    ) -> Result<(), AfcError> {
206        let pnum = self.next_pnum();
207        let hdr = AfcHeader::new(pnum, opcode, header_payload.len(), payload.len());
208        self.stream.write_all(hdr.as_bytes()).await?;
209        if !header_payload.is_empty() {
210            self.stream.write_all(header_payload).await?;
211        }
212        if !payload.is_empty() {
213            self.stream.write_all(payload).await?;
214        }
215        self.stream.flush().await?;
216        Ok(())
217    }
218
219    // ── recv ─────────────────────────────────────────────────────────────────
220
221    async fn recv(&mut self) -> Result<Packet, AfcError> {
222        let mut hdr_buf = [0u8; AfcHeader::SIZE];
223        self.stream.read_exact(&mut hdr_buf).await?;
224
225        let hdr = AfcHeader::ref_from_bytes(&hdr_buf)
226            .map_err(|_| AfcError::Protocol("bad AFC header".into()))?;
227
228        if hdr.magic.get() != AFC_MAGIC {
229            return Err(AfcError::Protocol(format!(
230                "bad AFC magic: 0x{:016X}",
231                hdr.magic.get()
232            )));
233        }
234
235        let entire_len = hdr.entire_len.get() as usize;
236        let this_len = hdr.this_len.get() as usize;
237        let opcode = hdr.operation.get();
238
239        let header_payload_len = this_len.saturating_sub(AfcHeader::SIZE);
240        let payload_len = entire_len.saturating_sub(this_len);
241
242        // Sanity check against DoS
243        const MAX_AFC_MSG: usize = 256 * 1024 * 1024; // 256 MiB
244        if header_payload_len > MAX_AFC_MSG || payload_len > MAX_AFC_MSG {
245            return Err(AfcError::Protocol(format!(
246                "AFC frame too large: header_payload={header_payload_len} payload={payload_len}"
247            )));
248        }
249
250        let mut header_payload = vec![0u8; header_payload_len];
251        let mut payload = vec![0u8; payload_len];
252
253        if header_payload_len > 0 {
254            self.stream.read_exact(&mut header_payload).await?;
255        }
256        if payload_len > 0 {
257            self.stream.read_exact(&mut payload).await?;
258        }
259
260        // Status opcode (1): header_payload[0..8] = LE u64 error code
261        if opcode == AfcOpcode::Status as u64 {
262            let code = AfcStatusCode::from_u64(if header_payload.len() >= 8 {
263                u64::from_le_bytes(
264                    header_payload[..8]
265                        .try_into()
266                        .map_err(|_| AfcError::Protocol("bad status code".into()))?,
267                )
268            } else {
269                0
270            });
271            if code != AfcStatusCode::Success {
272                return Err(AfcError::Status(code));
273            }
274        }
275
276        Ok(Packet {
277            opcode,
278            header_payload: Bytes::from(header_payload),
279            payload: Bytes::from(payload),
280        })
281    }
282
283    // ── public API ────────────────────────────────────────────────────────────
284
285    /// List directory entries (excludes "." and "..").
286    ///
287    /// The AFC device returns null-separated filenames in the payload section
288    /// of the response frame (entire_len > this_len).
289    pub async fn list_dir(&mut self, path: &str) -> Result<Vec<String>, AfcError> {
290        let mut hp = path.as_bytes().to_vec();
291        hp.push(0);
292        self.send(AfcOpcode::ReadDir, &hp, &[]).await?;
293        let pkt = self.recv().await?;
294        // Filenames come in the payload section (consistent with go-ios pack.Payload)
295        let entries = split_null_strings(&pkt.payload)
296            .into_iter()
297            .filter(|s| s != "." && s != "..")
298            .collect();
299        Ok(entries)
300    }
301
302    /// Get key-value file info for a path.
303    pub async fn stat(&mut self, path: &str) -> Result<HashMap<String, String>, AfcError> {
304        let mut hp = path.as_bytes().to_vec();
305        hp.push(0);
306        self.send(AfcOpcode::GetFileInfo, &hp, &[]).await?;
307        let pkt = self.recv().await?;
308        Ok(parse_kv_pairs(&pkt.payload))
309    }
310
311    /// Get parsed file info for a path.
312    ///
313    /// AFC reports `st_mode` as an octal string. This helper parses it into a
314    /// `u32`, matching go-ios behavior.
315    pub async fn stat_info(&mut self, path: &str) -> Result<AfcFileInfo, AfcError> {
316        let raw = self.stat(path).await?;
317        Ok(parse_file_info(path, raw))
318    }
319
320    /// Create a directory.
321    pub async fn make_dir(&mut self, path: &str) -> Result<(), AfcError> {
322        let mut hp = path.as_bytes().to_vec();
323        hp.push(0);
324        self.send(AfcOpcode::MakePath, &hp, &[]).await?;
325        self.recv().await?;
326        Ok(())
327    }
328
329    /// Remove a path (file or empty directory).
330    pub async fn remove(&mut self, path: &str) -> Result<(), AfcError> {
331        let mut hp = path.as_bytes().to_vec();
332        hp.push(0);
333        self.send(AfcOpcode::RemovePath, &hp, &[]).await?;
334        self.recv().await?;
335        Ok(())
336    }
337
338    /// Remove a path and all contents recursively.
339    pub async fn remove_all(&mut self, path: &str) -> Result<(), AfcError> {
340        let mut hp = path.as_bytes().to_vec();
341        hp.push(0);
342        self.send(AfcOpcode::RemovePathAndContents, &hp, &[])
343            .await?;
344        self.recv().await?;
345        Ok(())
346    }
347
348    /// Rename / move a path.
349    pub async fn rename(&mut self, from: &str, to: &str) -> Result<(), AfcError> {
350        let mut hp = from.as_bytes().to_vec();
351        hp.push(0);
352        hp.extend_from_slice(to.as_bytes());
353        hp.push(0);
354        self.send(AfcOpcode::RenamePath, &hp, &[]).await?;
355        self.recv().await?;
356        Ok(())
357    }
358
359    /// Read an entire file into memory.
360    pub async fn read_file(&mut self, path: &str) -> Result<Bytes, AfcError> {
361        let fd = self.file_open(path, Self::FILE_MODE_READ_ONLY).await?; // READ_ONLY
362        let mut data = BytesMut::new();
363        let chunk = 65536u64;
364        loop {
365            let buf = self.file_read(fd, chunk).await?;
366            if buf.is_empty() {
367                break;
368            }
369            data.extend_from_slice(&buf);
370        }
371        self.file_close(fd).await?;
372        Ok(data.freeze())
373    }
374
375    /// Read an entire file into memory, following a single AFC symlink.
376    ///
377    /// This matches go-ios PullSingleFile behavior: if the source path is a
378    /// symlink, AFC reports the target in `st_linktarget` and the file is read
379    /// from that target path instead.
380    pub async fn read_file_follow_links(&mut self, path: &str) -> Result<Bytes, AfcError> {
381        let target = self.resolve_read_path(path).await?;
382        self.read_file(&target).await
383    }
384
385    /// Write data to a file (creates or truncates).
386    pub async fn write_file(&mut self, path: &str, data: &[u8]) -> Result<(), AfcError> {
387        let mut reader = std::io::Cursor::new(data);
388        self.write_file_from_reader(path, &mut reader).await
389    }
390
391    /// Write data from an async reader to a file (creates or truncates).
392    pub async fn write_file_from_reader<R>(
393        &mut self,
394        path: &str,
395        reader: &mut R,
396    ) -> Result<(), AfcError>
397    where
398        R: AsyncRead + Unpin,
399    {
400        let fd = self
401            .file_open(path, Self::FILE_MODE_WRITE_ONLY_CREATE_TRUNC)
402            .await?;
403        let result = async {
404            let mut buf = vec![0u8; 1024 * 1024];
405            loop {
406                let n = reader.read(&mut buf).await?;
407                if n == 0 {
408                    break;
409                }
410                self.file_write(fd, &buf[..n]).await?;
411            }
412            Ok::<(), AfcError>(())
413        }
414        .await;
415        let close_result = self.file_close(fd).await;
416        result?;
417        close_result?;
418        Ok(())
419    }
420
421    pub async fn open_file(&mut self, path: &str, mode: u64) -> Result<u64, AfcError> {
422        self.file_open(path, mode).await
423    }
424
425    pub async fn lock_file(&mut self, fd: u64, operation: u64) -> Result<(), AfcError> {
426        let mut hp = [0u8; 16];
427        hp[..8].copy_from_slice(&fd.to_le_bytes());
428        hp[8..].copy_from_slice(&operation.to_le_bytes());
429        self.send(AfcOpcode::FileRefLock, &hp, &[]).await?;
430        self.recv().await?;
431        Ok(())
432    }
433
434    pub async fn close_file(&mut self, fd: u64) -> Result<(), AfcError> {
435        self.file_close(fd).await
436    }
437
438    /// Get device filesystem info.
439    pub async fn device_info(&mut self) -> Result<HashMap<String, String>, AfcError> {
440        self.send(AfcOpcode::GetDeviceInfo, &[], &[]).await?;
441        let pkt = self.recv().await?;
442        Ok(parse_kv_pairs(&pkt.payload))
443    }
444
445    // ── file handle ops ───────────────────────────────────────────────────────
446
447    async fn file_open(&mut self, path: &str, mode: u64) -> Result<u64, AfcError> {
448        let mut hp = vec![0u8; 8];
449        hp[..8].copy_from_slice(&mode.to_le_bytes());
450        hp.extend_from_slice(path.as_bytes());
451        hp.push(0);
452        self.send(AfcOpcode::FileRefOpen, &hp, &[]).await?;
453        let pkt = self.recv().await?;
454        if pkt.header_payload.len() < 8 {
455            return Err(AfcError::Protocol(
456                "FileRefOpenResult: short response".into(),
457            ));
458        }
459        let fd = u64::from_le_bytes(
460            pkt.header_payload[..8]
461                .try_into()
462                .map_err(|_| AfcError::Protocol("bad file handle".into()))?,
463        );
464        Ok(fd)
465    }
466
467    async fn file_read(&mut self, fd: u64, size: u64) -> Result<Bytes, AfcError> {
468        let mut hp = [0u8; 16];
469        hp[..8].copy_from_slice(&fd.to_le_bytes());
470        hp[8..].copy_from_slice(&size.to_le_bytes());
471        self.send(AfcOpcode::FileRefRead, &hp, &[]).await?;
472        let pkt = self.recv().await?;
473        Ok(pkt.payload)
474    }
475
476    async fn file_write(&mut self, fd: u64, data: &[u8]) -> Result<(), AfcError> {
477        let mut hp = [0u8; 8];
478        hp.copy_from_slice(&fd.to_le_bytes());
479        self.send(AfcOpcode::FileRefWrite, &hp, data).await?;
480        self.recv().await?;
481        Ok(())
482    }
483
484    async fn file_close(&mut self, fd: u64) -> Result<(), AfcError> {
485        let hp = fd.to_le_bytes();
486        self.send(AfcOpcode::FileRefClose, &hp, &[]).await?;
487        self.recv().await?;
488        Ok(())
489    }
490
491    async fn resolve_read_path(&mut self, path: &str) -> Result<String, AfcError> {
492        let info = self.stat_info(path).await?;
493        Ok(resolve_link_target(path, &info))
494    }
495}
496
497// ── helpers ───────────────────────────────────────────────────────────────────
498
499/// Split a null-byte-separated byte slice into owned strings, skipping empty.
500fn split_null_strings(data: &[u8]) -> Vec<String> {
501    data.split(|&b| b == 0)
502        .filter(|s| !s.is_empty())
503        .map(|s| String::from_utf8_lossy(s).into_owned())
504        .collect()
505}
506
507/// Parse null-separated key\0value\0 pairs into a HashMap.
508fn parse_kv_pairs(data: &[u8]) -> HashMap<String, String> {
509    let parts = split_null_strings(data);
510    let mut map = HashMap::new();
511    let mut it = parts.into_iter();
512    while let (Some(k), Some(v)) = (it.next(), it.next()) {
513        map.insert(k, v);
514    }
515    map
516}
517
518fn parse_file_info(path: &str, raw: HashMap<String, String>) -> AfcFileInfo {
519    let name = path
520        .rsplit('/')
521        .next()
522        .filter(|s| !s.is_empty())
523        .map(str::to_string);
524    let file_type = raw.get("st_ifmt").cloned();
525    let size = raw.get("st_size").and_then(|s| s.parse::<u64>().ok());
526    let mode = raw
527        .get("st_mode")
528        .and_then(|s| u32::from_str_radix(s, 8).ok());
529    let link_target = raw.get("st_linktarget").cloned().filter(|s| !s.is_empty());
530
531    AfcFileInfo {
532        name,
533        file_type,
534        size,
535        mode,
536        link_target,
537        raw,
538    }
539}
540
541fn resolve_link_target(path: &str, info: &AfcFileInfo) -> String {
542    let is_link = matches!(info.file_type.as_deref(), Some("S_IFLNK"));
543    if is_link {
544        if let Some(target) = &info.link_target {
545            return target.clone();
546        }
547    }
548    path.to_string()
549}
550
551// ── file mode constants (for callers) ─────────────────────────────────────────
552pub mod mode {
553    pub const READ_ONLY: u64 = 0x00000001;
554    pub const READ_WRITE_CREATE: u64 = 0x00000002;
555    pub const WRITE_ONLY_CREATE_TRUNC: u64 = 0x00000003;
556    pub const READ_WRITE_CREATE_TRUNC: u64 = 0x00000004;
557    pub const WRITE_ONLY_CREATE_APPEND: u64 = 0x00000005;
558    pub const READ_WRITE_CREATE_APPEND: u64 = 0x00000006;
559}
560
561#[cfg(test)]
562mod tests {
563    use crate::test_util::MockStream;
564
565    use super::*;
566    use zerocopy::FromBytes;
567
568    fn afc_frame(opcode: AfcOpcode, header_payload: &[u8], payload: &[u8]) -> Vec<u8> {
569        let header = AfcHeader::new(1, opcode, header_payload.len(), payload.len());
570        let mut frame = header.as_bytes().to_vec();
571        frame.extend_from_slice(header_payload);
572        frame.extend_from_slice(payload);
573        frame
574    }
575
576    fn afc_status_success_frame() -> Vec<u8> {
577        afc_frame(AfcOpcode::Status, &0u64.to_le_bytes(), &[])
578    }
579
580    fn afc_open_result_frame(fd: u64) -> Vec<u8> {
581        afc_frame(AfcOpcode::FileRefOpenResult, &fd.to_le_bytes(), &[])
582    }
583
584    fn afc_write_success_responses(write_count: usize) -> Vec<u8> {
585        let mut responses = afc_open_result_frame(42);
586        for _ in 0..write_count {
587            responses.extend_from_slice(&afc_status_success_frame());
588        }
589        responses.extend_from_slice(&afc_status_success_frame());
590        responses
591    }
592
593    fn file_write_payloads(written: &[u8]) -> Vec<Vec<u8>> {
594        let mut pos = 0;
595        let mut payloads = Vec::new();
596        while pos + AfcHeader::SIZE <= written.len() {
597            let header = AfcHeader::ref_from_bytes(&written[pos..pos + AfcHeader::SIZE]).unwrap();
598            let entire_len = header.entire_len.get() as usize;
599            let this_len = header.this_len.get() as usize;
600            let payload_start = pos + this_len;
601            let payload_end = pos + entire_len;
602            if header.operation.get() == AfcOpcode::FileRefWrite as u64 {
603                payloads.push(written[payload_start..payload_end].to_vec());
604            }
605            pos += entire_len;
606        }
607        assert_eq!(pos, written.len());
608        payloads
609    }
610
611    #[test]
612    fn test_split_null_strings() {
613        let data = b"foo\0bar\0baz\0";
614        let result = split_null_strings(data);
615        assert_eq!(result, vec!["foo", "bar", "baz"]);
616    }
617
618    #[test]
619    fn test_parse_kv_pairs() {
620        let data = b"st_size\x0012345\0st_ifmt\0S_IFREG\0";
621        let map = parse_kv_pairs(data);
622        assert_eq!(map["st_size"], "12345");
623        assert_eq!(map["st_ifmt"], "S_IFREG");
624    }
625
626    #[test]
627    fn test_afc_header_size() {
628        assert_eq!(std::mem::size_of::<AfcHeader>(), 40);
629    }
630
631    #[test]
632    fn test_afc_header_new() {
633        let hdr = AfcHeader::new(7, AfcOpcode::ReadDir, 5, 10);
634        assert_eq!(hdr.magic.get(), AFC_MAGIC);
635        assert_eq!(hdr.packet_num.get(), 7);
636        assert_eq!(hdr.this_len.get(), 45); // 40 + 5
637        assert_eq!(hdr.entire_len.get(), 55); // 45 + 10
638        assert_eq!(hdr.operation.get(), AfcOpcode::ReadDir as u64);
639    }
640
641    /// Simulate list_dir: the device returns filenames in the payload section.
642    #[tokio::test]
643    async fn test_list_dir_roundtrip() {
644        use zerocopy::IntoBytes;
645
646        // ReadDir response: filenames are in the payload section
647        // (this_len = 40, entire_len = 40 + names.len())
648        let names = b".\0..\0Photos\0Downloads\0";
649        let hdr = AfcHeader::new(
650            1,                  // packet_num
651            AfcOpcode::ReadDir, // opcode
652            0,                  // header_payload = 0 bytes (this_len = 40)
653            names.len(),        // payload = filenames (entire_len = 40 + names.len())
654        );
655        let mut server_resp = hdr.as_bytes().to_vec();
656        server_resp.extend_from_slice(names); // payload section follows header
657
658        let (client_side, mut server_side) = tokio::io::duplex(4096);
659        tokio::spawn(async move {
660            use tokio::io::{AsyncReadExt, AsyncWriteExt};
661            let mut buf = vec![0u8; 256];
662            let _ = server_side.read(&mut buf).await;
663            server_side.write_all(&server_resp).await.unwrap();
664        });
665
666        let mut afc = AfcClient::new(client_side);
667        let entries = afc.list_dir("/").await.unwrap();
668        // "." and ".." are filtered out
669        assert_eq!(entries, vec!["Photos", "Downloads"]);
670    }
671
672    #[test]
673    fn test_resolve_link_target_uses_st_linktarget_for_symlink() {
674        let mut raw = HashMap::new();
675        raw.insert("st_ifmt".to_string(), "S_IFLNK".to_string());
676        raw.insert(
677            "st_linktarget".to_string(),
678            "/var/mobile/real-file".to_string(),
679        );
680        let info = parse_file_info("/var/mobile/link", raw);
681
682        let resolved = resolve_link_target("/var/mobile/link", &info);
683        assert_eq!(resolved, "/var/mobile/real-file");
684    }
685
686    #[test]
687    fn test_resolve_link_target_keeps_original_path_for_regular_file() {
688        let mut raw = HashMap::new();
689        raw.insert("st_ifmt".to_string(), "S_IFREG".to_string());
690        let info = parse_file_info("/var/mobile/file", raw);
691
692        let resolved = resolve_link_target("/var/mobile/file", &info);
693        assert_eq!(resolved, "/var/mobile/file");
694    }
695
696    #[test]
697    fn test_parse_file_info_parses_st_mode_from_octal() {
698        let mut raw = HashMap::new();
699        raw.insert("st_ifmt".to_string(), "S_IFREG".to_string());
700        raw.insert("st_mode".to_string(), "100644".to_string());
701        raw.insert("st_size".to_string(), "12".to_string());
702
703        let info = parse_file_info("/var/mobile/file.txt", raw);
704        assert_eq!(info.name.as_deref(), Some("file.txt"));
705        assert_eq!(info.file_type.as_deref(), Some("S_IFREG"));
706        assert_eq!(info.size, Some(12));
707        assert_eq!(info.mode, Some(0o100644));
708    }
709
710    #[test]
711    fn test_afc_status_code_mapping_matches_go_ios_upper_status_codes() {
712        assert_eq!(AfcStatusCode::from_u64(24), AfcStatusCode::Other(24));
713        assert_eq!(AfcStatusCode::from_u64(25), AfcStatusCode::Other(25));
714        assert_eq!(AfcStatusCode::from_u64(26), AfcStatusCode::Other(26));
715        assert_eq!(AfcStatusCode::from_u64(27), AfcStatusCode::Other(27));
716        assert_eq!(AfcStatusCode::from_u64(30), AfcStatusCode::MuxError);
717        assert_eq!(AfcStatusCode::from_u64(31), AfcStatusCode::NoMem);
718        assert_eq!(AfcStatusCode::from_u64(32), AfcStatusCode::NotEnoughData);
719        assert_eq!(AfcStatusCode::from_u64(33), AfcStatusCode::DirNotEmpty);
720    }
721
722    #[tokio::test]
723    async fn write_file_from_reader_sends_chunks_without_prebuffering() {
724        let payload = vec![0xAB; 1024 * 1024 + 17];
725        let mut reader = std::io::Cursor::new(payload.clone());
726        let mut stream = MockStream::new(afc_write_success_responses(2));
727        let mut client = AfcClient::new(&mut stream);
728
729        client
730            .write_file_from_reader("/PublicStaging/app.ipa", &mut reader)
731            .await
732            .unwrap();
733
734        let payloads = file_write_payloads(&stream.written);
735        assert_eq!(payloads.len(), 2);
736        assert_eq!(payloads[0].len(), 1024 * 1024);
737        assert_eq!(payloads[1].len(), 17);
738        assert_eq!(
739            payloads.concat(),
740            payload,
741            "AFC file writes should preserve the streamed payload"
742        );
743    }
744}