Skip to main content

ios_core/services/backup2/
mod.rs

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; // Transfer completed successfully
19const FILE_TRANSFER_CODE_LOCAL_ERROR: u8 = 0x06; // Local (host) file I/O error
20const FILE_TRANSFER_CODE_FILE_DATA: u8 = 0x0c; // Payload contains file data chunk
21const FILE_TRANSFER_CODE_REMOTE_ERROR: u8 = 0x0b; // Remote (device) reported an error
22const BULK_OPERATION_ERROR: i64 = -13;
23const EMPTY_PARAMETER_STRING: &str = "___EmptyParameterString___";
24const DOWNLOAD_CHUNK_SIZE: usize = 8 * 1024 * 1024;
25// 978_307_200 seconds = 2001-01-01T00:00:00Z Unix timestamp
26// This is the Apple Core Data / NSDate epoch offset (seconds between Unix epoch and Apple epoch)
27const 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
882// A fresh random UUID is generated for each backup session.
883// Backup UUIDs are not required to be deterministic across sessions.
884fn 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    // pymobiledevice3 encodes directory mtimes as local wall-clock time relative to Apple's
984    // 2001 epoch, then serializes that wall-clock timestamp as if it were UTC.
985    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
999/// Maximum size for a length-prefixed string (64 KiB). Device names and file
1000/// paths are never anywhere near this limit; the guard protects against
1001/// corrupted or malicious size fields causing unbounded allocation.
1002const 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        // Available space = available blocks * fragment size
1121        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        // Craft a size field that exceeds MAX_PREFIXED_STRING_SIZE
1332        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}