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::File::open(&local_path).await {
632 Ok(mut file) => {
633 let mut buf = vec![0u8; DOWNLOAD_CHUNK_SIZE];
634 let mut read_failed = false;
635 loop {
636 let n = match file.read(&mut buf).await {
637 Ok(0) => break,
638 Ok(n) => n,
639 Err(err) => {
640 insert_file_failure(&mut failures, rel, &err);
641 write_transfer_frame(
642 self.device_link.stream_mut(),
643 FILE_TRANSFER_CODE_LOCAL_ERROR,
644 err.to_string().as_bytes(),
645 )
646 .await?;
647 read_failed = true;
648 break;
649 }
650 };
651 write_transfer_frame(
652 self.device_link.stream_mut(),
653 FILE_TRANSFER_CODE_FILE_DATA,
654 &buf[..n],
655 )
656 .await?;
657 }
658 if !read_failed {
659 write_transfer_frame(
660 self.device_link.stream_mut(),
661 FILE_TRANSFER_CODE_SUCCESS,
662 &[],
663 )
664 .await?;
665 }
666 }
667 Err(err) => {
668 insert_file_failure(&mut failures, rel, &err);
669 write_transfer_frame(
670 self.device_link.stream_mut(),
671 FILE_TRANSFER_CODE_LOCAL_ERROR,
672 err.to_string().as_bytes(),
673 )
674 .await?;
675 }
676 }
677 }
678
679 self.device_link
680 .stream_mut()
681 .write_all(&0u32.to_be_bytes())
682 .await?;
683 self.device_link.stream_mut().flush().await?;
684 if failures.is_empty() {
685 Ok((
686 0,
687 String::new(),
688 plist::Value::Dictionary(plist::Dictionary::new()),
689 ))
690 } else {
691 Ok((
692 BULK_OPERATION_ERROR,
693 "Multi status".to_string(),
694 plist::Value::Dictionary(failures),
695 ))
696 }
697 }
698}
699
700pub fn initialize_backup_directory(
701 backup_root: &Path,
702 target_identifier: &str,
703 info_plist: &plist::Dictionary,
704 full: bool,
705) -> Result<BackupDirectoryLayout, Mobilebackup2Error> {
706 let root = backup_root.to_path_buf();
707 let device_directory = root.join(target_identifier);
708 fs::create_dir_all(&device_directory)?;
709
710 let mut info_file = File::create(device_directory.join("Info.plist"))?;
711 plist::to_writer_xml(
712 &mut info_file,
713 &plist::Value::Dictionary(info_plist.clone()),
714 )?;
715
716 let status = plist::Dictionary::from_iter([
717 (
718 "BackupState".to_string(),
719 plist::Value::String("new".into()),
720 ),
721 (
722 "Date".to_string(),
723 plist::Value::Date(plist::Date::from(SystemTime::now())),
724 ),
725 ("IsFullBackup".to_string(), plist::Value::Boolean(full)),
726 ("Version".to_string(), plist::Value::String("3.3".into())),
727 (
728 "SnapshotState".to_string(),
729 plist::Value::String("finished".into()),
730 ),
731 (
732 "UUID".to_string(),
733 plist::Value::String(generate_backup_uuid()),
734 ),
735 ]);
736 let mut status_file = File::create(device_directory.join("Status.plist"))?;
737 plist::to_writer_binary(&mut status_file, &plist::Value::Dictionary(status))?;
738
739 let manifest_path = device_directory.join("Manifest.plist");
740 if full && manifest_path.exists() {
741 fs::remove_file(&manifest_path)?;
742 }
743 let _ = File::create(&manifest_path)?;
744
745 Ok(BackupDirectoryLayout {
746 root,
747 device_directory,
748 target_identifier: target_identifier.to_string(),
749 })
750}
751
752fn create_runtime_layout(
753 backup_root: &Path,
754 target_identifier: &str,
755) -> Result<BackupDirectoryLayout, Mobilebackup2Error> {
756 let root = backup_root.to_path_buf();
757 let device_directory = root.join(target_identifier);
758 fs::create_dir_all(&device_directory)?;
759 Ok(BackupDirectoryLayout {
760 root,
761 device_directory,
762 target_identifier: target_identifier.to_string(),
763 })
764}
765
766fn ensure_backup_directory(
767 backup_root: &Path,
768 target_identifier: &str,
769) -> Result<(), Mobilebackup2Error> {
770 let device_directory = backup_root.join(target_identifier);
771 for file_name in ["Info.plist", "Manifest.plist", "Status.plist"] {
772 let path = device_directory.join(file_name);
773 if !path.exists() {
774 return Err(Mobilebackup2Error::Protocol(format!(
775 "backup directory missing required file {}",
776 path.display()
777 )));
778 }
779 }
780 Ok(())
781}
782
783pub fn load_backup_applications(
784 backup_root: &Path,
785 target_identifier: &str,
786) -> Result<Option<plist::Value>, Mobilebackup2Error> {
787 ensure_backup_directory(backup_root, target_identifier)?;
788 let info = plist::Value::from_file(backup_root.join(target_identifier).join("Info.plist"))?;
789 Ok(info
790 .as_dictionary()
791 .and_then(|dict| dict.get("Applications"))
792 .cloned())
793}
794
795pub fn backup_is_encrypted(
796 backup_root: &Path,
797 target_identifier: &str,
798) -> Result<bool, Mobilebackup2Error> {
799 ensure_backup_directory(backup_root, target_identifier)?;
800 Ok(
801 read_backup_dictionary(&backup_root.join(target_identifier).join("Manifest.plist"))?
802 .get("IsEncrypted")
803 .and_then(plist_value_to_bool)
804 .unwrap_or(false),
805 )
806}
807
808fn read_backup_dictionary(path: &Path) -> Result<plist::Dictionary, Mobilebackup2Error> {
809 plist::Value::from_file(path)?
810 .into_dictionary()
811 .ok_or_else(|| {
812 Mobilebackup2Error::Protocol(format!(
813 "expected plist dictionary in backup metadata file {}",
814 path.display()
815 ))
816 })
817}
818
819#[derive(Serialize)]
820#[serde(rename_all = "PascalCase")]
821struct HelloRequest {
822 message_name: &'static str,
823 supported_protocol_versions: Vec<f64>,
824}
825
826#[derive(Serialize)]
827#[serde(rename_all = "PascalCase")]
828struct BackupRequest<'a> {
829 message_name: &'static str,
830 target_identifier: &'a str,
831}
832
833#[derive(Serialize)]
834#[serde(rename_all = "PascalCase")]
835struct RestoreRequestOptions {
836 restore_should_reboot: bool,
837 restore_dont_copy_backup: bool,
838 restore_preserve_settings: bool,
839 restore_system_files: bool,
840 remove_items_not_restored: bool,
841}
842
843#[derive(Serialize)]
844#[serde(rename_all = "PascalCase")]
845struct RestoreRequest<'a> {
846 message_name: &'static str,
847 target_identifier: &'a str,
848 source_identifier: &'a str,
849 #[serde(skip_serializing_if = "Option::is_none")]
850 password: Option<&'a str>,
851 options: RestoreRequestOptions,
852}
853
854#[derive(Serialize)]
855#[serde(rename_all = "PascalCase")]
856struct ChangePasswordRequest<'a> {
857 message_name: &'static str,
858 target_identifier: &'a str,
859 #[serde(skip_serializing_if = "Option::is_none")]
860 old_password: Option<&'a str>,
861 #[serde(skip_serializing_if = "Option::is_none")]
862 new_password: Option<&'a str>,
863}
864
865#[derive(Serialize)]
866#[serde(rename_all = "PascalCase")]
867struct InfoRequest<'a> {
868 message_name: &'static str,
869 target_identifier: &'a str,
870 #[serde(skip_serializing_if = "Option::is_none")]
871 source_identifier: Option<&'a str>,
872}
873
874#[derive(Serialize)]
875#[serde(rename_all = "PascalCase")]
876struct ListRequest<'a> {
877 message_name: &'static str,
878 target_identifier: &'a str,
879 source_identifier: &'a str,
880}
881
882fn generate_backup_uuid() -> String {
885 uuid::Uuid::new_v4().to_string().to_uppercase()
886}
887
888fn sanitize_relative_path(path: &str) -> Result<PathBuf, Mobilebackup2Error> {
889 let mut clean = PathBuf::new();
890 for component in Path::new(path).components() {
891 match component {
892 Component::Normal(part) => clean.push(part),
893 Component::CurDir => {}
894 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
895 return Err(Mobilebackup2Error::Protocol(format!(
896 "backup path escapes backup root: {path}"
897 )));
898 }
899 }
900 }
901
902 Ok(clean)
903}
904
905fn resolve_relative_path(
906 layout: &BackupDirectoryLayout,
907 rel: &str,
908) -> Result<PathBuf, Mobilebackup2Error> {
909 let clean = sanitize_relative_path(rel)?;
910 let prefixed_with_target = clean
911 .components()
912 .next()
913 .and_then(|component| match component {
914 Component::Normal(value) => value.to_str(),
915 _ => None,
916 })
917 == Some(layout.target_identifier.as_str());
918
919 Ok(if prefixed_with_target {
920 layout.root.join(clean)
921 } else {
922 layout.device_directory.join(clean)
923 })
924}
925
926fn copy_item(src: &Path, dst: &Path) -> Result<(), Mobilebackup2Error> {
927 if let Some(parent) = dst.parent() {
928 fs::create_dir_all(parent)?;
929 }
930
931 if src.is_dir() {
932 fs::create_dir_all(dst)?;
933 for entry in fs::read_dir(src)? {
934 let entry = entry?;
935 copy_item(&entry.path(), &dst.join(entry.file_name()))?;
936 }
937 } else {
938 fs::copy(src, dst)?;
939 }
940
941 Ok(())
942}
943
944fn contents_of_directory(path: &Path) -> Result<plist::Dictionary, Mobilebackup2Error> {
945 let mut entries = plist::Dictionary::new();
946 for entry in fs::read_dir(path)? {
947 let entry = entry?;
948 let metadata = entry.metadata()?;
949 let file_type = if metadata.is_dir() {
950 "DLFileTypeDirectory"
951 } else if metadata.is_file() {
952 "DLFileTypeRegular"
953 } else {
954 "DLFileTypeUnknown"
955 };
956 let modified = metadata.modified().unwrap_or_else(|err| {
957 tracing::debug!("cannot read mtime for {}: {err}", entry.path().display());
958 SystemTime::UNIX_EPOCH
959 });
960 entries.insert(
961 entry.file_name().to_string_lossy().into_owned(),
962 plist::Value::Dictionary(plist::Dictionary::from_iter([
963 (
964 "DLFileType".to_string(),
965 plist::Value::String(file_type.into()),
966 ),
967 (
968 "DLFileSize".to_string(),
969 plist::Value::Integer(metadata.len().into()),
970 ),
971 (
972 "DLFileModificationDate".to_string(),
973 plist::Value::Date(device_link_modification_date(modified)),
974 ),
975 ])),
976 );
977 }
978
979 Ok(entries)
980}
981
982fn device_link_modification_date(modified: SystemTime) -> plist::Date {
983 let modified = device_link_local_wall_clock(modified);
986 let shifted = modified
987 .checked_sub(APPLE_EPOCH_OFFSET)
988 .unwrap_or(SystemTime::UNIX_EPOCH);
989 plist::Date::from(shifted)
990}
991
992fn device_link_local_wall_clock(modified: SystemTime) -> SystemTime {
993 let utc = OffsetDateTime::from(modified);
994 let local_offset = UtcOffset::local_offset_at(utc).unwrap_or(UtcOffset::UTC);
995 let local_wall_clock = utc.to_offset(local_offset).replace_offset(UtcOffset::UTC);
996 local_wall_clock.into()
997}
998
999const MAX_PREFIXED_STRING_SIZE: usize = 64 * 1024;
1003
1004async fn read_prefixed_string<S>(stream: &mut S) -> Result<String, Mobilebackup2Error>
1005where
1006 S: AsyncRead + Unpin,
1007{
1008 let size = read_u32_be(stream).await? as usize;
1009 if size == 0 {
1010 return Ok(String::new());
1011 }
1012 if size > MAX_PREFIXED_STRING_SIZE {
1013 return Err(Mobilebackup2Error::Protocol(format!(
1014 "prefixed string too large: {size} bytes (max {MAX_PREFIXED_STRING_SIZE})"
1015 )));
1016 }
1017
1018 let mut buf = vec![0u8; size];
1019 stream.read_exact(&mut buf).await?;
1020 String::from_utf8(buf)
1021 .map_err(|err| Mobilebackup2Error::Protocol(format!("backup path was not utf-8: {err}")))
1022}
1023
1024async fn read_u32_be<S>(stream: &mut S) -> Result<u32, Mobilebackup2Error>
1025where
1026 S: AsyncRead + Unpin,
1027{
1028 let mut buf = [0u8; 4];
1029 stream.read_exact(&mut buf).await?;
1030 Ok(u32::from_be_bytes(buf))
1031}
1032
1033async fn write_prefixed_string<S>(stream: &mut S, value: &str) -> Result<(), Mobilebackup2Error>
1034where
1035 S: AsyncWrite + Unpin,
1036{
1037 stream
1038 .write_all(&(value.len() as u32).to_be_bytes())
1039 .await?;
1040 stream.write_all(value.as_bytes()).await?;
1041 Ok(())
1042}
1043
1044async fn write_transfer_frame<S>(
1045 stream: &mut S,
1046 code: u8,
1047 payload: &[u8],
1048) -> Result<(), Mobilebackup2Error>
1049where
1050 S: AsyncWrite + Unpin,
1051{
1052 stream
1053 .write_all(&((payload.len() as u32) + 1).to_be_bytes())
1054 .await?;
1055 stream.write_all(&[code]).await?;
1056 if !payload.is_empty() {
1057 stream.write_all(payload).await?;
1058 }
1059 Ok(())
1060}
1061
1062#[cfg(windows)]
1063fn available_space(path: &Path) -> Result<u64, Mobilebackup2Error> {
1064 use std::ffi::OsStr;
1065 use std::os::windows::ffi::OsStrExt;
1066
1067 #[link(name = "Kernel32")]
1068 extern "system" {
1069 fn GetDiskFreeSpaceExW(
1070 lpDirectoryName: *const u16,
1071 lpFreeBytesAvailableToCaller: *mut u64,
1072 lpTotalNumberOfBytes: *mut u64,
1073 lpTotalNumberOfFreeBytes: *mut u64,
1074 ) -> i32;
1075 }
1076
1077 let probe = if path.is_dir() {
1078 path.to_path_buf()
1079 } else {
1080 path.parent().unwrap_or(path).to_path_buf()
1081 };
1082 let wide: Vec<u16> = OsStr::new(probe.as_os_str())
1083 .encode_wide()
1084 .chain(std::iter::once(0))
1085 .collect();
1086 let mut available = 0u64;
1087 let ok = unsafe {
1088 GetDiskFreeSpaceExW(
1089 wide.as_ptr(),
1090 &mut available,
1091 std::ptr::null_mut(),
1092 std::ptr::null_mut(),
1093 )
1094 };
1095 if ok == 0 {
1096 return Err(Mobilebackup2Error::Io(std::io::Error::last_os_error()));
1097 }
1098 Ok(available)
1099}
1100
1101#[cfg(unix)]
1102fn available_space(path: &Path) -> Result<u64, Mobilebackup2Error> {
1103 use std::ffi::CString;
1104 use std::os::unix::ffi::OsStrExt;
1105
1106 let probe = if path.is_dir() {
1107 path.to_path_buf()
1108 } else {
1109 path.parent().unwrap_or(path).to_path_buf()
1110 };
1111 let c_path = CString::new(probe.as_os_str().as_bytes()).map_err(|e| {
1112 Mobilebackup2Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
1113 })?;
1114
1115 unsafe {
1116 let mut stat: libc::statvfs = std::mem::zeroed();
1117 if libc::statvfs(c_path.as_ptr(), &mut stat) != 0 {
1118 return Err(Mobilebackup2Error::Io(std::io::Error::last_os_error()));
1119 }
1120 Ok(stat.f_bavail as u64 * stat.f_frsize as u64)
1122 }
1123}
1124
1125fn plist_number_to_u64(value: &plist::Value) -> Option<u64> {
1126 match value {
1127 plist::Value::Integer(value) => value.as_unsigned(),
1128 plist::Value::Real(value) => Some(*value as u64),
1129 _ => None,
1130 }
1131}
1132
1133fn plist_number_to_f64(value: &plist::Value) -> Option<f64> {
1134 match value {
1135 plist::Value::Integer(value) => value.as_unsigned().map(|value| value as f64),
1136 plist::Value::Real(value) => Some(*value),
1137 _ => None,
1138 }
1139}
1140
1141fn plist_value_to_bool(value: &plist::Value) -> Option<bool> {
1142 match value {
1143 plist::Value::Boolean(value) => Some(*value),
1144 plist::Value::Integer(value) => value
1145 .as_signed()
1146 .map(|value| value != 0)
1147 .or_else(|| value.as_unsigned().map(|value| value != 0)),
1148 _ => None,
1149 }
1150}
1151
1152fn insert_file_failure(failures: &mut plist::Dictionary, rel: &str, err: &std::io::Error) {
1153 let mut failure = plist::Dictionary::from_iter([(
1154 "DLFileErrorString".to_string(),
1155 plist::Value::String(err.to_string()),
1156 )]);
1157 if let Some(code) = file_error_code_from_os_error(err) {
1158 failure.insert(
1159 "DLFileErrorCode".to_string(),
1160 plist::Value::Integer(code.into()),
1161 );
1162 }
1163 failures.insert(rel.to_string(), plist::Value::Dictionary(failure));
1164}
1165
1166fn file_error_code_from_os_error(error: &std::io::Error) -> Option<i64> {
1167 match error.raw_os_error()? {
1168 2 => Some(-6),
1169 17 => Some(-7),
1170 20 => Some(-8),
1171 21 => Some(-9),
1172 62 => Some(-10),
1173 5 => Some(-11),
1174 28 => Some(-15),
1175 _ => None,
1176 }
1177}
1178
1179fn should_suppress_disconnect_error(error: &DeviceLinkError) -> bool {
1180 matches!(
1181 error,
1182 DeviceLinkError::Io(io_error)
1183 if matches!(
1184 io_error.kind(),
1185 ErrorKind::BrokenPipe
1186 | ErrorKind::ConnectionAborted
1187 | ErrorKind::ConnectionReset
1188 | ErrorKind::NotConnected
1189 | ErrorKind::UnexpectedEof
1190 )
1191 )
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use std::io::ErrorKind;
1197
1198 use super::*;
1199
1200 #[test]
1201 fn initialize_backup_directory_creates_expected_seed_files() {
1202 let root =
1203 std::env::temp_dir().join(format!("ios-core-backup2-layout-{}", std::process::id()));
1204 if root.exists() {
1205 std::fs::remove_dir_all(&root).unwrap();
1206 }
1207 std::fs::create_dir_all(&root).unwrap();
1208
1209 let info = plist::Dictionary::from_iter([(
1210 "Device Name".to_string(),
1211 plist::Value::String("Example".into()),
1212 )]);
1213 let layout = initialize_backup_directory(&root, "device-id", &info, true).unwrap();
1214
1215 assert_eq!(layout.device_directory, root.join("device-id"));
1216 assert!(layout.device_directory.join("Info.plist").exists());
1217 assert!(layout.device_directory.join("Status.plist").exists());
1218 assert!(layout.device_directory.join("Manifest.plist").exists());
1219
1220 std::fs::remove_dir_all(root).unwrap();
1221 }
1222
1223 #[test]
1224 fn resolve_relative_path_accepts_plain_and_prefixed_paths() {
1225 let layout = BackupDirectoryLayout {
1226 root: PathBuf::from("backup-root"),
1227 device_directory: PathBuf::from("backup-root/device-id"),
1228 target_identifier: "device-id".into(),
1229 };
1230
1231 assert_eq!(
1232 resolve_relative_path(&layout, "Manifest.db").unwrap(),
1233 PathBuf::from("backup-root/device-id/Manifest.db")
1234 );
1235 assert_eq!(
1236 resolve_relative_path(&layout, "device-id/Manifest.db").unwrap(),
1237 PathBuf::from("backup-root/device-id/Manifest.db")
1238 );
1239 }
1240
1241 #[test]
1242 fn resolve_relative_path_rejects_parent_escapes() {
1243 let layout = BackupDirectoryLayout {
1244 root: PathBuf::from("backup-root"),
1245 device_directory: PathBuf::from("backup-root/device-id"),
1246 target_identifier: "device-id".into(),
1247 };
1248
1249 let err = resolve_relative_path(&layout, "../outside").unwrap_err();
1250 assert!(err.to_string().contains("escapes"));
1251 }
1252
1253 #[test]
1254 fn generated_backup_uuid_is_uppercase_v4() {
1255 let generated = generate_backup_uuid();
1256 let parsed = uuid::Uuid::parse_str(&generated).expect("status UUID should be parseable");
1257
1258 assert_eq!(generated, generated.to_uppercase());
1259 assert_eq!(parsed.get_version_num(), 4);
1260 }
1261
1262 #[test]
1263 fn backup_is_encrypted_reads_manifest_flag() {
1264 let root = std::env::temp_dir().join(format!(
1265 "ios-core-backup2-encryption-{}",
1266 std::process::id()
1267 ));
1268 let device_dir = root.join("device-id");
1269 if root.exists() {
1270 std::fs::remove_dir_all(&root).unwrap();
1271 }
1272 std::fs::create_dir_all(&device_dir).unwrap();
1273 std::fs::write(device_dir.join("Info.plist"), b"info").unwrap();
1274 plist::to_file_xml(
1275 device_dir.join("Manifest.plist"),
1276 &plist::Value::Dictionary(plist::Dictionary::from_iter([(
1277 "IsEncrypted".to_string(),
1278 plist::Value::Boolean(true),
1279 )])),
1280 )
1281 .unwrap();
1282 std::fs::write(device_dir.join("Status.plist"), b"status").unwrap();
1283
1284 assert!(backup_is_encrypted(&root, "device-id").unwrap());
1285
1286 std::fs::remove_dir_all(root).unwrap();
1287 }
1288
1289 #[test]
1290 fn device_link_modification_date_preserves_subsecond_apple_epoch_timestamp() {
1291 let modified = SystemTime::UNIX_EPOCH
1292 + APPLE_EPOCH_OFFSET
1293 + Duration::from_secs(123)
1294 + Duration::from_millis(900);
1295 let encoded = device_link_modification_date(modified);
1296 let shifted: SystemTime = encoded.into();
1297 let expected = device_link_local_wall_clock(modified)
1298 .checked_sub(APPLE_EPOCH_OFFSET)
1299 .unwrap_or(SystemTime::UNIX_EPOCH);
1300
1301 assert_eq!(shifted, expected);
1302 }
1303
1304 #[test]
1305 fn suppresses_expected_disconnect_transport_errors() {
1306 for kind in [
1307 ErrorKind::BrokenPipe,
1308 ErrorKind::ConnectionAborted,
1309 ErrorKind::ConnectionReset,
1310 ErrorKind::NotConnected,
1311 ErrorKind::UnexpectedEof,
1312 ] {
1313 assert!(should_suppress_disconnect_error(&DeviceLinkError::Io(
1314 std::io::Error::from(kind),
1315 )));
1316 }
1317 }
1318
1319 #[test]
1320 fn keeps_unexpected_disconnect_errors_visible() {
1321 assert!(!should_suppress_disconnect_error(&DeviceLinkError::Io(
1322 std::io::Error::from(ErrorKind::Other),
1323 )));
1324 assert!(!should_suppress_disconnect_error(
1325 &DeviceLinkError::Protocol("disconnect protocol mismatch".into(),)
1326 ));
1327 }
1328
1329 #[tokio::test]
1330 async fn read_prefixed_string_rejects_oversized_allocation() {
1331 let size = (MAX_PREFIXED_STRING_SIZE as u32) + 1;
1333 let data = size.to_be_bytes();
1334 let mut cursor = std::io::Cursor::new(data.to_vec());
1335 let err = read_prefixed_string(&mut cursor).await.unwrap_err();
1336 assert!(
1337 err.to_string().contains("too large"),
1338 "expected size guard error, got: {err}"
1339 );
1340 }
1341
1342 #[tokio::test]
1343 async fn read_prefixed_string_accepts_normal_size() {
1344 let payload = b"hello";
1345 let size = (payload.len() as u32).to_be_bytes();
1346 let mut data = size.to_vec();
1347 data.extend_from_slice(payload);
1348 let mut cursor = std::io::Cursor::new(data);
1349 let result = read_prefixed_string(&mut cursor).await.unwrap();
1350 assert_eq!(result, "hello");
1351 }
1352}