Skip to main content

ios_core/services/backup2/
mod.rs

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