1use bytes::{Bytes, BytesMut};
9use indexmap::IndexMap;
10use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
11
12use crate::xpc::{XpcClient, XpcError, XpcMessage, XpcValue};
13
14pub const CONTROL_SERVICE_NAME: &str = "com.apple.coredevice.fileservice.control";
16pub const DATA_SERVICE_NAME: &str = "com.apple.coredevice.fileservice.data";
18pub const MAX_FILE_SIZE: u64 = 1024 * 1024 * 1024;
20pub const MAX_INLINE_DATA_SIZE: u64 = 500;
22
23const FILE_WIRE_MAGIC: &[u8; 8] = b"rwb!FILE";
24
25#[derive(Debug, thiserror::Error)]
27pub enum FileServiceError {
28 #[error("xpc error: {0}")]
30 Xpc(#[from] XpcError),
31 #[error("IO error: {0}")]
33 Io(#[from] std::io::Error),
34 #[error("protocol error: {0}")]
36 Protocol(String),
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[repr(u64)]
45pub enum Domain {
46 AppDataContainer = 1,
48 AppGroupDataContainer = 2,
50 Temporary = 3,
52 RootStaging = 4,
54 SystemCrashLogs = 5,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FileTransferTicket {
61 pub response_token: u64,
63 pub file_id: u64,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct FileWriteOptions {
70 pub permissions: i64,
72 pub uid: i64,
74 pub gid: i64,
76 pub creation_time: i64,
78 pub last_modification_time: i64,
80}
81
82impl FileWriteOptions {
83 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
105pub struct FileServiceClient {
107 control: XpcClient,
108 session_id: String,
109}
110
111impl FileServiceClient {
112 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 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 pub fn session_id(&self) -> &str {
138 &self.session_id
139 }
140
141 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 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 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 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 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 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 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 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 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 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 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}