Skip to main content

ios_core/services/fileservice/
mod.rs

1//! iOS 17+ CoreDevice fileservice over RSD.
2//!
3//! The service uses two RSD entries: a control XPC service for sessions and metadata
4//! operations, and a data service for `rwb!FILE` byte transfers. Devices that do not
5//! expose both RSD services should report a clear missing-service error rather than
6//! falling back to AFC or another CoreDevice service name.
7
8use bytes::{Bytes, BytesMut};
9use indexmap::IndexMap;
10use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
11
12use crate::xpc::{XpcClient, XpcError, XpcMessage, XpcValue};
13
14/// RSD service name for session creation and metadata operations.
15pub const CONTROL_SERVICE_NAME: &str = "com.apple.coredevice.fileservice.control";
16/// RSD service name for file payload transfer streams.
17pub const DATA_SERVICE_NAME: &str = "com.apple.coredevice.fileservice.data";
18/// Conservative upper bound for a single file transfer.
19pub const MAX_FILE_SIZE: u64 = 1024 * 1024 * 1024;
20/// Maximum payload size inlined in a `ProposeFile` control request.
21pub const MAX_INLINE_DATA_SIZE: u64 = 500;
22
23const FILE_WIRE_MAGIC: &[u8; 8] = b"rwb!FILE";
24
25/// Errors returned by the CoreDevice fileservice client.
26#[derive(Debug, thiserror::Error)]
27pub enum FileServiceError {
28    /// Underlying XPC transport or encoding error.
29    #[error("xpc error: {0}")]
30    Xpc(#[from] XpcError),
31    /// Underlying data-stream I/O error.
32    #[error("IO error: {0}")]
33    Io(#[from] std::io::Error),
34    /// Fileservice response or transfer frame did not match the expected protocol shape.
35    #[error("protocol error: {0}")]
36    Protocol(String),
37}
38
39/// Fileservice domain identifiers used by CoreDevice.
40///
41/// Some domains require an app bundle identifier or app group identifier when creating
42/// the session. Temporary and root staging domains use an empty identifier.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[repr(u64)]
45pub enum Domain {
46    /// Per-application data container.
47    AppDataContainer = 1,
48    /// App group data container.
49    AppGroupDataContainer = 2,
50    /// Device temporary fileservice domain.
51    Temporary = 3,
52    /// Root staging domain used by reference tools for upload staging.
53    RootStaging = 4,
54    /// System crash log domain.
55    SystemCrashLogs = 5,
56}
57
58/// Tokens returned by `RetrieveFile` or upload proposal calls.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FileTransferTicket {
61    /// Control-plane token used when opening the data-plane transfer.
62    pub response_token: u64,
63    /// File identifier embedded in the `rwb!FILE` transfer header.
64    pub file_id: u64,
65}
66
67/// Metadata sent with file creation proposals.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct FileWriteOptions {
70    /// POSIX mode bits for the proposed file.
71    pub permissions: i64,
72    /// Owner user ID.
73    pub uid: i64,
74    /// Owner group ID.
75    pub gid: i64,
76    /// Unix creation timestamp in seconds.
77    pub creation_time: i64,
78    /// Unix modification timestamp in seconds.
79    pub last_modification_time: i64,
80}
81
82impl FileWriteOptions {
83    /// Return mobile-user defaults matching reference CoreDevice fileservice clients.
84    pub fn mobile_defaults_now() -> Self {
85        let now = std::time::SystemTime::now()
86            .duration_since(std::time::UNIX_EPOCH)
87            .map(|duration| duration.as_secs() as i64)
88            .unwrap_or(0);
89        Self {
90            permissions: 0o644,
91            uid: 501,
92            gid: 501,
93            creation_time: now,
94            last_modification_time: now,
95        }
96    }
97}
98
99impl Default for FileWriteOptions {
100    fn default() -> Self {
101        Self::mobile_defaults_now()
102    }
103}
104
105/// Client for an active fileservice session.
106pub struct FileServiceClient {
107    control: XpcClient,
108    session_id: String,
109}
110
111impl FileServiceClient {
112    /// Create a new fileservice session in the requested domain.
113    pub async fn connect(
114        mut control: XpcClient,
115        domain: Domain,
116        identifier: impl AsRef<str>,
117    ) -> Result<Self, FileServiceError> {
118        let response = control
119            .call(build_create_session_request(domain, identifier.as_ref()))
120            .await?;
121        let session_id = parse_create_session_response(response)?;
122        Ok(Self {
123            control,
124            session_id,
125        })
126    }
127
128    /// Build a client from an existing session ID.
129    pub fn with_session(control: XpcClient, session_id: impl Into<String>) -> Self {
130        Self {
131            control,
132            session_id: session_id.into(),
133        }
134    }
135
136    /// Return the CoreDevice session identifier.
137    pub fn session_id(&self) -> &str {
138        &self.session_id
139    }
140
141    /// List child names for `path` within the active session domain.
142    pub async fn list_directory(&mut self, path: &str) -> Result<Vec<String>, FileServiceError> {
143        let response = self
144            .control
145            .call_recv_client_server(build_retrieve_directory_list_request(
146                &self.session_id,
147                path,
148            ))
149            .await?;
150        parse_directory_list_response(response)
151    }
152
153    /// Request a data-plane ticket for downloading `path`.
154    pub async fn retrieve_file_ticket(
155        &mut self,
156        path: &str,
157    ) -> Result<FileTransferTicket, FileServiceError> {
158        let response = self
159            .control
160            .call(build_retrieve_file_request(&self.session_id, path))
161            .await?;
162        parse_retrieve_file_response(response)
163    }
164
165    /// Download `path` into memory through the data service stream.
166    pub async fn download_file<S>(
167        &mut self,
168        path: &str,
169        data_stream: &mut S,
170    ) -> Result<Bytes, FileServiceError>
171    where
172        S: AsyncRead + AsyncWrite + Unpin,
173    {
174        let ticket = self.retrieve_file_ticket(path).await?;
175        send_download_wire_request(data_stream, &ticket).await?;
176        receive_file_data(data_stream).await
177    }
178
179    /// Download `path` directly into an async writer through the data service stream.
180    pub async fn download_file_to_writer<S, W>(
181        &mut self,
182        path: &str,
183        data_stream: &mut S,
184        writer: &mut W,
185    ) -> Result<u64, FileServiceError>
186    where
187        S: AsyncRead + AsyncWrite + Unpin,
188        W: AsyncWrite + Unpin,
189    {
190        let ticket = self.retrieve_file_ticket(path).await?;
191        send_download_wire_request(data_stream, &ticket).await?;
192        receive_file_data_to_writer(data_stream, writer).await
193    }
194
195    /// Create an empty remote file with the supplied metadata.
196    pub async fn propose_empty_file(
197        &mut self,
198        path: &str,
199        options: FileWriteOptions,
200    ) -> Result<(), FileServiceError> {
201        let response = self
202            .control
203            .call(build_propose_empty_file_request(
204                &self.session_id,
205                path,
206                options,
207            ))
208            .await?;
209        let body = response_body(response)?;
210        ensure_no_error(&body)
211    }
212
213    /// Remove a remote item.
214    ///
215    /// Set `recursive` for directories. The device enforces the permissions of the
216    /// selected session domain.
217    pub async fn remove_item(
218        &mut self,
219        path: &str,
220        recursive: bool,
221    ) -> Result<(), FileServiceError> {
222        let response = self
223            .control
224            .call(build_remove_item_request(&self.session_id, path, recursive))
225            .await?;
226        let body = response_body(response)?;
227        ensure_no_error(&body)
228    }
229
230    /// Create a directory with the supplied metadata.
231    pub async fn create_directory(
232        &mut self,
233        path: &str,
234        options: FileWriteOptions,
235    ) -> Result<(), FileServiceError> {
236        let response = self
237            .control
238            .call(build_create_directory_request(
239                &self.session_id,
240                path,
241                options,
242            ))
243            .await?;
244        let body = response_body(response)?;
245        ensure_no_error(&body)
246    }
247
248    /// Rename or move an item within the active session domain.
249    pub async fn rename_item(&mut self, from: &str, to: &str) -> Result<(), FileServiceError> {
250        let response = self
251            .control
252            .call(build_rename_item_request(&self.session_id, from, to))
253            .await?;
254        let body = response_body(response)?;
255        ensure_no_error(&body)
256    }
257
258    /// Upload a small file by embedding its bytes in the control-plane request.
259    pub async fn upload_inline_file(
260        &mut self,
261        path: &str,
262        data: Bytes,
263        options: FileWriteOptions,
264    ) -> Result<(), FileServiceError> {
265        if data.is_empty() {
266            return self.propose_empty_file(path, options).await;
267        }
268        if data.len() as u64 > MAX_INLINE_DATA_SIZE {
269            return Err(FileServiceError::Protocol(format!(
270                "inline file size {} exceeds maximum inline size {MAX_INLINE_DATA_SIZE}",
271                data.len()
272            )));
273        }
274
275        let response = self
276            .control
277            .call(build_propose_file_request(
278                &self.session_id,
279                path,
280                data.len() as u64,
281                Some(data),
282                options,
283            ))
284            .await?;
285        let _ = parse_propose_file_response(response)?;
286        Ok(())
287    }
288
289    /// Request a data-plane upload ticket for a large file.
290    pub async fn propose_file_upload(
291        &mut self,
292        path: &str,
293        file_size: u64,
294        options: FileWriteOptions,
295    ) -> Result<FileTransferTicket, FileServiceError> {
296        if file_size > MAX_FILE_SIZE {
297            return Err(FileServiceError::Protocol(format!(
298                "file size {file_size} exceeds maximum allowed size {MAX_FILE_SIZE}"
299            )));
300        }
301        if file_size <= MAX_INLINE_DATA_SIZE {
302            return Err(FileServiceError::Protocol(format!(
303                "file size {file_size} fits inline; use upload_inline_file"
304            )));
305        }
306
307        let response = self
308            .control
309            .call(build_propose_file_request(
310                &self.session_id,
311                path,
312                file_size,
313                None,
314                options,
315            ))
316            .await?;
317        parse_propose_file_response(response)?.ok_or_else(|| {
318            FileServiceError::Protocol("ProposeFile response missing upload ticket".into())
319        })
320    }
321
322    /// Stream large file bytes to the data service using a previously issued ticket.
323    pub async fn upload_file_data<S, R>(
324        &mut self,
325        data_stream: &mut S,
326        ticket: &FileTransferTicket,
327        reader: &mut R,
328        file_size: u64,
329    ) -> Result<(), FileServiceError>
330    where
331        S: AsyncRead + AsyncWrite + Unpin,
332        R: AsyncRead + Unpin,
333    {
334        upload_file_data(data_stream, ticket, reader, file_size).await
335    }
336}
337
338fn build_create_session_request(domain: Domain, identifier: &str) -> XpcValue {
339    XpcValue::Dictionary(IndexMap::from([
340        ("Cmd".to_string(), XpcValue::String("CreateSession".into())),
341        ("Domain".to_string(), XpcValue::Uint64(domain as u64)),
342        (
343            "Identifier".to_string(),
344            XpcValue::String(identifier.to_string()),
345        ),
346        ("Session".to_string(), XpcValue::String(String::new())),
347        ("User".to_string(), XpcValue::String("mobile".into())),
348    ]))
349}
350
351fn build_retrieve_directory_list_request(session_id: &str, path: &str) -> XpcValue {
352    XpcValue::Dictionary(IndexMap::from([
353        (
354            "Cmd".to_string(),
355            XpcValue::String("RetrieveDirectoryList".into()),
356        ),
357        (
358            "MessageUUID".to_string(),
359            XpcValue::String(uuid::Uuid::new_v4().to_string()),
360        ),
361        ("Path".to_string(), XpcValue::String(path.to_string())),
362        (
363            "SessionID".to_string(),
364            XpcValue::String(session_id.to_string()),
365        ),
366    ]))
367}
368
369fn build_retrieve_file_request(session_id: &str, path: &str) -> XpcValue {
370    XpcValue::Dictionary(IndexMap::from([
371        ("Cmd".to_string(), XpcValue::String("RetrieveFile".into())),
372        ("Path".to_string(), XpcValue::String(path.to_string())),
373        (
374            "SessionID".to_string(),
375            XpcValue::String(session_id.to_string()),
376        ),
377    ]))
378}
379
380fn build_propose_empty_file_request(
381    session_id: &str,
382    path: &str,
383    options: FileWriteOptions,
384) -> XpcValue {
385    XpcValue::Dictionary(file_write_metadata(
386        "ProposeEmptyFile",
387        session_id,
388        path,
389        options,
390    ))
391}
392
393fn build_propose_file_request(
394    session_id: &str,
395    path: &str,
396    file_size: u64,
397    file_data: Option<Bytes>,
398    options: FileWriteOptions,
399) -> XpcValue {
400    let mut dict = file_write_metadata("ProposeFile", session_id, path, options);
401    dict.insert("FileSize".to_string(), XpcValue::Uint64(file_size));
402    if let Some(file_data) = file_data {
403        dict.insert("FileData".to_string(), XpcValue::Data(file_data));
404    }
405    XpcValue::Dictionary(dict)
406}
407
408fn build_remove_item_request(session_id: &str, path: &str, recursive: bool) -> XpcValue {
409    XpcValue::Dictionary(IndexMap::from([
410        ("Cmd".to_string(), XpcValue::String("RemoveItem".into())),
411        ("Path".to_string(), XpcValue::String(path.to_string())),
412        ("Recursive".to_string(), XpcValue::Bool(recursive)),
413        (
414            "SessionID".to_string(),
415            XpcValue::String(session_id.to_string()),
416        ),
417    ]))
418}
419
420fn build_create_directory_request(
421    session_id: &str,
422    path: &str,
423    options: FileWriteOptions,
424) -> XpcValue {
425    XpcValue::Dictionary(file_write_metadata(
426        "CreateDirectory",
427        session_id,
428        path,
429        options,
430    ))
431}
432
433fn build_rename_item_request(session_id: &str, from: &str, to: &str) -> XpcValue {
434    XpcValue::Dictionary(IndexMap::from([
435        ("Cmd".to_string(), XpcValue::String("RenameItem".into())),
436        ("SourcePath".to_string(), XpcValue::String(from.to_string())),
437        (
438            "DestinationPath".to_string(),
439            XpcValue::String(to.to_string()),
440        ),
441        (
442            "SessionID".to_string(),
443            XpcValue::String(session_id.to_string()),
444        ),
445    ]))
446}
447
448fn file_write_metadata(
449    command: &str,
450    session_id: &str,
451    path: &str,
452    options: FileWriteOptions,
453) -> IndexMap<String, XpcValue> {
454    IndexMap::from([
455        ("Cmd".to_string(), XpcValue::String(command.to_string())),
456        (
457            "FileCreationTime".to_string(),
458            XpcValue::Int64(options.creation_time),
459        ),
460        (
461            "FileLastModificationTime".to_string(),
462            XpcValue::Int64(options.last_modification_time),
463        ),
464        (
465            "FilePermissions".to_string(),
466            XpcValue::Int64(options.permissions),
467        ),
468        ("FileOwnerUserID".to_string(), XpcValue::Int64(options.uid)),
469        ("FileOwnerGroupID".to_string(), XpcValue::Int64(options.gid)),
470        ("Path".to_string(), XpcValue::String(path.to_string())),
471        (
472            "SessionID".to_string(),
473            XpcValue::String(session_id.to_string()),
474        ),
475    ])
476}
477
478fn parse_create_session_response(response: XpcMessage) -> Result<String, FileServiceError> {
479    let body = response_body(response)?;
480    ensure_no_error(&body)?;
481    let dict = body_dict(&body)?;
482    dict.get("NewSessionID")
483        .and_then(XpcValue::as_str)
484        .map(ToOwned::to_owned)
485        .ok_or_else(|| {
486            FileServiceError::Protocol(format!(
487                "CreateSession response missing NewSessionID: {body:?}"
488            ))
489        })
490}
491
492fn parse_directory_list_response(response: XpcMessage) -> Result<Vec<String>, FileServiceError> {
493    let body = response_body(response)?;
494    ensure_no_error(&body)?;
495    let dict = body_dict(&body)?;
496    let file_list = dict.get("FileList").ok_or_else(|| {
497        FileServiceError::Protocol(format!(
498            "RetrieveDirectoryList response missing FileList: {body:?}"
499        ))
500    })?;
501    let XpcValue::Array(items) = file_list else {
502        return Err(FileServiceError::Protocol(format!(
503            "FileList is not an array: {file_list:?}"
504        )));
505    };
506    Ok(items
507        .iter()
508        .filter_map(|item| item.as_str().map(ToOwned::to_owned))
509        .collect())
510}
511
512fn parse_retrieve_file_response(
513    response: XpcMessage,
514) -> Result<FileTransferTicket, FileServiceError> {
515    let body = response_body(response)?;
516    ensure_no_error(&body)?;
517    let dict = body_dict(&body)?;
518    Ok(FileTransferTicket {
519        response_token: dict.get("Response").and_then(as_u64).ok_or_else(|| {
520            FileServiceError::Protocol(format!(
521                "RetrieveFile response missing Response token: {body:?}"
522            ))
523        })?,
524        file_id: dict.get("NewFileID").and_then(as_u64).ok_or_else(|| {
525            FileServiceError::Protocol(format!("RetrieveFile response missing NewFileID: {body:?}"))
526        })?,
527    })
528}
529
530fn parse_propose_file_response(
531    response: XpcMessage,
532) -> Result<Option<FileTransferTicket>, FileServiceError> {
533    let body = response_body(response)?;
534    ensure_no_error(&body)?;
535    let dict = body_dict(&body)?;
536    let response_token = dict.get("Response").and_then(as_u64);
537    let file_id = dict.get("NewFileID").and_then(as_u64);
538
539    match (response_token, file_id) {
540        (Some(response_token), Some(file_id)) => Ok(Some(FileTransferTicket {
541            response_token,
542            file_id,
543        })),
544        (None, None) => Ok(None),
545        _ => Err(FileServiceError::Protocol(format!(
546            "ProposeFile response has incomplete upload ticket: {body:?}"
547        ))),
548    }
549}
550
551async fn send_download_wire_request<S>(
552    stream: &mut S,
553    ticket: &FileTransferTicket,
554) -> Result<(), FileServiceError>
555where
556    S: AsyncWrite + Unpin,
557{
558    stream
559        .write_all(&build_download_wire_request(ticket.clone()))
560        .await?;
561    stream.flush().await?;
562    Ok(())
563}
564
565fn build_download_wire_request(ticket: FileTransferTicket) -> [u8; 40] {
566    let mut request = [0u8; 40];
567    request[0..8].copy_from_slice(FILE_WIRE_MAGIC);
568    request[8..16].copy_from_slice(&ticket.response_token.to_be_bytes());
569    request[24..32].copy_from_slice(&ticket.file_id.to_be_bytes());
570    request
571}
572
573fn build_upload_wire_header(ticket: &FileTransferTicket, file_size: u64) -> [u8; 40] {
574    let mut request = [0u8; 40];
575    request[0..8].copy_from_slice(FILE_WIRE_MAGIC);
576    request[24..32].copy_from_slice(&ticket.file_id.to_be_bytes());
577    request[32..40].copy_from_slice(&file_size.to_be_bytes());
578    request
579}
580
581async fn upload_file_data<S, R>(
582    stream: &mut S,
583    ticket: &FileTransferTicket,
584    reader: &mut R,
585    file_size: u64,
586) -> Result<(), FileServiceError>
587where
588    S: AsyncRead + AsyncWrite + Unpin,
589    R: AsyncRead + Unpin,
590{
591    stream
592        .write_all(&build_upload_wire_header(ticket, file_size))
593        .await?;
594
595    let mut remaining = file_size;
596    let mut buffer = [0u8; 256 * 1024];
597    while remaining > 0 {
598        let to_read = remaining.min(buffer.len() as u64) as usize;
599        let n = reader.read(&mut buffer[..to_read]).await?;
600        if n == 0 {
601            return Err(FileServiceError::Io(std::io::Error::new(
602                std::io::ErrorKind::UnexpectedEof,
603                "file upload source ended before declared size",
604            )));
605        }
606        stream.write_all(&buffer[..n]).await?;
607        remaining -= n as u64;
608    }
609    stream.flush().await?;
610
611    let mut confirmation = [0u8; 32];
612    stream.read_exact(&mut confirmation).await?;
613    if &confirmation[0..8] != FILE_WIRE_MAGIC {
614        return Err(FileServiceError::Protocol(format!(
615            "invalid upload confirmation magic: {:?}",
616            &confirmation[0..8]
617        )));
618    }
619    Ok(())
620}
621
622async fn receive_file_data<S>(stream: &mut S) -> Result<Bytes, FileServiceError>
623where
624    S: AsyncRead + Unpin,
625{
626    let file_size = read_file_data_header(stream).await?;
627    if file_size > MAX_FILE_SIZE {
628        return Err(FileServiceError::Protocol(format!(
629            "file size {file_size} exceeds maximum allowed size {MAX_FILE_SIZE}"
630        )));
631    }
632
633    let mut data = BytesMut::with_capacity(file_size as usize);
634    data.resize(file_size as usize, 0);
635    stream.read_exact(&mut data).await?;
636    Ok(data.freeze())
637}
638
639async fn receive_file_data_to_writer<S, W>(
640    stream: &mut S,
641    writer: &mut W,
642) -> Result<u64, FileServiceError>
643where
644    S: AsyncRead + Unpin,
645    W: AsyncWrite + Unpin,
646{
647    let file_size = read_file_data_header(stream).await?;
648    if file_size > MAX_FILE_SIZE {
649        return Err(FileServiceError::Protocol(format!(
650            "file size {file_size} exceeds maximum allowed size {MAX_FILE_SIZE}"
651        )));
652    }
653
654    let mut remaining = file_size;
655    let mut buffer = [0u8; 256 * 1024];
656    while remaining > 0 {
657        let to_read = remaining.min(buffer.len() as u64) as usize;
658        stream.read_exact(&mut buffer[..to_read]).await?;
659        writer.write_all(&buffer[..to_read]).await?;
660        remaining -= to_read as u64;
661    }
662    writer.flush().await?;
663    Ok(file_size)
664}
665
666async fn read_file_data_header<S>(stream: &mut S) -> Result<u64, FileServiceError>
667where
668    S: AsyncRead + Unpin,
669{
670    let mut header = [0u8; 40];
671    stream.read_exact(&mut header).await?;
672    if &header[0..8] != FILE_WIRE_MAGIC {
673        return Err(FileServiceError::Protocol(format!(
674            "invalid file data magic: {:?}",
675            &header[0..8]
676        )));
677    }
678    Ok(u32::from_be_bytes(
679        header[36..40]
680            .try_into()
681            .map_err(|_| FileServiceError::Protocol("invalid file data size header".into()))?,
682    ) as u64)
683}
684
685fn response_body(response: XpcMessage) -> Result<XpcValue, FileServiceError> {
686    response
687        .body
688        .ok_or_else(|| FileServiceError::Protocol("missing response body".into()))
689}
690
691fn body_dict(value: &XpcValue) -> Result<&IndexMap<String, XpcValue>, FileServiceError> {
692    value.as_dict().ok_or_else(|| {
693        FileServiceError::Protocol(format!("response body is not a dict: {value:?}"))
694    })
695}
696
697fn ensure_no_error(value: &XpcValue) -> Result<(), FileServiceError> {
698    if let Some(message) = error_message(value) {
699        return Err(FileServiceError::Protocol(message));
700    }
701    Ok(())
702}
703
704fn error_message(value: &XpcValue) -> Option<String> {
705    let dict = value.as_dict()?;
706    let encoded_error = dict.get("EncodedError")?;
707    if matches!(encoded_error, XpcValue::Null) {
708        return None;
709    }
710    if let Some(message) = nested_error_message(encoded_error) {
711        return Some(message);
712    }
713    dict.get("LocalizedDescription")
714        .and_then(XpcValue::as_str)
715        .map(ToOwned::to_owned)
716        .or_else(|| Some(format!("{encoded_error:?}")))
717}
718
719fn nested_error_message(value: &XpcValue) -> Option<String> {
720    match value {
721        XpcValue::String(message) => Some(message.clone()),
722        XpcValue::Dictionary(dict) => {
723            for key in [
724                "LocalizedDescription",
725                "localizedDescription",
726                "NSLocalizedDescription",
727                "message",
728                "description",
729            ] {
730                if let Some(XpcValue::String(message)) = dict.get(key) {
731                    return Some(message.clone());
732                }
733            }
734            None
735        }
736        _ => None,
737    }
738}
739
740fn as_u64(value: &XpcValue) -> Option<u64> {
741    match value {
742        XpcValue::Uint64(value) => Some(*value),
743        XpcValue::Int64(value) if *value >= 0 => Some(*value as u64),
744        _ => None,
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use bytes::Bytes;
751    use indexmap::IndexMap;
752    use tokio::io::AsyncWriteExt;
753
754    use super::*;
755    use crate::xpc::{XpcMessage, XpcValue};
756
757    #[test]
758    fn create_session_request_matches_coredevice_fileservice_shape() {
759        let request = build_create_session_request(Domain::AppDataContainer, "com.example.App");
760        let dict = request.as_dict().expect("request should be a dictionary");
761
762        assert_eq!(dict["Cmd"].as_str(), Some("CreateSession"));
763        assert_eq!(dict["Domain"], XpcValue::Uint64(1));
764        assert_eq!(dict["Identifier"].as_str(), Some("com.example.App"));
765        assert_eq!(dict["Session"].as_str(), Some(""));
766        assert_eq!(dict["User"].as_str(), Some("mobile"));
767    }
768
769    #[test]
770    fn session_response_extracts_new_session_id() {
771        let response = XpcMessage {
772            flags: 0,
773            msg_id: 1,
774            body: Some(XpcValue::Dictionary(IndexMap::from([(
775                "NewSessionID".to_string(),
776                XpcValue::String("SESSION-1".into()),
777            )]))),
778        };
779
780        assert_eq!(
781            parse_create_session_response(response).unwrap(),
782            "SESSION-1"
783        );
784    }
785
786    #[test]
787    fn encoded_error_uses_nested_localized_description() {
788        let response = XpcMessage {
789            flags: 0,
790            msg_id: 1,
791            body: Some(XpcValue::Dictionary(IndexMap::from([(
792                "EncodedError".to_string(),
793                XpcValue::Dictionary(IndexMap::from([(
794                    "LocalizedDescription".to_string(),
795                    XpcValue::String("No such file".into()),
796                )])),
797            )]))),
798        };
799
800        let err = parse_create_session_response(response).unwrap_err();
801        assert!(err.to_string().contains("No such file"));
802    }
803
804    #[test]
805    fn directory_list_response_keeps_string_entries() {
806        let response = XpcMessage {
807            flags: 0,
808            msg_id: 2,
809            body: Some(XpcValue::Dictionary(IndexMap::from([(
810                "FileList".to_string(),
811                XpcValue::Array(vec![
812                    XpcValue::String("Documents".into()),
813                    XpcValue::Uint64(7),
814                    XpcValue::String("Library".into()),
815                ]),
816            )]))),
817        };
818
819        assert_eq!(
820            parse_directory_list_response(response).unwrap(),
821            vec!["Documents".to_string(), "Library".to_string()]
822        );
823    }
824
825    #[test]
826    fn retrieve_file_response_extracts_tokens() {
827        let response = XpcMessage {
828            flags: 0,
829            msg_id: 3,
830            body: Some(XpcValue::Dictionary(IndexMap::from([
831                ("Response".to_string(), XpcValue::Uint64(0x11)),
832                ("NewFileID".to_string(), XpcValue::Uint64(0x22)),
833            ]))),
834        };
835
836        assert_eq!(
837            parse_retrieve_file_response(response).unwrap(),
838            FileTransferTicket {
839                response_token: 0x11,
840                file_id: 0x22,
841            }
842        );
843    }
844
845    #[test]
846    fn propose_empty_file_request_includes_metadata() {
847        let options = FileWriteOptions {
848            permissions: 0o644,
849            uid: 501,
850            gid: 501,
851            creation_time: 100,
852            last_modification_time: 200,
853        };
854        let request = build_propose_empty_file_request("SESSION-1", "empty.txt", options);
855        let dict = request.as_dict().expect("request should be a dictionary");
856
857        assert_eq!(dict["Cmd"].as_str(), Some("ProposeEmptyFile"));
858        assert_eq!(dict["Path"].as_str(), Some("empty.txt"));
859        assert_eq!(dict["SessionID"].as_str(), Some("SESSION-1"));
860        assert_eq!(dict["FilePermissions"], XpcValue::Int64(0o644));
861        assert_eq!(dict["FileOwnerUserID"], XpcValue::Int64(501));
862        assert_eq!(dict["FileOwnerGroupID"], XpcValue::Int64(501));
863        assert_eq!(dict["FileCreationTime"], XpcValue::Int64(100));
864        assert_eq!(dict["FileLastModificationTime"], XpcValue::Int64(200));
865    }
866
867    #[test]
868    fn propose_file_request_inlines_small_file_data() {
869        let options = FileWriteOptions {
870            permissions: 0o600,
871            uid: 501,
872            gid: 501,
873            creation_time: 1,
874            last_modification_time: 2,
875        };
876        let request = build_propose_file_request(
877            "SESSION-1",
878            "notes.txt",
879            5,
880            Some(Bytes::from_static(b"hello")),
881            options,
882        );
883        let dict = request.as_dict().expect("request should be a dictionary");
884
885        assert_eq!(dict["Cmd"].as_str(), Some("ProposeFile"));
886        assert_eq!(dict["FileSize"], XpcValue::Uint64(5));
887        assert_eq!(dict["FilePermissions"], XpcValue::Int64(0o600));
888        assert_eq!(
889            dict["FileData"],
890            XpcValue::Data(Bytes::from_static(b"hello"))
891        );
892    }
893
894    #[test]
895    fn remove_item_request_includes_session_path_and_recursive_flag() {
896        let request = build_remove_item_request("SESSION-1", "Documents/old.txt", false);
897        let dict = request.as_dict().expect("request should be a dictionary");
898
899        assert_eq!(dict["Cmd"].as_str(), Some("RemoveItem"));
900        assert_eq!(dict["Path"].as_str(), Some("Documents/old.txt"));
901        assert_eq!(dict["SessionID"].as_str(), Some("SESSION-1"));
902        assert_eq!(dict["Recursive"], XpcValue::Bool(false));
903    }
904
905    #[test]
906    fn create_directory_request_includes_write_metadata() {
907        let options = FileWriteOptions {
908            permissions: 0o755,
909            uid: 501,
910            gid: 501,
911            creation_time: 11,
912            last_modification_time: 12,
913        };
914        let request = build_create_directory_request("SESSION-1", "Documents/New", options);
915        let dict = request.as_dict().expect("request should be a dictionary");
916
917        assert_eq!(dict["Cmd"].as_str(), Some("CreateDirectory"));
918        assert_eq!(dict["Path"].as_str(), Some("Documents/New"));
919        assert_eq!(dict["SessionID"].as_str(), Some("SESSION-1"));
920        assert_eq!(dict["FilePermissions"], XpcValue::Int64(0o755));
921        assert_eq!(dict["FileOwnerUserID"], XpcValue::Int64(501));
922        assert_eq!(dict["FileOwnerGroupID"], XpcValue::Int64(501));
923        assert_eq!(dict["FileCreationTime"], XpcValue::Int64(11));
924        assert_eq!(dict["FileLastModificationTime"], XpcValue::Int64(12));
925    }
926
927    #[test]
928    fn rename_item_request_includes_source_and_destination_paths() {
929        let request =
930            build_rename_item_request("SESSION-1", "Documents/old.txt", "Documents/new.txt");
931        let dict = request.as_dict().expect("request should be a dictionary");
932
933        assert_eq!(dict["Cmd"].as_str(), Some("RenameItem"));
934        assert_eq!(dict["SourcePath"].as_str(), Some("Documents/old.txt"));
935        assert_eq!(dict["DestinationPath"].as_str(), Some("Documents/new.txt"));
936        assert_eq!(dict["SessionID"].as_str(), Some("SESSION-1"));
937    }
938
939    #[test]
940    fn propose_file_response_extracts_large_upload_ticket() {
941        let response = XpcMessage {
942            flags: 0,
943            msg_id: 4,
944            body: Some(XpcValue::Dictionary(IndexMap::from([
945                ("Response".to_string(), XpcValue::Uint64(0x33)),
946                ("NewFileID".to_string(), XpcValue::Uint64(0x44)),
947            ]))),
948        };
949
950        assert_eq!(
951            parse_propose_file_response(response).unwrap(),
952            Some(FileTransferTicket {
953                response_token: 0x33,
954                file_id: 0x44,
955            })
956        );
957    }
958
959    #[test]
960    fn download_wire_request_uses_rwb_file_big_endian_header() {
961        let header = build_download_wire_request(FileTransferTicket {
962            response_token: 0x0102_0304_0506_0708,
963            file_id: 0x1112_1314_1516_1718,
964        });
965
966        assert_eq!(&header[0..8], b"rwb!FILE");
967        assert_eq!(&header[8..16], &[1, 2, 3, 4, 5, 6, 7, 8]);
968        assert_eq!(&header[16..24], &[0; 8]);
969        assert_eq!(
970            &header[24..32],
971            &[0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18]
972        );
973        assert_eq!(&header[32..40], &[0; 8]);
974    }
975
976    #[test]
977    fn upload_wire_header_uses_zero_token_file_id_and_size() {
978        let header = build_upload_wire_header(
979            &FileTransferTicket {
980                response_token: 0x99,
981                file_id: 0x1112_1314_1516_1718,
982            },
983            0x0102_0304_0506_0708,
984        );
985
986        assert_eq!(&header[0..8], b"rwb!FILE");
987        assert_eq!(&header[8..16], &[0; 8]);
988        assert_eq!(
989            &header[24..32],
990            &[0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18]
991        );
992        assert_eq!(&header[32..40], &[1, 2, 3, 4, 5, 6, 7, 8]);
993    }
994
995    #[tokio::test]
996    async fn receive_file_data_reads_size_from_offset_36() {
997        let (mut client, mut server) = tokio::io::duplex(128);
998        let writer = tokio::spawn(async move {
999            let mut header = [0u8; 40];
1000            header[0..8].copy_from_slice(b"rwb!FILE");
1001            header[36..40].copy_from_slice(&(5u32.to_be_bytes()));
1002            server.write_all(&header).await.unwrap();
1003            server.write_all(b"hello").await.unwrap();
1004        });
1005
1006        let data = receive_file_data(&mut client).await.unwrap();
1007
1008        assert_eq!(data, Bytes::from_static(b"hello"));
1009        writer.await.unwrap();
1010    }
1011
1012    #[tokio::test]
1013    async fn upload_file_data_streams_header_payload_and_checks_confirmation() {
1014        let (mut data_client, mut data_server) = tokio::io::duplex(256);
1015        let (mut reader_client, mut reader_server) = tokio::io::duplex(16);
1016        let server = tokio::spawn(async move {
1017            reader_server.write_all(b"hello").await.unwrap();
1018
1019            let mut header_and_payload = [0u8; 45];
1020            data_server
1021                .read_exact(&mut header_and_payload)
1022                .await
1023                .unwrap();
1024            assert_eq!(&header_and_payload[0..8], b"rwb!FILE");
1025            assert_eq!(&header_and_payload[8..16], &[0; 8]);
1026            assert_eq!(&header_and_payload[32..40], &(5u64.to_be_bytes()));
1027            assert_eq!(&header_and_payload[40..45], b"hello");
1028
1029            let mut confirmation = [0u8; 32];
1030            confirmation[0..8].copy_from_slice(b"rwb!FILE");
1031            data_server.write_all(&confirmation).await.unwrap();
1032        });
1033
1034        upload_file_data(
1035            &mut data_client,
1036            &FileTransferTicket {
1037                response_token: 7,
1038                file_id: 9,
1039            },
1040            &mut reader_client,
1041            5,
1042        )
1043        .await
1044        .unwrap();
1045
1046        server.await.unwrap();
1047    }
1048}