1use std::fs::{self, File};
2use std::io::ErrorKind;
3use std::path::{Component, Path, PathBuf};
4use std::time::Duration;
5use std::time::SystemTime;
6
7use serde::Serialize;
8use time::{OffsetDateTime, UtcOffset};
9use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
10use tracing::warn;
11
12use crate::services::device_link::{DeviceLinkClient, DeviceLinkError};
13
14pub const SERVICE_NAME: &str = "com.apple.mobilebackup2";
15pub const RSD_SERVICE_NAME: &str = "com.apple.mobilebackup2.shim.remote";
16pub const SUPPORTED_PROTOCOL_VERSIONS: [f64; 2] = [2.0, 2.1];
17
18const FILE_TRANSFER_CODE_SUCCESS: u8 = 0x00; const FILE_TRANSFER_CODE_LOCAL_ERROR: u8 = 0x06; const FILE_TRANSFER_CODE_FILE_DATA: u8 = 0x0c; const FILE_TRANSFER_CODE_REMOTE_ERROR: u8 = 0x0b; const BULK_OPERATION_ERROR: i64 = -13;
23const EMPTY_PARAMETER_STRING: &str = "___EmptyParameterString___";
24const DOWNLOAD_CHUNK_SIZE: usize = 8 * 1024 * 1024;
25const APPLE_EPOCH_OFFSET: Duration = Duration::from_secs(978_307_200);
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct VersionExchange {
31 pub device_link_version: u64,
32 pub protocol_version: f64,
33 pub local_versions: Vec<f64>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct BackupDirectoryLayout {
38 pub root: PathBuf,
39 pub device_directory: PathBuf,
40 pub target_identifier: String,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct BackupResult {
45 pub layout: BackupDirectoryLayout,
46 pub device_link_version: u64,
47 pub protocol_version: f64,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct RestoreOptions<'a> {
52 pub system: bool,
53 pub reboot: bool,
54 pub copy: bool,
55 pub settings: bool,
56 pub remove: bool,
57 pub password: Option<&'a str>,
58 pub source_identifier: Option<&'a str>,
59}
60
61impl Default for RestoreOptions<'_> {
62 fn default() -> Self {
63 Self {
64 system: false,
65 reboot: true,
66 copy: false,
67 settings: true,
68 remove: false,
69 password: None,
70 source_identifier: None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
76pub struct RestoreResult {
77 pub layout: BackupDirectoryLayout,
78 pub device_link_version: u64,
79 pub protocol_version: f64,
80}
81
82service_error!(
83 Mobilebackup2Error,
84 before {
85 #[error("device link error: {0}")]
86 DeviceLink(#[from] DeviceLinkError),
87 },
88 after {},
89);
90
91pub struct Mobilebackup2Client<S> {
92 device_link: DeviceLinkClient<S>,
93}
94
95impl<S> Mobilebackup2Client<S> {
96 pub fn new(stream: S) -> Self {
97 Self {
98 device_link: DeviceLinkClient::new(stream),
99 }
100 }
101}
102
103impl<S> Mobilebackup2Client<S>
104where
105 S: AsyncRead + AsyncWrite + Unpin,
106{
107 pub async fn version_exchange(&mut self) -> Result<VersionExchange, Mobilebackup2Error> {
108 let device_link_version = self.device_link.version_exchange().await?;
109 let local_versions = SUPPORTED_PROTOCOL_VERSIONS.to_vec();
110
111 self.device_link
112 .send_process_message(&HelloRequest {
113 message_name: "Hello",
114 supported_protocol_versions: local_versions.clone(),
115 })
116 .await?;
117
118 let response = self.device_link.recv_process_message().await?;
119 let error_code = response
120 .get("ErrorCode")
121 .and_then(plist_number_to_u64)
122 .ok_or_else(|| {
123 Mobilebackup2Error::Protocol(format!(
124 "backup2 hello response missing ErrorCode: {response:?}"
125 ))
126 })?;
127 if error_code != 0 {
128 return Err(Mobilebackup2Error::Protocol(format!(
129 "backup2 hello returned ErrorCode={error_code}: {response:?}"
130 )));
131 }
132
133 let protocol_version = response
134 .get("ProtocolVersion")
135 .and_then(plist_number_to_f64)
136 .ok_or_else(|| {
137 Mobilebackup2Error::Protocol(format!(
138 "backup2 hello response missing ProtocolVersion: {response:?}"
139 ))
140 })?;
141 if !local_versions.contains(&protocol_version) {
142 return Err(Mobilebackup2Error::Protocol(format!(
143 "backup2 negotiated unsupported protocol version {protocol_version}"
144 )));
145 }
146
147 Ok(VersionExchange {
148 device_link_version,
149 protocol_version,
150 local_versions,
151 })
152 }
153
154 pub async fn backup(
155 &mut self,
156 backup_root: &Path,
157 target_identifier: &str,
158 full: bool,
159 info_plist: &plist::Dictionary,
160 ) -> Result<BackupResult, Mobilebackup2Error> {
161 let version = self.version_exchange().await?;
162 let layout = {
163 let root = backup_root.to_path_buf();
164 let id = target_identifier.to_owned();
165 let info = info_plist.clone();
166 tokio::task::spawn_blocking(move || {
167 initialize_backup_directory(&root, &id, &info, full)
168 })
169 .await
170 .map_err(|e| Mobilebackup2Error::Io(std::io::Error::other(e.to_string())))?
171 }?;
172
173 self.device_link
174 .send_process_message(&BackupRequest {
175 message_name: "Backup",
176 target_identifier,
177 })
178 .await?;
179
180 let run_result = self.run_loop(&layout).await;
181 let _ = self.finish_session(run_result).await?;
182
183 Ok(BackupResult {
184 layout,
185 device_link_version: version.device_link_version,
186 protocol_version: version.protocol_version,
187 })
188 }
189
190 pub async fn change_password(
191 &mut self,
192 backup_root: &Path,
193 target_identifier: &str,
194 old_password: Option<&str>,
195 new_password: Option<&str>,
196 ) -> Result<(), Mobilebackup2Error> {
197 let _ = self.version_exchange().await?;
198 let layout = create_runtime_layout(backup_root, target_identifier)?;
199
200 self.device_link
201 .send_process_message(&ChangePasswordRequest {
202 message_name: "ChangePassword",
203 target_identifier,
204 old_password,
205 new_password,
206 })
207 .await?;
208
209 let run_result = self.run_loop(&layout).await;
210 let _ = self.finish_session(run_result).await?;
211 Ok(())
212 }
213
214 pub async fn restore(
215 &mut self,
216 backup_root: &Path,
217 target_identifier: &str,
218 options: RestoreOptions<'_>,
219 ) -> Result<RestoreResult, Mobilebackup2Error> {
220 let source_identifier = options.source_identifier.unwrap_or(target_identifier);
221 ensure_backup_directory(backup_root, source_identifier)?;
222 let layout = create_runtime_layout(backup_root, source_identifier)?;
223 let manifest = read_backup_dictionary(&layout.device_directory.join("Manifest.plist"))?;
224 let password = if manifest
225 .get("IsEncrypted")
226 .and_then(plist_value_to_bool)
227 .unwrap_or(false)
228 {
229 Some(options.password.ok_or_else(|| {
230 Mobilebackup2Error::Protocol(
231 "backup is encrypted; restore requires a password".into(),
232 )
233 })?)
234 } else {
235 None
236 };
237 let version = self.version_exchange().await?;
238
239 self.device_link
240 .send_process_message(&RestoreRequest {
241 message_name: "Restore",
242 target_identifier,
243 source_identifier,
244 password,
245 options: RestoreRequestOptions {
246 restore_should_reboot: options.reboot,
247 restore_dont_copy_backup: !options.copy,
248 restore_preserve_settings: options.settings,
249 restore_system_files: options.system,
250 remove_items_not_restored: options.remove,
251 },
252 })
253 .await?;
254
255 let run_result = self.run_loop(&layout).await;
256 let _ = self.finish_session(run_result).await?;
257 Ok(RestoreResult {
258 layout,
259 device_link_version: version.device_link_version,
260 protocol_version: version.protocol_version,
261 })
262 }
263
264 pub async fn info(
265 &mut self,
266 backup_root: &Path,
267 target_identifier: &str,
268 source_identifier: Option<&str>,
269 ) -> Result<Option<plist::Value>, Mobilebackup2Error> {
270 let _ = self.version_exchange().await?;
271 let layout_identifier = source_identifier.unwrap_or(target_identifier);
272 ensure_backup_directory(backup_root, layout_identifier)?;
273 let layout = create_runtime_layout(backup_root, layout_identifier)?;
274
275 self.device_link
276 .send_process_message(&InfoRequest {
277 message_name: "Info",
278 target_identifier,
279 source_identifier,
280 })
281 .await?;
282
283 let run_result = self.run_loop(&layout).await;
284 self.finish_session(run_result).await
285 }
286
287 pub async fn list(
288 &mut self,
289 backup_root: &Path,
290 target_identifier: &str,
291 source_identifier: Option<&str>,
292 ) -> Result<Option<plist::Value>, Mobilebackup2Error> {
293 let _ = self.version_exchange().await?;
294 let source_identifier = source_identifier.unwrap_or(target_identifier);
295 ensure_backup_directory(backup_root, source_identifier)?;
296 let layout = create_runtime_layout(backup_root, source_identifier)?;
297
298 self.device_link
299 .send_process_message(&ListRequest {
300 message_name: "List",
301 target_identifier,
302 source_identifier,
303 })
304 .await?;
305
306 let run_result = self.run_loop(&layout).await;
307 self.finish_session(run_result).await
308 }
309
310 async fn disconnect_best_effort(&mut self) {
311 if let Err(err) = self.device_link.disconnect().await {
312 if !should_suppress_disconnect_error(&err) {
313 warn!("backup2 disconnect failed: {err}");
314 }
315 }
316 }
317
318 async fn finish_session<T>(
319 &mut self,
320 result: Result<T, Mobilebackup2Error>,
321 ) -> Result<T, Mobilebackup2Error> {
322 self.disconnect_best_effort().await;
323 result
324 }
325
326 async fn run_loop(
327 &mut self,
328 layout: &BackupDirectoryLayout,
329 ) -> Result<Option<plist::Value>, Mobilebackup2Error> {
330 loop {
331 let message = self.device_link.recv_message().await?;
332 let parts = message.as_array().ok_or_else(|| {
333 Mobilebackup2Error::Protocol(format!(
334 "device link loop expected array message, got {message:?}"
335 ))
336 })?;
337
338 let command = parts
339 .first()
340 .and_then(plist::Value::as_string)
341 .ok_or_else(|| {
342 Mobilebackup2Error::Protocol(format!(
343 "device link message missing command: {message:?}"
344 ))
345 })?;
346 match command {
347 "DLMessageProcessMessage" => {
348 let payload = parts
349 .get(1)
350 .and_then(plist::Value::as_dictionary)
351 .ok_or_else(|| {
352 Mobilebackup2Error::Protocol(format!(
353 "process message missing dictionary payload: {message:?}"
354 ))
355 })?;
356 let error_code = payload.get("ErrorCode").and_then(plist_number_to_u64);
357 if let Some(code) = error_code {
358 if code != 0 {
359 return Err(Mobilebackup2Error::Protocol(format!(
360 "backup process returned ErrorCode={code}: {payload:?}"
361 )));
362 }
363 }
364 return Ok(payload.get("Content").cloned());
365 }
366 "DLMessageCreateDirectory" => {
367 let path = parts
368 .get(1)
369 .and_then(plist::Value::as_string)
370 .ok_or_else(|| {
371 Mobilebackup2Error::Protocol(format!(
372 "create directory missing path: {message:?}"
373 ))
374 })?;
375 tokio::fs::create_dir_all(resolve_relative_path(layout, path)?).await?;
376 self.send_status_response(
377 0,
378 "",
379 plist::Value::Dictionary(plist::Dictionary::new()),
380 )
381 .await?;
382 }
383 "DLMessageUploadFiles" => {
384 self.receive_uploaded_files(layout).await?;
385 self.send_status_response(
386 0,
387 "",
388 plist::Value::Dictionary(plist::Dictionary::new()),
389 )
390 .await?;
391 }
392 "DLMessageDownloadFiles" => {
393 let files = parts
394 .get(1)
395 .and_then(plist::Value::as_array)
396 .ok_or_else(|| {
397 Mobilebackup2Error::Protocol(format!(
398 "download files missing array payload: {message:?}"
399 ))
400 })?;
401 let (status_code, status_message, status_payload) =
402 self.send_requested_files(layout, files).await?;
403 self.send_status_response(status_code, &status_message, status_payload)
404 .await?;
405 }
406 "DLMessageGetFreeDiskSpace" => {
407 let free_bytes = available_space(&layout.device_directory)?;
408 self.send_status_response(0, "", plist::Value::Integer(free_bytes.into()))
409 .await?;
410 }
411 "DLMessageMoveItems" | "DLMessageMoveFiles" => {
412 let items = parts
413 .get(1)
414 .and_then(plist::Value::as_dictionary)
415 .ok_or_else(|| {
416 Mobilebackup2Error::Protocol(format!(
417 "move items missing mapping payload: {message:?}"
418 ))
419 })?;
420 for (src, dst_value) in items {
421 let dst = dst_value.as_string().ok_or_else(|| {
422 Mobilebackup2Error::Protocol(format!(
423 "move target for {src} was not a string: {message:?}"
424 ))
425 })?;
426 let src_path = resolve_relative_path(layout, src)?;
427 let dst_path = resolve_relative_path(layout, dst)?;
428 if let Some(parent) = dst_path.parent() {
429 tokio::fs::create_dir_all(parent).await?;
430 }
431 tokio::fs::rename(src_path, dst_path).await?;
432 }
433 self.send_status_response(
434 0,
435 "",
436 plist::Value::Dictionary(plist::Dictionary::new()),
437 )
438 .await?;
439 }
440 "DLMessageRemoveItems" | "DLMessageRemoveFiles" => {
441 let items = parts
442 .get(1)
443 .and_then(plist::Value::as_array)
444 .ok_or_else(|| {
445 Mobilebackup2Error::Protocol(format!(
446 "remove items missing array payload: {message:?}"
447 ))
448 })?;
449 for item in items {
450 let rel = item.as_string().ok_or_else(|| {
451 Mobilebackup2Error::Protocol(format!(
452 "remove item path was not a string: {message:?}"
453 ))
454 })?;
455 let target = resolve_relative_path(layout, rel)?;
456 if target.is_dir() {
457 tokio::fs::remove_dir_all(&target).await?;
458 } else if target.exists() {
459 tokio::fs::remove_file(&target).await?;
460 }
461 }
462 self.send_status_response(
463 0,
464 "",
465 plist::Value::Dictionary(plist::Dictionary::new()),
466 )
467 .await?;
468 }
469 "DLContentsOfDirectory" => {
470 let rel = parts
471 .get(1)
472 .and_then(plist::Value::as_string)
473 .ok_or_else(|| {
474 Mobilebackup2Error::Protocol(format!(
475 "contents-of-directory missing path: {message:?}"
476 ))
477 })?;
478 let path = resolve_relative_path(layout, rel)?;
479 let listing = tokio::task::spawn_blocking(move || contents_of_directory(&path))
480 .await
481 .map_err(|e| {
482 Mobilebackup2Error::Io(std::io::Error::other(e.to_string()))
483 })??;
484 self.send_status_response(0, "", plist::Value::Dictionary(listing))
485 .await?;
486 }
487 "DLMessageCopyItem" => {
488 let src = parts
489 .get(1)
490 .and_then(plist::Value::as_string)
491 .ok_or_else(|| {
492 Mobilebackup2Error::Protocol(format!(
493 "copy item missing source: {message:?}"
494 ))
495 })?;
496 let dst = parts
497 .get(2)
498 .and_then(plist::Value::as_string)
499 .ok_or_else(|| {
500 Mobilebackup2Error::Protocol(format!(
501 "copy item missing destination: {message:?}"
502 ))
503 })?;
504 let src_path = resolve_relative_path(layout, src)?;
505 let dst_path = resolve_relative_path(layout, dst)?;
506 tokio::task::spawn_blocking(move || copy_item(&src_path, &dst_path))
507 .await
508 .map_err(|e| {
509 Mobilebackup2Error::Io(std::io::Error::other(e.to_string()))
510 })??;
511 self.send_status_response(
512 0,
513 "",
514 plist::Value::Dictionary(plist::Dictionary::new()),
515 )
516 .await?;
517 }
518 "DLMessagePurgeDiskSpace" => {
519 return Err(Mobilebackup2Error::Protocol(
520 "backup host cannot purge disk space automatically".into(),
521 ));
522 }
523 other => {
524 return Err(Mobilebackup2Error::Protocol(format!(
525 "unsupported backup device-link command {other}: {message:?}"
526 )));
527 }
528 }
529 }
530 }
531
532 async fn receive_uploaded_files(
533 &mut self,
534 layout: &BackupDirectoryLayout,
535 ) -> Result<(), Mobilebackup2Error> {
536 loop {
537 let device_name = read_prefixed_string(self.device_link.stream_mut()).await?;
538 if device_name.is_empty() {
539 break;
540 }
541
542 let file_name = read_prefixed_string(self.device_link.stream_mut()).await?;
543 let output_path = resolve_relative_path(layout, &file_name)?;
544 if let Some(parent) = output_path.parent() {
545 tokio::fs::create_dir_all(parent).await?;
546 }
547 let mut file = tokio::fs::File::create(&output_path).await?;
548
549 loop {
550 let frame_size = read_u32_be(self.device_link.stream_mut()).await?;
551 let mut code = [0u8; 1];
552 self.device_link.stream_mut().read_exact(&mut code).await?;
553 let payload_len = frame_size.checked_sub(1).ok_or_else(|| {
554 Mobilebackup2Error::Protocol(format!(
555 "backup file transfer frame too short for {file_name}"
556 ))
557 })? as usize;
558 let mut payload = vec![0u8; payload_len];
559 self.device_link
560 .stream_mut()
561 .read_exact(&mut payload)
562 .await?;
563
564 match code[0] {
565 FILE_TRANSFER_CODE_FILE_DATA => {
566 tokio::io::AsyncWriteExt::write_all(&mut file, &payload).await?
567 }
568 FILE_TRANSFER_CODE_SUCCESS => break,
569 FILE_TRANSFER_CODE_REMOTE_ERROR => {
570 let message = String::from_utf8_lossy(&payload);
571 warn!(
572 "backup upload for device path '{}' to local file '{}' reported remote error: {}",
573 device_name,
574 file_name,
575 message
576 );
577 break;
578 }
579 other => {
580 return Err(Mobilebackup2Error::Protocol(format!(
581 "unknown backup file transfer code 0x{other:02x} for {file_name}"
582 )));
583 }
584 }
585 }
586 file.flush().await?;
587 }
588
589 Ok(())
590 }
591
592 async fn send_status_response(
593 &mut self,
594 status_code: i64,
595 status_message: &str,
596 status_payload: plist::Value,
597 ) -> Result<(), Mobilebackup2Error> {
598 self.device_link
599 .send_message(&vec![
600 plist::Value::String("DLMessageStatusResponse".into()),
601 plist::Value::Integer(status_code.into()),
602 plist::Value::String(
603 if status_message.is_empty() {
604 EMPTY_PARAMETER_STRING
605 } else {
606 status_message
607 }
608 .into(),
609 ),
610 status_payload,
611 ])
612 .await?;
613 Ok(())
614 }
615
616 async fn send_requested_files(
617 &mut self,
618 layout: &BackupDirectoryLayout,
619 files: &[plist::Value],
620 ) -> Result<(i64, String, plist::Value), Mobilebackup2Error> {
621 let mut failures = plist::Dictionary::new();
622 for file in files {
623 let rel = file.as_string().ok_or_else(|| {
624 Mobilebackup2Error::Protocol(format!(
625 "download file path was not a string: {file:?}"
626 ))
627 })?;
628 let local_path = resolve_relative_path(layout, rel)?;
629 write_prefixed_string(self.device_link.stream_mut(), rel).await?;
630
631 match tokio::fs::read(&local_path).await {
632 Ok(contents) => {
633 let mut offset = 0usize;
634 while offset < contents.len() {
635 let end = (offset + DOWNLOAD_CHUNK_SIZE).min(contents.len());
636 write_transfer_frame(
637 self.device_link.stream_mut(),
638 FILE_TRANSFER_CODE_FILE_DATA,
639 &contents[offset..end],
640 )
641 .await?;
642 offset = end;
643 }
644 write_transfer_frame(
645 self.device_link.stream_mut(),
646 FILE_TRANSFER_CODE_SUCCESS,
647 &[],
648 )
649 .await?;
650 }
651 Err(err) => {
652 let mut failure = plist::Dictionary::from_iter([(
653 "DLFileErrorString".to_string(),
654 plist::Value::String(err.to_string()),
655 )]);
656 if let Some(code) = file_error_code_from_os_error(&err) {
657 failure.insert(
658 "DLFileErrorCode".to_string(),
659 plist::Value::Integer(code.into()),
660 );
661 }
662 failures.insert(rel.to_string(), plist::Value::Dictionary(failure));
663 write_transfer_frame(
664 self.device_link.stream_mut(),
665 FILE_TRANSFER_CODE_LOCAL_ERROR,
666 err.to_string().as_bytes(),
667 )
668 .await?;
669 }
670 }
671 }
672
673 self.device_link
674 .stream_mut()
675 .write_all(&0u32.to_be_bytes())
676 .await?;
677 self.device_link.stream_mut().flush().await?;
678 if failures.is_empty() {
679 Ok((
680 0,
681 String::new(),
682 plist::Value::Dictionary(plist::Dictionary::new()),
683 ))
684 } else {
685 Ok((
686 BULK_OPERATION_ERROR,
687 "Multi status".to_string(),
688 plist::Value::Dictionary(failures),
689 ))
690 }
691 }
692}
693
694pub fn initialize_backup_directory(
695 backup_root: &Path,
696 target_identifier: &str,
697 info_plist: &plist::Dictionary,
698 full: bool,
699) -> Result<BackupDirectoryLayout, Mobilebackup2Error> {
700 let root = backup_root.to_path_buf();
701 let device_directory = root.join(target_identifier);
702 fs::create_dir_all(&device_directory)?;
703
704 let mut info_file = File::create(device_directory.join("Info.plist"))?;
705 plist::to_writer_xml(
706 &mut info_file,
707 &plist::Value::Dictionary(info_plist.clone()),
708 )
709 .map_err(|e| Mobilebackup2Error::Plist(e.to_string()))?;
710
711 let status = plist::Dictionary::from_iter([
712 (
713 "BackupState".to_string(),
714 plist::Value::String("new".into()),
715 ),
716 (
717 "Date".to_string(),
718 plist::Value::Date(plist::Date::from(SystemTime::now())),
719 ),
720 ("IsFullBackup".to_string(), plist::Value::Boolean(full)),
721 ("Version".to_string(), plist::Value::String("3.3".into())),
722 (
723 "SnapshotState".to_string(),
724 plist::Value::String("finished".into()),
725 ),
726 (
727 "UUID".to_string(),
728 plist::Value::String(generate_backup_uuid()),
729 ),
730 ]);
731 let mut status_file = File::create(device_directory.join("Status.plist"))?;
732 plist::to_writer_binary(&mut status_file, &plist::Value::Dictionary(status))
733 .map_err(|e| Mobilebackup2Error::Plist(e.to_string()))?;
734
735 let manifest_path = device_directory.join("Manifest.plist");
736 if full && manifest_path.exists() {
737 fs::remove_file(&manifest_path)?;
738 }
739 let _ = File::create(&manifest_path)?;
740
741 Ok(BackupDirectoryLayout {
742 root,
743 device_directory,
744 target_identifier: target_identifier.to_string(),
745 })
746}
747
748fn create_runtime_layout(
749 backup_root: &Path,
750 target_identifier: &str,
751) -> Result<BackupDirectoryLayout, Mobilebackup2Error> {
752 let root = backup_root.to_path_buf();
753 let device_directory = root.join(target_identifier);
754 fs::create_dir_all(&device_directory)?;
755 Ok(BackupDirectoryLayout {
756 root,
757 device_directory,
758 target_identifier: target_identifier.to_string(),
759 })
760}
761
762fn ensure_backup_directory(
763 backup_root: &Path,
764 target_identifier: &str,
765) -> Result<(), Mobilebackup2Error> {
766 let device_directory = backup_root.join(target_identifier);
767 for file_name in ["Info.plist", "Manifest.plist", "Status.plist"] {
768 let path = device_directory.join(file_name);
769 if !path.exists() {
770 return Err(Mobilebackup2Error::Protocol(format!(
771 "backup directory missing required file {}",
772 path.display()
773 )));
774 }
775 }
776 Ok(())
777}
778
779pub fn load_backup_applications(
780 backup_root: &Path,
781 target_identifier: &str,
782) -> Result<Option<plist::Value>, Mobilebackup2Error> {
783 ensure_backup_directory(backup_root, target_identifier)?;
784 let info = plist::Value::from_file(backup_root.join(target_identifier).join("Info.plist"))
785 .map_err(|err| Mobilebackup2Error::Plist(err.to_string()))?;
786 Ok(info
787 .as_dictionary()
788 .and_then(|dict| dict.get("Applications"))
789 .cloned())
790}
791
792pub fn backup_is_encrypted(
793 backup_root: &Path,
794 target_identifier: &str,
795) -> Result<bool, Mobilebackup2Error> {
796 ensure_backup_directory(backup_root, target_identifier)?;
797 Ok(
798 read_backup_dictionary(&backup_root.join(target_identifier).join("Manifest.plist"))?
799 .get("IsEncrypted")
800 .and_then(plist_value_to_bool)
801 .unwrap_or(false),
802 )
803}
804
805fn read_backup_dictionary(path: &Path) -> Result<plist::Dictionary, Mobilebackup2Error> {
806 plist::Value::from_file(path)
807 .map_err(|err| Mobilebackup2Error::Plist(err.to_string()))?
808 .into_dictionary()
809 .ok_or_else(|| {
810 Mobilebackup2Error::Protocol(format!(
811 "expected plist dictionary in backup metadata file {}",
812 path.display()
813 ))
814 })
815}
816
817#[derive(Serialize)]
818#[serde(rename_all = "PascalCase")]
819struct HelloRequest {
820 message_name: &'static str,
821 supported_protocol_versions: Vec<f64>,
822}
823
824#[derive(Serialize)]
825#[serde(rename_all = "PascalCase")]
826struct BackupRequest<'a> {
827 message_name: &'static str,
828 target_identifier: &'a str,
829}
830
831#[derive(Serialize)]
832#[serde(rename_all = "PascalCase")]
833struct RestoreRequestOptions {
834 restore_should_reboot: bool,
835 restore_dont_copy_backup: bool,
836 restore_preserve_settings: bool,
837 restore_system_files: bool,
838 remove_items_not_restored: bool,
839}
840
841#[derive(Serialize)]
842#[serde(rename_all = "PascalCase")]
843struct RestoreRequest<'a> {
844 message_name: &'static str,
845 target_identifier: &'a str,
846 source_identifier: &'a str,
847 #[serde(skip_serializing_if = "Option::is_none")]
848 password: Option<&'a str>,
849 options: RestoreRequestOptions,
850}
851
852#[derive(Serialize)]
853#[serde(rename_all = "PascalCase")]
854struct ChangePasswordRequest<'a> {
855 message_name: &'static str,
856 target_identifier: &'a str,
857 #[serde(skip_serializing_if = "Option::is_none")]
858 old_password: Option<&'a str>,
859 #[serde(skip_serializing_if = "Option::is_none")]
860 new_password: Option<&'a str>,
861}
862
863#[derive(Serialize)]
864#[serde(rename_all = "PascalCase")]
865struct InfoRequest<'a> {
866 message_name: &'static str,
867 target_identifier: &'a str,
868 #[serde(skip_serializing_if = "Option::is_none")]
869 source_identifier: Option<&'a str>,
870}
871
872#[derive(Serialize)]
873#[serde(rename_all = "PascalCase")]
874struct ListRequest<'a> {
875 message_name: &'static str,
876 target_identifier: &'a str,
877 source_identifier: &'a str,
878}
879
880fn generate_backup_uuid() -> String {
883 uuid::Uuid::new_v4().to_string().to_uppercase()
884}
885
886fn sanitize_relative_path(path: &str) -> Result<PathBuf, Mobilebackup2Error> {
887 let mut clean = PathBuf::new();
888 for component in Path::new(path).components() {
889 match component {
890 Component::Normal(part) => clean.push(part),
891 Component::CurDir => {}
892 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
893 return Err(Mobilebackup2Error::Protocol(format!(
894 "backup path escapes backup root: {path}"
895 )));
896 }
897 }
898 }
899
900 Ok(clean)
901}
902
903fn resolve_relative_path(
904 layout: &BackupDirectoryLayout,
905 rel: &str,
906) -> Result<PathBuf, Mobilebackup2Error> {
907 let clean = sanitize_relative_path(rel)?;
908 let prefixed_with_target = clean
909 .components()
910 .next()
911 .and_then(|component| match component {
912 Component::Normal(value) => value.to_str(),
913 _ => None,
914 })
915 == Some(layout.target_identifier.as_str());
916
917 Ok(if prefixed_with_target {
918 layout.root.join(clean)
919 } else {
920 layout.device_directory.join(clean)
921 })
922}
923
924fn copy_item(src: &Path, dst: &Path) -> Result<(), Mobilebackup2Error> {
925 if let Some(parent) = dst.parent() {
926 fs::create_dir_all(parent)?;
927 }
928
929 if src.is_dir() {
930 fs::create_dir_all(dst)?;
931 for entry in fs::read_dir(src)? {
932 let entry = entry?;
933 copy_item(&entry.path(), &dst.join(entry.file_name()))?;
934 }
935 } else {
936 fs::copy(src, dst)?;
937 }
938
939 Ok(())
940}
941
942fn contents_of_directory(path: &Path) -> Result<plist::Dictionary, Mobilebackup2Error> {
943 let mut entries = plist::Dictionary::new();
944 for entry in fs::read_dir(path)? {
945 let entry = entry?;
946 let metadata = entry.metadata()?;
947 let file_type = if metadata.is_dir() {
948 "DLFileTypeDirectory"
949 } else if metadata.is_file() {
950 "DLFileTypeRegular"
951 } else {
952 "DLFileTypeUnknown"
953 };
954 let modified = metadata.modified().unwrap_or_else(|err| {
955 tracing::debug!("cannot read mtime for {}: {err}", entry.path().display());
956 SystemTime::UNIX_EPOCH
957 });
958 entries.insert(
959 entry.file_name().to_string_lossy().into_owned(),
960 plist::Value::Dictionary(plist::Dictionary::from_iter([
961 (
962 "DLFileType".to_string(),
963 plist::Value::String(file_type.into()),
964 ),
965 (
966 "DLFileSize".to_string(),
967 plist::Value::Integer(metadata.len().into()),
968 ),
969 (
970 "DLFileModificationDate".to_string(),
971 plist::Value::Date(device_link_modification_date(modified)),
972 ),
973 ])),
974 );
975 }
976
977 Ok(entries)
978}
979
980fn device_link_modification_date(modified: SystemTime) -> plist::Date {
981 let modified = device_link_local_wall_clock(modified);
984 let shifted = modified
985 .checked_sub(APPLE_EPOCH_OFFSET)
986 .unwrap_or(SystemTime::UNIX_EPOCH);
987 plist::Date::from(shifted)
988}
989
990fn device_link_local_wall_clock(modified: SystemTime) -> SystemTime {
991 let utc = OffsetDateTime::from(modified);
992 let local_offset = UtcOffset::local_offset_at(utc).unwrap_or(UtcOffset::UTC);
993 let local_wall_clock = utc.to_offset(local_offset).replace_offset(UtcOffset::UTC);
994 local_wall_clock.into()
995}
996
997const MAX_PREFIXED_STRING_SIZE: usize = 64 * 1024;
1001
1002async fn read_prefixed_string<S>(stream: &mut S) -> Result<String, Mobilebackup2Error>
1003where
1004 S: AsyncRead + Unpin,
1005{
1006 let size = read_u32_be(stream).await? as usize;
1007 if size == 0 {
1008 return Ok(String::new());
1009 }
1010 if size > MAX_PREFIXED_STRING_SIZE {
1011 return Err(Mobilebackup2Error::Protocol(format!(
1012 "prefixed string too large: {size} bytes (max {MAX_PREFIXED_STRING_SIZE})"
1013 )));
1014 }
1015
1016 let mut buf = vec![0u8; size];
1017 stream.read_exact(&mut buf).await?;
1018 String::from_utf8(buf)
1019 .map_err(|err| Mobilebackup2Error::Protocol(format!("backup path was not utf-8: {err}")))
1020}
1021
1022async fn read_u32_be<S>(stream: &mut S) -> Result<u32, Mobilebackup2Error>
1023where
1024 S: AsyncRead + Unpin,
1025{
1026 let mut buf = [0u8; 4];
1027 stream.read_exact(&mut buf).await?;
1028 Ok(u32::from_be_bytes(buf))
1029}
1030
1031async fn write_prefixed_string<S>(stream: &mut S, value: &str) -> Result<(), Mobilebackup2Error>
1032where
1033 S: AsyncWrite + Unpin,
1034{
1035 stream
1036 .write_all(&(value.len() as u32).to_be_bytes())
1037 .await?;
1038 stream.write_all(value.as_bytes()).await?;
1039 Ok(())
1040}
1041
1042async fn write_transfer_frame<S>(
1043 stream: &mut S,
1044 code: u8,
1045 payload: &[u8],
1046) -> Result<(), Mobilebackup2Error>
1047where
1048 S: AsyncWrite + Unpin,
1049{
1050 stream
1051 .write_all(&((payload.len() as u32) + 1).to_be_bytes())
1052 .await?;
1053 stream.write_all(&[code]).await?;
1054 if !payload.is_empty() {
1055 stream.write_all(payload).await?;
1056 }
1057 Ok(())
1058}
1059
1060#[cfg(windows)]
1061fn available_space(path: &Path) -> Result<u64, Mobilebackup2Error> {
1062 use std::ffi::OsStr;
1063 use std::os::windows::ffi::OsStrExt;
1064
1065 #[link(name = "Kernel32")]
1066 extern "system" {
1067 fn GetDiskFreeSpaceExW(
1068 lpDirectoryName: *const u16,
1069 lpFreeBytesAvailableToCaller: *mut u64,
1070 lpTotalNumberOfBytes: *mut u64,
1071 lpTotalNumberOfFreeBytes: *mut u64,
1072 ) -> i32;
1073 }
1074
1075 let probe = if path.is_dir() {
1076 path.to_path_buf()
1077 } else {
1078 path.parent().unwrap_or(path).to_path_buf()
1079 };
1080 let wide: Vec<u16> = OsStr::new(probe.as_os_str())
1081 .encode_wide()
1082 .chain(std::iter::once(0))
1083 .collect();
1084 let mut available = 0u64;
1085 let ok = unsafe {
1086 GetDiskFreeSpaceExW(
1087 wide.as_ptr(),
1088 &mut available,
1089 std::ptr::null_mut(),
1090 std::ptr::null_mut(),
1091 )
1092 };
1093 if ok == 0 {
1094 return Err(Mobilebackup2Error::Io(std::io::Error::last_os_error()));
1095 }
1096 Ok(available)
1097}
1098
1099#[cfg(not(windows))]
1100fn available_space(path: &Path) -> Result<u64, Mobilebackup2Error> {
1101 let _ = path;
1102 Ok(0)
1103}
1104
1105fn plist_number_to_u64(value: &plist::Value) -> Option<u64> {
1106 match value {
1107 plist::Value::Integer(value) => value.as_unsigned(),
1108 plist::Value::Real(value) => Some(*value as u64),
1109 _ => None,
1110 }
1111}
1112
1113fn plist_number_to_f64(value: &plist::Value) -> Option<f64> {
1114 match value {
1115 plist::Value::Integer(value) => value.as_unsigned().map(|value| value as f64),
1116 plist::Value::Real(value) => Some(*value),
1117 _ => None,
1118 }
1119}
1120
1121fn plist_value_to_bool(value: &plist::Value) -> Option<bool> {
1122 match value {
1123 plist::Value::Boolean(value) => Some(*value),
1124 plist::Value::Integer(value) => value
1125 .as_signed()
1126 .map(|value| value != 0)
1127 .or_else(|| value.as_unsigned().map(|value| value != 0)),
1128 _ => None,
1129 }
1130}
1131
1132fn file_error_code_from_os_error(error: &std::io::Error) -> Option<i64> {
1133 match error.raw_os_error()? {
1134 2 => Some(-6),
1135 17 => Some(-7),
1136 20 => Some(-8),
1137 21 => Some(-9),
1138 62 => Some(-10),
1139 5 => Some(-11),
1140 28 => Some(-15),
1141 _ => None,
1142 }
1143}
1144
1145fn should_suppress_disconnect_error(error: &DeviceLinkError) -> bool {
1146 matches!(
1147 error,
1148 DeviceLinkError::Io(io_error)
1149 if matches!(
1150 io_error.kind(),
1151 ErrorKind::BrokenPipe
1152 | ErrorKind::ConnectionAborted
1153 | ErrorKind::ConnectionReset
1154 | ErrorKind::NotConnected
1155 | ErrorKind::UnexpectedEof
1156 )
1157 )
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use std::io::ErrorKind;
1163
1164 use super::*;
1165
1166 #[test]
1167 fn initialize_backup_directory_creates_expected_seed_files() {
1168 let root =
1169 std::env::temp_dir().join(format!("ios-core-backup2-layout-{}", std::process::id()));
1170 if root.exists() {
1171 std::fs::remove_dir_all(&root).unwrap();
1172 }
1173 std::fs::create_dir_all(&root).unwrap();
1174
1175 let info = plist::Dictionary::from_iter([(
1176 "Device Name".to_string(),
1177 plist::Value::String("Example".into()),
1178 )]);
1179 let layout = initialize_backup_directory(&root, "device-id", &info, true).unwrap();
1180
1181 assert_eq!(layout.device_directory, root.join("device-id"));
1182 assert!(layout.device_directory.join("Info.plist").exists());
1183 assert!(layout.device_directory.join("Status.plist").exists());
1184 assert!(layout.device_directory.join("Manifest.plist").exists());
1185
1186 std::fs::remove_dir_all(root).unwrap();
1187 }
1188
1189 #[test]
1190 fn resolve_relative_path_accepts_plain_and_prefixed_paths() {
1191 let layout = BackupDirectoryLayout {
1192 root: PathBuf::from("backup-root"),
1193 device_directory: PathBuf::from("backup-root/device-id"),
1194 target_identifier: "device-id".into(),
1195 };
1196
1197 assert_eq!(
1198 resolve_relative_path(&layout, "Manifest.db").unwrap(),
1199 PathBuf::from("backup-root/device-id/Manifest.db")
1200 );
1201 assert_eq!(
1202 resolve_relative_path(&layout, "device-id/Manifest.db").unwrap(),
1203 PathBuf::from("backup-root/device-id/Manifest.db")
1204 );
1205 }
1206
1207 #[test]
1208 fn resolve_relative_path_rejects_parent_escapes() {
1209 let layout = BackupDirectoryLayout {
1210 root: PathBuf::from("backup-root"),
1211 device_directory: PathBuf::from("backup-root/device-id"),
1212 target_identifier: "device-id".into(),
1213 };
1214
1215 let err = resolve_relative_path(&layout, "../outside").unwrap_err();
1216 assert!(err.to_string().contains("escapes"));
1217 }
1218
1219 #[test]
1220 fn generated_backup_uuid_is_uppercase_v4() {
1221 let generated = generate_backup_uuid();
1222 let parsed = uuid::Uuid::parse_str(&generated).expect("status UUID should be parseable");
1223
1224 assert_eq!(generated, generated.to_uppercase());
1225 assert_eq!(parsed.get_version_num(), 4);
1226 }
1227
1228 #[test]
1229 fn backup_is_encrypted_reads_manifest_flag() {
1230 let root = std::env::temp_dir().join(format!(
1231 "ios-core-backup2-encryption-{}",
1232 std::process::id()
1233 ));
1234 let device_dir = root.join("device-id");
1235 if root.exists() {
1236 std::fs::remove_dir_all(&root).unwrap();
1237 }
1238 std::fs::create_dir_all(&device_dir).unwrap();
1239 std::fs::write(device_dir.join("Info.plist"), b"info").unwrap();
1240 plist::to_file_xml(
1241 device_dir.join("Manifest.plist"),
1242 &plist::Value::Dictionary(plist::Dictionary::from_iter([(
1243 "IsEncrypted".to_string(),
1244 plist::Value::Boolean(true),
1245 )])),
1246 )
1247 .unwrap();
1248 std::fs::write(device_dir.join("Status.plist"), b"status").unwrap();
1249
1250 assert!(backup_is_encrypted(&root, "device-id").unwrap());
1251
1252 std::fs::remove_dir_all(root).unwrap();
1253 }
1254
1255 #[test]
1256 fn device_link_modification_date_preserves_subsecond_apple_epoch_timestamp() {
1257 let modified = SystemTime::UNIX_EPOCH
1258 + APPLE_EPOCH_OFFSET
1259 + Duration::from_secs(123)
1260 + Duration::from_millis(900);
1261 let encoded = device_link_modification_date(modified);
1262 let shifted: SystemTime = encoded.into();
1263 let expected = device_link_local_wall_clock(modified)
1264 .checked_sub(APPLE_EPOCH_OFFSET)
1265 .unwrap_or(SystemTime::UNIX_EPOCH);
1266
1267 assert_eq!(shifted, expected);
1268 }
1269
1270 #[test]
1271 fn suppresses_expected_disconnect_transport_errors() {
1272 for kind in [
1273 ErrorKind::BrokenPipe,
1274 ErrorKind::ConnectionAborted,
1275 ErrorKind::ConnectionReset,
1276 ErrorKind::NotConnected,
1277 ErrorKind::UnexpectedEof,
1278 ] {
1279 assert!(should_suppress_disconnect_error(&DeviceLinkError::Io(
1280 std::io::Error::from(kind),
1281 )));
1282 }
1283 }
1284
1285 #[test]
1286 fn keeps_unexpected_disconnect_errors_visible() {
1287 assert!(!should_suppress_disconnect_error(&DeviceLinkError::Io(
1288 std::io::Error::from(ErrorKind::Other),
1289 )));
1290 assert!(!should_suppress_disconnect_error(
1291 &DeviceLinkError::Protocol("disconnect protocol mismatch".into(),)
1292 ));
1293 }
1294
1295 #[tokio::test]
1296 async fn read_prefixed_string_rejects_oversized_allocation() {
1297 let size = (MAX_PREFIXED_STRING_SIZE as u32) + 1;
1299 let data = size.to_be_bytes();
1300 let mut cursor = std::io::Cursor::new(data.to_vec());
1301 let err = read_prefixed_string(&mut cursor).await.unwrap_err();
1302 assert!(
1303 err.to_string().contains("too large"),
1304 "expected size guard error, got: {err}"
1305 );
1306 }
1307
1308 #[tokio::test]
1309 async fn read_prefixed_string_accepts_normal_size() {
1310 let payload = b"hello";
1311 let size = (payload.len() as u32).to_be_bytes();
1312 let mut data = size.to_vec();
1313 data.extend_from_slice(payload);
1314 let mut cursor = std::io::Cursor::new(data);
1315 let result = read_prefixed_string(&mut cursor).await.unwrap();
1316 assert_eq!(result, "hello");
1317 }
1318}