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