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