diff --git a/Cargo.lock b/Cargo.lock index 78ededd..08a3a9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,17 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async_zip" version = "0.0.18" @@ -1351,6 +1362,7 @@ version = "0.1.60" dependencies = [ "aes", "async-stream", + "async-trait", "async_zip", "base64 0.22.1", "bytes", diff --git a/idevice/Cargo.toml b/idevice/Cargo.toml index 3327d63..a94416f 100644 --- a/idevice/Cargo.toml +++ b/idevice/Cargo.toml @@ -44,6 +44,7 @@ reqwest = { version = "0.13", features = [ ], optional = true, default-features = false } futures = { version = "0.3", optional = true } async-stream = { version = "0.3.6", optional = true } +async-trait = { version = "0.1", optional = true } sha2 = { version = "0.10", optional = true, features = ["oid"] } @@ -161,7 +162,7 @@ springboardservices = [] misagent = [] mobile_image_mounter = ["dep:sha2"] mobileactivationd = ["_reqwest"] -mobilebackup2 = [] +mobilebackup2 = ["_serde_json", "dep:async-trait"] wda = ["_serde_json", "tokio/time", "tokio/net"] notification_proxy = [ "tokio/macros", diff --git a/idevice/src/services/mobilebackup2.rs b/idevice/src/services/mobilebackup2.rs index 1ac08cd..a96d615 100644 --- a/idevice/src/services/mobilebackup2.rs +++ b/idevice/src/services/mobilebackup2.rs @@ -3,6 +3,12 @@ //! Provides functionality for interacting with the mobilebackup2 service on iOS devices, //! which allows creating, restoring, and managing device backups. +mod atomic_fs; +mod journal; +mod paths; +mod store; +mod transaction; + use plist::Dictionary; use std::future::Future; use std::io::{Read, Write}; @@ -12,6 +18,8 @@ use std::time::SystemTime; use tokio::io::AsyncReadExt; use tracing::{debug, warn}; +use self::paths::BackupPath; +use self::store::{BackupStore, DelegateBackupStore}; use crate::{Idevice, IdeviceError, IdeviceService, obf}; /// DeviceLink message codes used in MobileBackup2 binary streams @@ -955,7 +963,8 @@ impl MobileBackup2Client { ) .await?; - self.process_dl_loop(backup_root, delegate).await + let mut store = DelegateBackupStore::new(backup_root, delegate); + self.process_dl_loop(&mut store).await } /// High-level API: Restore from a local backup directory using DeviceLink file exchange @@ -993,7 +1002,8 @@ impl MobileBackup2Client { ) .await?; - self.process_dl_loop(backup_root, delegate).await + let mut store = DelegateBackupStore::new(backup_root, delegate); + self.process_dl_loop(&mut store).await } /// Processes the DeviceLink message loop used by backup, restore, and other operations. @@ -1002,8 +1012,7 @@ impl MobileBackup2Client { /// status) or `DLMessageDisconnect` is received. async fn process_dl_loop( &mut self, - host_dir: &Path, - delegate: &dyn BackupDelegate, + store: &mut dyn BackupStore, ) -> Result, IdeviceError> { let mut overall_progress: f64 = -1.0; loop { @@ -1030,15 +1039,14 @@ impl MobileBackup2Client { match tag.as_str() { "DLMessageDownloadFiles" => { - self.handle_download_files(&value, host_dir, delegate) - .await?; + self.handle_download_files(&value, store).await?; } "DLMessageUploadFiles" => { - self.handle_upload_files(&value, host_dir, delegate, overall_progress) + self.handle_upload_files(&value, store, overall_progress) .await?; } "DLMessageGetFreeDiskSpace" => { - let freespace = delegate.get_free_disk_space(host_dir); + let freespace = store.get_free_disk_space(); self.send_status_response( 0, None, @@ -1047,7 +1055,7 @@ impl MobileBackup2Client { .await?; } "DLContentsOfDirectory" => { - let listing = Self::list_directory_contents(&value, host_dir, delegate).await; + let listing = Self::list_directory_contents(&value, store).await; self.send_status_response(0, None, Some(listing)).await?; } "DLMessageCreateDirectory" => { @@ -1057,12 +1065,11 @@ impl MobileBackup2Client { debug!("Creating directory: {dir}"); } - let status = - Self::create_directory_from_message(&value, host_dir, delegate).await; + let status = Self::create_directory_from_message(&value, store).await; self.send_status_response(status, None, None).await?; } "DLMessageMoveFiles" | "DLMessageMoveItems" => { - let status = Self::move_files_from_message(&value, host_dir, delegate).await; + let status = Self::move_files_from_message(&value, store).await; self.send_status_response( status, None, @@ -1071,7 +1078,7 @@ impl MobileBackup2Client { .await?; } "DLMessageRemoveFiles" | "DLMessageRemoveItems" => { - let status = Self::remove_files_from_message(&value, host_dir, delegate).await; + let status = Self::remove_files_from_message(&value, store).await; self.send_status_response( status, None, @@ -1080,7 +1087,7 @@ impl MobileBackup2Client { .await?; } "DLMessageCopyItem" => { - let status = Self::copy_item_from_message(&value, host_dir, delegate).await; + let status = Self::copy_item_from_message(&value, store).await; self.send_status_response( status, None, @@ -1111,8 +1118,7 @@ impl MobileBackup2Client { async fn handle_download_files( &mut self, dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, + store: &mut dyn BackupStore, ) -> Result<(), IdeviceError> { let mut err_any = false; if let plist::Value::Array(arr) = dl_value @@ -1122,7 +1128,15 @@ impl MobileBackup2Client { for pv in files { if let Some(path) = pv.as_string() { debug!("Device requested file: {path}"); - if let Err(e) = self.send_single_file(host_dir, path, delegate).await { + let backup_path = match BackupPath::parse_item(path) { + Ok(path) => path, + Err(e) => { + warn!("Invalid requested file path {path}: {e}"); + err_any = true; + continue; + } + }; + if let Err(e) = self.send_single_file(&backup_path, store).await { warn!("Failed to send file {path}: {e}"); err_any = true; } @@ -1146,17 +1160,16 @@ impl MobileBackup2Client { async fn send_single_file( &mut self, - host_dir: &Path, - rel_path: &str, - delegate: &dyn BackupDelegate, + rel_path: &BackupPath, + store: &mut dyn BackupStore, ) -> Result<(), IdeviceError> { - let full = host_dir.join(rel_path); - let path_bytes = rel_path.as_bytes().to_vec(); + let path_string = rel_path.as_relative_path().to_string_lossy().into_owned(); + let path_bytes = path_string.as_bytes().to_vec(); let nlen = (path_bytes.len() as u32).to_be_bytes(); self.idevice.send_raw(&nlen).await?; self.idevice.send_raw(&path_bytes).await?; - let mut f = match delegate.open_file_read(&full).await { + let mut f = match store.open_file_read(rel_path).await { Ok(f) => f, Err(e) => { // send error @@ -1194,8 +1207,7 @@ impl MobileBackup2Client { async fn handle_upload_files( &mut self, dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, + store: &mut dyn BackupStore, overall_progress: f64, ) -> Result<(), IdeviceError> { let mut file_count: u32 = 0; @@ -1224,11 +1236,7 @@ impl MobileBackup2Client { break; } let fname = self.read_exact_string(flen as usize).await?; - - let dst = host_dir.join(&fname); - if let Some(parent) = dst.parent() { - let _ = delegate.create_dir_all(parent).await; - } + let backup_path = BackupPath::parse_item(&fname)?; // Read first code+data block let mut nlen = self.read_be_u32().await?; @@ -1237,15 +1245,19 @@ impl MobileBackup2Client { } let mut code = self.read_one().await?; - // Remove existing file and create new one - let _ = delegate.remove(&dst).await; - let mut file = delegate.create_file_write(&dst).await?; + if code != DL_CODE_FILE_DATA && code != DL_CODE_SUCCESS { + let _ = self.read_exact((nlen - 1) as usize).await?; + continue; + } + + let mut replacement = store.begin_replace(&backup_path).await?; // Receive file data blocks while code == DL_CODE_FILE_DATA { let block_size = (nlen - 1) as usize; let data = self.read_exact(block_size).await?; - file.write_all(&data) + replacement + .write_all(&data) .map_err(|e| IdeviceError::InternalError(e.to_string()))?; bytes_done += block_size as u64; @@ -1258,9 +1270,16 @@ impl MobileBackup2Client { } } + let success = nlen > 0 && code == DL_CODE_SUCCESS; + if success { + replacement.finish().await?; + } else { + replacement.abort().await?; + } + file_count += 1; - delegate.on_file_received(&fname, file_count); - delegate.on_progress(bytes_done, bytes_total, overall_progress); + store.on_file_received(&backup_path, file_count); + store.on_progress(bytes_done, bytes_total, overall_progress); // Handle trailing error/status message if nlen > 0 && code != DL_CODE_FILE_DATA && code != DL_CODE_SUCCESS { @@ -1295,15 +1314,17 @@ impl MobileBackup2Client { async fn create_directory_from_message( dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, + store: &mut dyn BackupStore, ) -> i64 { if let plist::Value::Array(arr) = dl_value && arr.len() >= 2 && let Some(plist::Value::String(dir)) = arr.get(1) { - let path = host_dir.join(dir); - return match delegate.create_dir_all(&path).await { + let path = match BackupPath::parse_item(dir) { + Ok(path) => path, + Err(_) => return -1, + }; + return match store.create_dir_all(&path).await { Ok(_) => 0, Err(_) => -1, }; @@ -1311,23 +1332,22 @@ impl MobileBackup2Client { -1 } - async fn move_files_from_message( - dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, - ) -> i64 { + async fn move_files_from_message(dl_value: &plist::Value, store: &mut dyn BackupStore) -> i64 { if let plist::Value::Array(arr) = dl_value && arr.len() >= 2 && let Some(plist::Value::Dictionary(map)) = arr.get(1) { for (from, to_v) in map.iter() { if let Some(to) = to_v.as_string() { - let old = host_dir.join(from); - let newp = host_dir.join(to); - if let Some(parent) = newp.parent() { - let _ = delegate.create_dir_all(parent).await; - } - if delegate.rename(&old, &newp).await.is_err() { + let old = match BackupPath::parse_item(from) { + Ok(path) => path, + Err(_) => return -1, + }; + let newp = match BackupPath::parse_item(to) { + Ok(path) => path, + Err(_) => return -1, + }; + if store.rename(&old, &newp).await.is_err() { return -1; } } @@ -1339,8 +1359,7 @@ impl MobileBackup2Client { async fn remove_files_from_message( dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, + store: &mut dyn BackupStore, ) -> i64 { if let plist::Value::Array(arr) = dl_value && arr.len() >= 2 @@ -1348,8 +1367,11 @@ impl MobileBackup2Client { { for it in items { if let Some(p) = it.as_string() { - let path = host_dir.join(p); - if delegate.exists(&path).await && delegate.remove(&path).await.is_err() { + let path = match BackupPath::parse_item(p) { + Ok(path) => path, + Err(_) => return -1, + }; + if store.exists(&path).await && store.remove(&path).await.is_err() { return -1; } } @@ -1359,22 +1381,21 @@ impl MobileBackup2Client { -1 } - async fn copy_item_from_message( - dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, - ) -> i64 { + async fn copy_item_from_message(dl_value: &plist::Value, store: &mut dyn BackupStore) -> i64 { if let plist::Value::Array(arr) = dl_value && arr.len() >= 3 && let (Some(plist::Value::String(src)), Some(plist::Value::String(dst))) = (arr.get(1), arr.get(2)) { - let from = host_dir.join(src); - let to = host_dir.join(dst); - if let Some(parent) = to.parent() { - let _ = delegate.create_dir_all(parent).await; - } - return match delegate.copy(&from, &to).await { + let from = match BackupPath::parse_item(src) { + Ok(path) => path, + Err(_) => return -1, + }; + let to = match BackupPath::parse_item(dst) { + Ok(path) => path, + Err(_) => return -1, + }; + return match store.copy(&from, &to).await { Ok(_) => 0, Err(_) => -1, }; @@ -1464,7 +1485,8 @@ impl MobileBackup2Client { }); self.send_device_link_message("Info", Some(dict)).await?; - match self.process_dl_loop(backup_root, delegate).await? { + let mut store = DelegateBackupStore::new(backup_root, delegate); + match self.process_dl_loop(&mut store).await? { Some(res) => Ok(res), None => Err(IdeviceError::UnexpectedResponse( "info_from_path DL loop returned no response".into(), @@ -1492,7 +1514,8 @@ impl MobileBackup2Client { }); self.send_device_link_message("List", Some(dict)).await?; - match self.process_dl_loop(backup_root, delegate).await? { + let mut store = DelegateBackupStore::new(backup_root, delegate); + match self.process_dl_loop(&mut store).await? { Some(res) => Ok(res), None => Err(IdeviceError::UnexpectedResponse( "list_from_path DL loop returned no response".into(), @@ -1526,7 +1549,8 @@ impl MobileBackup2Client { let opts = password.map(|pw| crate::plist!(dict { "Password": pw })); self.send_request("Unback", target_udid, Some(source), opts) .await?; - let _ = self.process_dl_loop(backup_root, delegate).await?; + let mut store = DelegateBackupStore::new(backup_root, delegate); + let _ = self.process_dl_loop(&mut store).await?; Ok(()) } @@ -1554,7 +1578,8 @@ impl MobileBackup2Client { "Password":? password, }); self.send_device_link_message("Extract", Some(dict)).await?; - let _ = self.process_dl_loop(backup_root, delegate).await?; + let mut store = DelegateBackupStore::new(backup_root, delegate); + let _ = self.process_dl_loop(&mut store).await?; Ok(()) } @@ -1575,7 +1600,8 @@ impl MobileBackup2Client { }); self.send_device_link_message("ChangePassword", Some(dict)) .await?; - let _ = self.process_dl_loop(backup_root, delegate).await?; + let mut store = DelegateBackupStore::new(backup_root, delegate); + let _ = self.process_dl_loop(&mut store).await?; Ok(()) } @@ -1592,7 +1618,8 @@ impl MobileBackup2Client { }); self.send_device_link_message("EraseDevice", Some(dict)) .await?; - let _ = self.process_dl_loop(backup_root, delegate).await?; + let mut store = DelegateBackupStore::new(backup_root, delegate); + let _ = self.process_dl_loop(&mut store).await?; Ok(()) } @@ -1627,8 +1654,7 @@ impl MobileBackup2Client { /// Lists the contents of a directory referenced in a `DLContentsOfDirectory` message. async fn list_directory_contents( dl_value: &plist::Value, - host_dir: &Path, - delegate: &dyn BackupDelegate, + store: &mut dyn BackupStore, ) -> plist::Value { let mut dirlist = Dictionary::new(); @@ -1641,8 +1667,11 @@ impl MobileBackup2Client { return plist::Value::Dictionary(dirlist); }; - let full_path = host_dir.join(&rel_path); - if let Ok(entries) = delegate.list_dir(&full_path).await { + let path = match BackupPath::parse(&rel_path) { + Ok(path) => path, + Err(_) => return plist::Value::Dictionary(dirlist), + }; + if let Ok(entries) = store.list_dir(&path).await { for entry in entries { let mut fdict = Dictionary::new(); let ftype = if entry.is_dir { diff --git a/idevice/src/services/mobilebackup2/atomic_fs.rs b/idevice/src/services/mobilebackup2/atomic_fs.rs new file mode 100644 index 0000000..8122f94 --- /dev/null +++ b/idevice/src/services/mobilebackup2/atomic_fs.rs @@ -0,0 +1,21 @@ +#![allow(dead_code)] + +use std::path::Path; + +use super::transaction::BackupTransactionError; + +/// Replace `to` with `from` atomically on the same filesystem. +/// +/// Contract: +/// - `from` and `to` must be on the same filesystem +/// - `from` must be a fully written and flushed file +/// - on success, `to` contains the previous contents of `from` +/// - on success, `from` no longer exists +/// - on failure, callers must use journal recovery to decide whether to retry, +/// roll back, or clean up leftovers +/// +/// This is intentionally an explicit platform boundary. POSIX and Windows have +/// different replacement semantics, especially when `to` already exists. +pub(crate) fn atomic_replace_file(_from: &Path, _to: &Path) -> Result<(), BackupTransactionError> { + unimplemented!("atomic same-filesystem replace is intentionally left as a platform boundary") +} diff --git a/idevice/src/services/mobilebackup2/journal.rs b/idevice/src/services/mobilebackup2/journal.rs new file mode 100644 index 0000000..9426766 --- /dev/null +++ b/idevice/src/services/mobilebackup2/journal.rs @@ -0,0 +1,243 @@ +#![allow(dead_code)] + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::marker::PhantomData; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::IdeviceError; + +const JOURNAL_MAGIC: &str = "# idevice mobilebackup2 journal v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SequencedRecord { + pub(crate) seq: u64, + #[serde(flatten)] + pub(crate) record: T, +} + +#[derive(Debug)] +pub(crate) struct JsonLineJournal { + path: PathBuf, + file: File, + next_seq: u64, + _marker: PhantomData, +} + +impl JsonLineJournal +where + T: Serialize + DeserializeOwned, +{ + pub(crate) fn create(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let exists = path.exists(); + if exists { + let valid_len = Self::valid_prefix_len(path)?; + OpenOptions::new() + .write(true) + .open(path)? + .set_len(valid_len)?; + } + + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + if !exists || file.metadata()?.len() == 0 { + writeln!(file, "{JOURNAL_MAGIC}")?; + file.sync_data()?; + } + + let next_seq = Self::replay(path)?.last().map_or(1, |entry| entry.seq + 1); + Ok(Self { + path: path.to_path_buf(), + file, + next_seq, + _marker: PhantomData, + }) + } + + pub(crate) fn append(&mut self, record: &T) -> Result { + let seq = self.next_seq; + let line = serde_json::to_string(&SequencedRecord { seq, record })?; + writeln!(self.file, "{line}")?; + self.file.sync_data()?; + self.next_seq += 1; + Ok(seq) + } + + pub(crate) fn replay(path: &Path) -> Result>, IdeviceError> { + if !path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(path)?; + let mut records = Vec::new(); + + for (idx, line) in content.split_inclusive('\n').enumerate() { + if !line.ends_with('\n') { + break; + } + + let line = line.trim_end_matches(['\r', '\n']); + if idx == 0 && line == JOURNAL_MAGIC { + continue; + } + if line.trim().is_empty() { + continue; + } + + match serde_json::from_str::>(&line) { + Ok(record) => records.push(record), + Err(err) if err.is_eof() => break, + Err(err) => return Err(err.into()), + } + } + + Ok(records) + } + + fn valid_prefix_len(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let mut offset = 0usize; + + for (idx, line) in content.split_inclusive('\n').enumerate() { + if !line.ends_with('\n') { + break; + } + + let trimmed = line.trim_end_matches(['\r', '\n']); + if idx == 0 && trimmed == JOURNAL_MAGIC { + offset += line.len(); + continue; + } + if trimmed.trim().is_empty() { + offset += line.len(); + continue; + } + + serde_json::from_str::>(trimmed)?; + offset += line.len(); + } + + Ok(offset as u64) + } + + #[allow(dead_code)] + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + enum TestRecord { + Begin { tx_id: String }, + CommitReady, + } + + fn temp_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "idevice-mobilebackup2-journal-{name}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn append_only_journal_replays_complete_records_and_ignores_partial_tail() { + let root = temp_dir("jsonl"); + let journal_path = root.join("journal.jsonl"); + let mut journal = JsonLineJournal::create(&journal_path).unwrap(); + + journal + .append(&TestRecord::Begin { + tx_id: "tx-1".into(), + }) + .unwrap(); + journal.append(&TestRecord::CommitReady).unwrap(); + fs::OpenOptions::new() + .append(true) + .open(&journal_path) + .unwrap() + .write_all(b"{\"seq\":") + .unwrap(); + + let records = JsonLineJournal::::replay(&journal_path).unwrap(); + + assert_eq!( + records, + vec![ + SequencedRecord { + seq: 1, + record: TestRecord::Begin { + tx_id: "tx-1".into() + } + }, + SequencedRecord { + seq: 2, + record: TestRecord::CommitReady + } + ] + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn create_truncates_partial_tail_before_appending_next_record() { + let root = temp_dir("truncate"); + let journal_path = root.join("journal.jsonl"); + let mut journal = JsonLineJournal::create(&journal_path).unwrap(); + journal + .append(&TestRecord::Begin { + tx_id: "tx-1".into(), + }) + .unwrap(); + fs::OpenOptions::new() + .append(true) + .open(&journal_path) + .unwrap() + .write_all(b"{\"seq\":") + .unwrap(); + + let mut reopened = JsonLineJournal::create(&journal_path).unwrap(); + reopened.append(&TestRecord::CommitReady).unwrap(); + + let records = JsonLineJournal::::replay(&journal_path).unwrap(); + + assert_eq!( + records, + vec![ + SequencedRecord { + seq: 1, + record: TestRecord::Begin { + tx_id: "tx-1".into() + } + }, + SequencedRecord { + seq: 2, + record: TestRecord::CommitReady + } + ] + ); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/idevice/src/services/mobilebackup2/paths.rs b/idevice/src/services/mobilebackup2/paths.rs new file mode 100644 index 0000000..fc1f2f3 --- /dev/null +++ b/idevice/src/services/mobilebackup2/paths.rs @@ -0,0 +1,275 @@ +#![allow(dead_code)] + +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::transaction::{BackupTransactionError, JOURNAL_DIR_NAME}; + +/// Monotonic operation id inside one backup transaction. +/// +/// This id is not meaningful outside `.idevice-journal/journal.jsonl`. +/// It lets later records like `old_saved`, `installed`, and `undone` refer +/// back to the operation that was prepared earlier in the same transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub(crate) struct OpId(pub(crate) u64); + +/// Identifier for one rollback-journal transaction. +/// +/// The value is written in the `begin` record and is primarily diagnostic: it +/// helps humans correlate log lines with one attempted MobileBackup2 operation. +/// It is not an Apple backup identifier and is not stored in backup manifests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TxId(pub(crate) String); + +/// Relative path to an item in the visible iTunes-compatible backup tree. +/// +/// `BackupPath` represents a path requested by the MobileBackup2 protocol, such +/// as a backup blob, `Manifest.plist`, or `Status.plist`. The path is always +/// relative to the backup directory for one device/source identifier. +/// +/// Example: +/// +/// ```text +/// backup_root/ +/// 00008030-001C195E0E91802E/ <- backup directory +/// Manifest.plist <- BackupPath("Manifest.plist") +/// Status.plist <- BackupPath("Status.plist") +/// ab/cdef1234... <- BackupPath("ab/cdef1234...") +/// ``` +/// +/// Values usually originate from the device over MobileBackup2 messages. That +/// makes them external input. Constructors must reject absolute paths, Windows +/// prefixes, and parent-directory components before the path is joined onto the +/// host filesystem. +/// +/// The empty string is accepted and represents the backup directory itself. +/// This is needed for protocol messages that list the backup root. +/// +/// Rejected examples: +/// +/// ```text +/// /tmp/file +/// ../Manifest.plist +/// ab/../../outside +/// C:\Users\name\file +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct BackupPath(PathBuf); + +impl BackupPath { + /// Parse an untrusted MobileBackup2 path into a safe backup-relative path. + /// + /// Contract: + /// - accepts only paths that stay inside one visible backup directory + /// - rejects absolute paths, path prefixes, and `..` + /// - accepts the empty string as the backup root + /// - does not touch the filesystem + pub(crate) fn parse(input: &str) -> Result { + validate_relative_path(input, true).map(Self) + } + + /// Parse a path for operations that mutate or read a concrete backup item. + /// + /// Unlike `parse`, this rejects the backup root itself. Use `parse` only for + /// protocol requests where the root directory is a legitimate target, such + /// as directory listing. + pub(crate) fn parse_item(input: &str) -> Result { + validate_relative_path(input, false).map(Self) + } + + /// Join this validated path onto the visible backup directory. + /// + /// Example: + /// + /// ```text + /// BackupPath("ab/cdef").join_to_backup_dir("/Backups/UDID") + /// => /Backups/UDID/ab/cdef + /// ``` + pub(crate) fn join_to_backup_dir(&self, backup_dir: &Path) -> PathBuf { + backup_dir.join(&self.0) + } + + /// Return the path as stored in the journal. + pub(crate) fn as_relative_path(&self) -> &Path { + &self.0 + } +} + +/// Relative path to a file owned by `.idevice-journal/`. +/// +/// `JournalPath` points to transaction-private files used for rollback and +/// staging. These files are not part of the iTunes backup format and should not +/// be referenced by `Manifest.plist` or exposed to the device as backup content. +/// +/// Example: +/// +/// ```text +/// backup_root/ +/// 00008030-001C195E0E91802E/ +/// .idevice-journal/ <- journal root +/// journal.jsonl +/// tmp/00000001.tmp <- JournalPath("tmp/00000001.tmp") +/// old/00000001.old <- JournalPath("old/00000001.old") +/// ``` +/// +/// Paths are stored relative to `.idevice-journal/`, not as absolute host +/// paths. That keeps recovery possible if the whole backup directory is moved. +/// `tmp/` files contain newly received data before it is installed into the +/// visible backup tree. `old/` files contain previous versions of visible +/// backup files so rollback can restore the last committed backup. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct JournalPath(PathBuf); + +impl JournalPath { + /// Deterministic tmp path for an operation. + /// + /// This only constructs the journal-relative path. It does not create the + /// file or reserve the operation id. + pub(crate) fn tmp(op: OpId) -> Self { + Self(PathBuf::from(format!("tmp/{:016}.tmp", op.0))) + } + + /// Deterministic old-state path for an operation. + /// + /// The old-state file stores the previous contents of a visible backup path + /// before that path is replaced, removed, moved over, or copied over. + pub(crate) fn old(op: OpId) -> Self { + Self(PathBuf::from(format!("old/{:016}.old", op.0))) + } + + /// Parse a path read from `journal.jsonl`. + /// + /// Contract: + /// - accepts only paths under `.idevice-journal/tmp/` or `.idevice-journal/old/` + /// - rejects absolute paths, path prefixes, empty paths, `.`, and `..` + /// - does not touch the filesystem + pub(crate) fn parse_recorded(path: PathBuf) -> Result { + let path_string = path.to_string_lossy(); + let path = validate_relative_path(&path_string, false)?; + match path.components().next() { + Some(Component::Normal(prefix)) if prefix == "tmp" || prefix == "old" => Ok(Self(path)), + _ => Err(BackupTransactionError::InvalidJournalPath( + path_string.into_owned(), + )), + } + } + + /// Join this journal-relative path onto `.idevice-journal/`. + /// + /// Example: + /// + /// ```text + /// JournalPath("tmp/0000000000000001.tmp").join_to_journal_dir("/Backups/UDID/.idevice-journal") + /// => /Backups/UDID/.idevice-journal/tmp/0000000000000001.tmp + /// ``` + pub(crate) fn join_to_journal_dir(&self, journal_dir: &Path) -> PathBuf { + journal_dir.join(&self.0) + } + + /// Return the path as stored in the journal. + pub(crate) fn as_relative_path(&self) -> &Path { + &self.0 + } +} + +fn validate_relative_path( + input: &str, + allow_empty: bool, +) -> Result { + if input.is_empty() { + return if allow_empty { + Ok(PathBuf::new()) + } else { + Err(BackupTransactionError::InvalidBackupPath(input.into())) + }; + } + + if input.contains('\\') || input.contains('\0') { + return Err(BackupTransactionError::InvalidBackupPath(input.into())); + } + + let path = Path::new(input); + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => out.push(part), + Component::CurDir => { + return Err(BackupTransactionError::InvalidBackupPath(input.into())); + } + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(BackupTransactionError::InvalidBackupPath(input.into())); + } + } + } + + if out.as_os_str().is_empty() && !allow_empty { + return Err(BackupTransactionError::InvalidBackupPath(input.into())); + } + if matches!( + out.components().next(), + Some(Component::Normal(first)) if first == JOURNAL_DIR_NAME + ) { + return Err(BackupTransactionError::InvalidBackupPath(input.into())); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_path_accepts_relative_paths_and_empty_root() { + assert_eq!( + BackupPath::parse("ab/cdef") + .unwrap() + .as_relative_path() + .to_string_lossy(), + "ab/cdef" + ); + assert_eq!( + BackupPath::parse("").unwrap().as_relative_path(), + Path::new("") + ); + } + + #[test] + fn backup_item_path_rejects_root_paths() { + for input in ["", ".", "./"] { + assert!(BackupPath::parse_item(input).is_err(), "{input:?}"); + } + } + + #[test] + fn backup_path_rejects_escape_and_platform_ambiguous_paths() { + for input in [ + "/tmp/file", + "../Manifest.plist", + "ab/../../outside", + "C:\\Users\\name\\file", + "ab\\cdef", + "ab/\0/cdef", + ".", + "./", + ".idevice-journal", + ".idevice-journal/journal.jsonl", + ] { + assert!(BackupPath::parse(input).is_err(), "{input:?}"); + } + } + + #[test] + fn journal_path_accepts_only_tmp_and_old_relative_paths() { + assert_eq!( + JournalPath::parse_recorded(PathBuf::from("tmp/0000000000000001.tmp")) + .unwrap() + .as_relative_path() + .to_string_lossy(), + "tmp/0000000000000001.tmp" + ); + assert!(JournalPath::parse_recorded(PathBuf::from("Manifest.plist")).is_err()); + assert!(JournalPath::parse_recorded(PathBuf::from("../old/file")).is_err()); + } +} diff --git a/idevice/src/services/mobilebackup2/store.rs b/idevice/src/services/mobilebackup2/store.rs new file mode 100644 index 0000000..b5c9322 --- /dev/null +++ b/idevice/src/services/mobilebackup2/store.rs @@ -0,0 +1,182 @@ +#![allow(dead_code)] + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; + +use super::paths::BackupPath; +use super::{BackupDelegate, DirEntryInfo}; +use crate::IdeviceError; + +/// Staged replacement for one backup file. +/// +/// MobileBackup2 streams file data first and sends a success/error trailer +/// afterwards. The store API models that lifecycle explicitly: callers write +/// bytes into the replacement, then either `finish()` after a success trailer or +/// `abort()` after an error/cancel. +#[async_trait] +pub(crate) trait BackupFileReplacement: Write + Send { + /// Install the staged file into the visible backup tree. + async fn finish(self: Box) -> Result<(), IdeviceError>; + + /// Discard the staged file without installing it. + async fn abort(self: Box) -> Result<(), IdeviceError>; +} + +/// Host-side storage API used by the MobileBackup2 protocol loop. +/// +/// This is intentionally higher level than the legacy `BackupDelegate`: paths +/// are validated `BackupPath`s, and file writes use a replacement lifecycle +/// instead of exposing direct final-path truncation to protocol handlers. +#[async_trait] +pub(crate) trait BackupStore: Send { + /// Returns the available disk space in bytes for the backing storage. + fn get_free_disk_space(&self) -> u64; + + /// Open an existing backup file for reading. + async fn open_file_read(&self, path: &BackupPath) + -> Result, IdeviceError>; + + /// Begin replacing a visible backup file. + /// + /// Implementations may write directly to the final path or stage the data + /// elsewhere. Transactional implementations must not mutate the visible path + /// until `BackupFileReplacement::finish()`. + async fn begin_replace( + &mut self, + path: &BackupPath, + ) -> Result, IdeviceError>; + + async fn create_dir_all(&mut self, path: &BackupPath) -> Result<(), IdeviceError>; + async fn remove(&mut self, path: &BackupPath) -> Result<(), IdeviceError>; + async fn rename(&mut self, from: &BackupPath, to: &BackupPath) -> Result<(), IdeviceError>; + async fn copy(&mut self, src: &BackupPath, dst: &BackupPath) -> Result<(), IdeviceError>; + async fn exists(&self, path: &BackupPath) -> bool; + async fn is_dir(&self, path: &BackupPath) -> bool; + async fn list_dir(&self, path: &BackupPath) -> Result, IdeviceError>; + + fn on_file_received(&self, _path: &BackupPath, _file_count: u32) {} + fn on_progress(&self, _bytes_done: u64, _bytes_total: u64, _overall_progress: f64) {} +} + +/// Compatibility adapter for the existing delegate API. +/// +/// This keeps current public/FFI callers compiling while moving the protocol +/// loop to `BackupStore`. New transactional storage should implement +/// `BackupStore` directly instead of going through this adapter. +pub(crate) struct DelegateBackupStore<'a> { + root: PathBuf, + delegate: &'a dyn BackupDelegate, +} + +impl<'a> DelegateBackupStore<'a> { + pub(crate) fn new(root: &Path, delegate: &'a dyn BackupDelegate) -> Self { + Self { + root: root.to_path_buf(), + delegate, + } + } + + fn full_path(&self, path: &BackupPath) -> PathBuf { + path.join_to_backup_dir(&self.root) + } +} + +#[async_trait] +impl BackupStore for DelegateBackupStore<'_> { + fn get_free_disk_space(&self) -> u64 { + self.delegate.get_free_disk_space(&self.root) + } + + async fn open_file_read( + &self, + path: &BackupPath, + ) -> Result, IdeviceError> { + self.delegate.open_file_read(&self.full_path(path)).await + } + + async fn begin_replace( + &mut self, + path: &BackupPath, + ) -> Result, IdeviceError> { + let full = self.full_path(path); + if let Some(parent) = full.parent() { + self.delegate.create_dir_all(parent).await?; + } + let writer = self.delegate.create_file_write(&full).await?; + Ok(Box::new(DelegateFileReplacement { writer })) + } + + async fn create_dir_all(&mut self, path: &BackupPath) -> Result<(), IdeviceError> { + self.delegate.create_dir_all(&self.full_path(path)).await + } + + async fn remove(&mut self, path: &BackupPath) -> Result<(), IdeviceError> { + self.delegate.remove(&self.full_path(path)).await + } + + async fn rename(&mut self, from: &BackupPath, to: &BackupPath) -> Result<(), IdeviceError> { + let to_full = self.full_path(to); + if let Some(parent) = to_full.parent() { + self.delegate.create_dir_all(parent).await?; + } + self.delegate.rename(&self.full_path(from), &to_full).await + } + + async fn copy(&mut self, src: &BackupPath, dst: &BackupPath) -> Result<(), IdeviceError> { + let dst_full = self.full_path(dst); + if let Some(parent) = dst_full.parent() { + self.delegate.create_dir_all(parent).await?; + } + self.delegate.copy(&self.full_path(src), &dst_full).await + } + + async fn exists(&self, path: &BackupPath) -> bool { + self.delegate.exists(&self.full_path(path)).await + } + + async fn is_dir(&self, path: &BackupPath) -> bool { + self.delegate.is_dir(&self.full_path(path)).await + } + + async fn list_dir(&self, path: &BackupPath) -> Result, IdeviceError> { + self.delegate.list_dir(&self.full_path(path)).await + } + + fn on_file_received(&self, path: &BackupPath, file_count: u32) { + self.delegate + .on_file_received(&path.as_relative_path().to_string_lossy(), file_count); + } + + fn on_progress(&self, bytes_done: u64, bytes_total: u64, overall_progress: f64) { + self.delegate + .on_progress(bytes_done, bytes_total, overall_progress); + } +} + +struct DelegateFileReplacement { + writer: Box, +} + +impl Write for DelegateFileReplacement { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.writer.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.writer.flush() + } +} + +#[async_trait] +impl BackupFileReplacement for DelegateFileReplacement { + async fn finish(mut self: Box) -> Result<(), IdeviceError> { + self.flush() + .map_err(|e| IdeviceError::InternalError(e.to_string())) + } + + async fn abort(self: Box) -> Result<(), IdeviceError> { + Ok(()) + } +} diff --git a/idevice/src/services/mobilebackup2/transaction.rs b/idevice/src/services/mobilebackup2/transaction.rs new file mode 100644 index 0000000..8be701d --- /dev/null +++ b/idevice/src/services/mobilebackup2/transaction.rs @@ -0,0 +1,1230 @@ +#![allow(dead_code)] + +//! Rollback journal for MobileBackup2 host-side backup mutations. +//! +//! MobileBackup2 asks the host to create, replace, move, copy, and remove files +//! inside an iTunes-compatible backup directory. Those mutations are applied +//! immediately so the device sees the same filesystem state it requested. +//! +//! Before each visible backup mutation, this module appends enough information +//! to `.idevice-journal/journal.jsonl` to undo the mutation later. If the +//! process dies before commit, recovery replays the journal and rolls installed +//! operations back in reverse order. If commit completed, recovery only removes +//! leftover journal files. + +use std::collections::BTreeMap; +use std::fs::File; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::journal::{JsonLineJournal, SequencedRecord}; +use super::paths::{BackupPath, JournalPath, OpId, TxId}; +use crate::IdeviceError; + +pub(crate) const JOURNAL_DIR_NAME: &str = ".idevice-journal"; +pub(crate) const JOURNAL_FILE_NAME: &str = "journal.jsonl"; + +/// Error type for transaction and recovery code. +/// +/// The transaction layer keeps its own error type so path validation, corrupt +/// journals, unsupported atomic primitives, and IO failures stay distinguishable +/// while this code is being developed. Public MobileBackup2 APIs can convert it +/// into `IdeviceError` at the boundary. +#[derive(Debug, Error)] +pub(crate) enum BackupTransactionError { + #[error("transaction IO failed")] + Io(#[from] io::Error), + #[error("transaction JSON serialization failed")] + Json(#[from] serde_json::Error), + #[error("invalid backup path: {0}")] + InvalidBackupPath(String), + #[error("invalid journal path: {0}")] + InvalidJournalPath(String), + #[error("corrupt backup journal: {0}")] + CorruptJournal(String), + #[error("atomic filesystem operation is not implemented")] + UnsupportedAtomicOperation, +} + +impl From for IdeviceError { + fn from(value: BackupTransactionError) -> Self { + IdeviceError::InternalError(value.to_string()) + } +} + +/// One durable fact in the append-only backup transaction journal. +/// +/// Records are never edited in place. A later record advances the state of an +/// earlier operation. This makes crash recovery simple: ignore a partial final +/// line, replay complete records in order, then decide whether to clean up a +/// committed transaction or roll back an incomplete one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum BackupRecord { + Begin { + tx_id: TxId, + }, + ReplacePrepared { + op: OpId, + path: BackupPath, + old: Option, + tmp: JournalPath, + old_existed: bool, + }, + OldSaved { + op: OpId, + }, + TempWritten { + op: OpId, + size: u64, + }, + Installed { + op: OpId, + }, + RemovePrepared { + op: OpId, + path: BackupPath, + old: Option, + old_existed: bool, + }, + Removed { + op: OpId, + }, + MovePrepared { + op: OpId, + from: BackupPath, + to: BackupPath, + replaced: Option, + replaced_existed: bool, + }, + Moved { + op: OpId, + }, + CopyPrepared { + op: OpId, + from: BackupPath, + to: BackupPath, + replaced: Option, + replaced_existed: bool, + }, + Copied { + op: OpId, + }, + MkdirPrepared { + op: OpId, + path: BackupPath, + existed: bool, + }, + MkdirDone { + op: OpId, + }, + CommitReady, + Committed, + RollbackStarted, + Undone { + op: OpId, + }, + RolledBack, +} + +/// Transaction-level status derived from replaying `BackupRecord`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TransactionStatus { + Active, + CommitReady, + Committed, + RollbackStarted, + RolledBack, +} + +/// Operation-level phase derived from replaying `BackupRecord`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OperationPhase { + Prepared, + OldSaved, + TempWritten, + Installed, + Undone, +} + +/// Prepared filesystem mutation with all paths needed for rollback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PreparedOperation { + Replace { + path: BackupPath, + old: Option, + tmp: JournalPath, + old_existed: bool, + }, + Remove { + path: BackupPath, + old: Option, + old_existed: bool, + }, + Move { + from: BackupPath, + to: BackupPath, + replaced: Option, + replaced_existed: bool, + }, + Copy { + from: BackupPath, + to: BackupPath, + replaced: Option, + replaced_existed: bool, + }, + Mkdir { + path: BackupPath, + existed: bool, + }, +} + +/// Current state of one prepared operation, reconstructed from the journal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OperationState { + pub(crate) op: OpId, + pub(crate) operation: PreparedOperation, + pub(crate) phase: OperationPhase, +} + +/// In-memory projection of `.idevice-journal/journal.jsonl`. +/// +/// The JSONL file is the source of truth. `TransactionState` is a replay result +/// optimized for recovery decisions: what transaction phase are we in, which +/// operations were installed, and what must be undone in reverse order? +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TransactionState { + pub(crate) tx_id: Option, + pub(crate) status: TransactionStatus, + operations: BTreeMap, + install_order: Vec, +} + +impl TransactionState { + /// Reconstruct transaction state from complete journal records. + /// + /// Contract: + /// - processes records in journal order + /// - uses `BTreeMap` for deterministic operation ordering + /// - does not touch the filesystem + pub(crate) fn from_records( + records: Vec>, + ) -> Result { + let mut state = Self { + tx_id: None, + status: TransactionStatus::Active, + operations: BTreeMap::new(), + install_order: Vec::new(), + }; + + for entry in records { + let is_begin = matches!(entry.record, BackupRecord::Begin { .. }); + if state.tx_id.is_none() && !is_begin { + return Err(corrupt("journal record before begin")); + } + state.apply_record(entry.record)?; + } + + Ok(state) + } + + /// Return installed operations that still need rollback. + /// + /// Rollback callers should process the returned operations in reverse order + /// so dependent operations are undone before the earlier mutations they may + /// rely on. + pub(crate) fn installed_operations(&self) -> Vec { + if matches!( + self.status, + TransactionStatus::Committed | TransactionStatus::RolledBack + ) { + return Vec::new(); + } + self.installed_operation_projection() + } + + fn installed_operation_projection(&self) -> Vec { + self.install_order + .iter() + .rev() + .filter_map(|op| self.operations.get(op)) + .filter(|op| op.phase == OperationPhase::Installed) + .cloned() + .collect() + } + + fn apply_record(&mut self, record: BackupRecord) -> Result<(), BackupTransactionError> { + match record { + BackupRecord::Begin { tx_id } => { + if self.tx_id.is_some() || self.status != TransactionStatus::Active { + return Err(corrupt("duplicate or late begin record")); + } + self.tx_id = Some(tx_id); + } + BackupRecord::ReplacePrepared { + op, + path, + old, + tmp, + old_existed, + } => self.prepare( + op, + PreparedOperation::Replace { + path, + old, + tmp, + old_existed, + }, + )?, + BackupRecord::RemovePrepared { + op, + path, + old, + old_existed, + } => self.prepare( + op, + PreparedOperation::Remove { + path, + old, + old_existed, + }, + )?, + BackupRecord::MovePrepared { + op, + from, + to, + replaced, + replaced_existed, + } => self.prepare( + op, + PreparedOperation::Move { + from, + to, + replaced, + replaced_existed, + }, + )?, + BackupRecord::CopyPrepared { + op, + from, + to, + replaced, + replaced_existed, + } => self.prepare( + op, + PreparedOperation::Copy { + from, + to, + replaced, + replaced_existed, + }, + )?, + BackupRecord::MkdirPrepared { op, path, existed } => { + self.prepare(op, PreparedOperation::Mkdir { path, existed })? + } + BackupRecord::OldSaved { op } => { + self.require_active()?; + self.advance(op, OperationPhase::OldSaved, |operation, phase| { + old_state_needed(operation) + && match operation { + PreparedOperation::Replace { .. } => { + phase == OperationPhase::TempWritten + } + PreparedOperation::Remove { .. } + | PreparedOperation::Move { .. } + | PreparedOperation::Copy { .. } => phase == OperationPhase::Prepared, + PreparedOperation::Mkdir { .. } => false, + } + })?; + } + BackupRecord::TempWritten { op, .. } => { + self.require_active()?; + self.advance(op, OperationPhase::TempWritten, |operation, phase| { + matches!(operation, PreparedOperation::Replace { .. }) + && phase == OperationPhase::Prepared + })?; + } + BackupRecord::Installed { op } => { + self.require_active()?; + self.advance(op, OperationPhase::Installed, |operation, phase| { + matches!(operation, PreparedOperation::Replace { .. }) + && replace_ready(operation, phase) + })?; + } + BackupRecord::Removed { op } => { + self.require_active()?; + self.advance(op, OperationPhase::Installed, |operation, phase| { + matches!(operation, PreparedOperation::Remove { .. }) + && old_state_ready(operation, phase) + })?; + } + BackupRecord::Moved { op } => { + self.require_active()?; + self.advance(op, OperationPhase::Installed, |operation, phase| { + matches!(operation, PreparedOperation::Move { .. }) + && old_state_ready(operation, phase) + })?; + } + BackupRecord::Copied { op } => { + self.require_active()?; + self.advance(op, OperationPhase::Installed, |operation, phase| { + matches!(operation, PreparedOperation::Copy { .. }) + && old_state_ready(operation, phase) + })?; + } + BackupRecord::MkdirDone { op } => { + self.require_active()?; + self.advance(op, OperationPhase::Installed, |operation, phase| { + matches!( + (operation, phase), + (PreparedOperation::Mkdir { .. }, OperationPhase::Prepared) + ) + })?; + } + BackupRecord::CommitReady => { + if self.status != TransactionStatus::Active { + return Err(corrupt("commit_ready outside active transaction")); + } + self.status = TransactionStatus::CommitReady; + } + BackupRecord::Committed => { + if self.status != TransactionStatus::CommitReady { + return Err(corrupt("committed without commit_ready")); + } + self.status = TransactionStatus::Committed; + } + BackupRecord::RollbackStarted => { + if matches!( + self.status, + TransactionStatus::Committed | TransactionStatus::RolledBack + ) { + return Err(corrupt("rollback_started after terminal transaction state")); + } + self.status = TransactionStatus::RollbackStarted; + } + BackupRecord::Undone { op } => { + if self.status != TransactionStatus::RollbackStarted { + return Err(corrupt("undone outside rollback")); + } + if self + .installed_operation_projection() + .first() + .is_none_or(|operation| operation.op != op) + { + return Err(corrupt("undone operation is not next in rollback order")); + } + self.advance(op, OperationPhase::Undone, |_operation, phase| { + phase == OperationPhase::Installed + })?; + } + BackupRecord::RolledBack => { + if self.status != TransactionStatus::RollbackStarted { + return Err(corrupt("rolled_back without rollback_started")); + } + if !self.installed_operation_projection().is_empty() { + return Err(corrupt("rolled_back with installed operations remaining")); + } + self.status = TransactionStatus::RolledBack; + } + } + + Ok(()) + } + + fn prepare( + &mut self, + op: OpId, + operation: PreparedOperation, + ) -> Result<(), BackupTransactionError> { + if self.tx_id.is_none() { + return Err(corrupt("operation prepared before begin")); + } + if self.operations.contains_key(&op) { + return Err(corrupt("duplicate operation id")); + } + if self.status != TransactionStatus::Active { + return Err(corrupt("operation prepared outside active transaction")); + } + validate_prepared_operation(&operation)?; + + self.operations.insert( + op, + OperationState { + op, + operation, + phase: OperationPhase::Prepared, + }, + ); + Ok(()) + } + + fn advance( + &mut self, + op: OpId, + next: OperationPhase, + allowed: impl FnOnce(&PreparedOperation, OperationPhase) -> bool, + ) -> Result<(), BackupTransactionError> { + let operation = self + .operations + .get_mut(&op) + .ok_or_else(|| corrupt("operation phase references unknown operation"))?; + if !allowed(&operation.operation, operation.phase) { + return Err(corrupt("invalid operation phase transition")); + } + operation.phase = next; + if next == OperationPhase::Installed { + self.install_order.push(op); + } + Ok(()) + } + + fn require_active(&self) -> Result<(), BackupTransactionError> { + if self.status == TransactionStatus::Active { + Ok(()) + } else { + Err(corrupt("operation phase outside active transaction")) + } + } +} + +fn corrupt(message: impl Into) -> BackupTransactionError { + BackupTransactionError::CorruptJournal(message.into()) +} + +fn old_state_needed(operation: &PreparedOperation) -> bool { + match operation { + PreparedOperation::Replace { old_existed, .. } + | PreparedOperation::Remove { old_existed, .. } => *old_existed, + PreparedOperation::Move { + replaced_existed, .. + } + | PreparedOperation::Copy { + replaced_existed, .. + } => *replaced_existed, + PreparedOperation::Mkdir { .. } => false, + } +} + +fn old_state_ready(operation: &PreparedOperation, phase: OperationPhase) -> bool { + match operation { + PreparedOperation::Replace { old_existed, .. } + | PreparedOperation::Remove { old_existed, .. } => { + (*old_existed && phase == OperationPhase::OldSaved) + || (!*old_existed && phase == OperationPhase::Prepared) + } + PreparedOperation::Move { + replaced_existed, .. + } + | PreparedOperation::Copy { + replaced_existed, .. + } => { + (*replaced_existed && phase == OperationPhase::OldSaved) + || (!*replaced_existed && phase == OperationPhase::Prepared) + } + PreparedOperation::Mkdir { .. } => phase == OperationPhase::Prepared, + } +} + +fn replace_ready(operation: &PreparedOperation, phase: OperationPhase) -> bool { + match operation { + PreparedOperation::Replace { old_existed, .. } => { + (*old_existed && phase == OperationPhase::OldSaved) + || (!*old_existed && phase == OperationPhase::TempWritten) + } + _ => false, + } +} + +fn validate_prepared_operation( + operation: &PreparedOperation, +) -> Result<(), BackupTransactionError> { + let valid = match operation { + PreparedOperation::Replace { + old, old_existed, .. + } + | PreparedOperation::Remove { + old, old_existed, .. + } => old.is_some() == *old_existed, + PreparedOperation::Move { + replaced, + replaced_existed, + .. + } + | PreparedOperation::Copy { + replaced, + replaced_existed, + .. + } => replaced.is_some() == *replaced_existed, + PreparedOperation::Mkdir { .. } => true, + }; + + if valid { + Ok(()) + } else { + Err(corrupt("prepared operation old-state flag/path mismatch")) + } +} + +/// Live rollback transaction for one visible backup directory. +/// +/// `backup_dir` is the iTunes-compatible directory for one source identifier. +/// `journal_dir` is `backup_dir/.idevice-journal`. All `JournalPath`s are +/// relative to `journal_dir`; all `BackupPath`s are relative to `backup_dir`. +pub(crate) struct BackupTransaction { + backup_dir: PathBuf, + journal_dir: PathBuf, + journal: JsonLineJournal, + state: TransactionState, + next_op: OpId, +} + +impl BackupTransaction { + /// Start a new transaction in `/.idevice-journal`. + /// + /// Contract: + /// - creates `tmp/`, `old/`, and `journal.jsonl` + /// - appends a durable `begin` record + /// - fails if an unfinished journal already exists; call `recover()` first + pub(crate) fn begin(_backup_dir: &Path, _tx_id: TxId) -> Result { + todo!("start a backup rollback transaction") + } + + /// Recover or clean up any existing transaction journal for `backup_dir`. + /// + /// Contract: + /// - no-op if `.idevice-journal/` does not exist + /// - committed or rolled-back journals are cleaned up + /// - active or rollback-started journals are rolled back idempotently + /// - safe to call before every public MobileBackup2 backup-facing operation + pub(crate) fn recover(_backup_dir: &Path) -> Result<(), BackupTransactionError> { + todo!("recover or clean up an existing backup rollback transaction") + } + + /// Prepare replacement of one visible backup file. + /// + /// This does not modify `path` yet. The returned `FileReplacement` writes + /// into `.idevice-journal/tmp/`. The visible path is changed only by + /// `FileReplacement::finish()`, after the tmp file is complete and the old + /// destination state has been journaled. + pub(crate) fn begin_replace( + &mut self, + _path: BackupPath, + ) -> Result, BackupTransactionError> { + todo!("prepare journal-backed file replacement") + } + + /// Remove one visible backup path with rollback protection. + /// + /// Contract: + /// - appends remove intent before touching `path` + /// - preserves old state under `.idevice-journal/old/` when `path` exists + /// - appends `removed` only after the visible mutation succeeds + pub(crate) fn remove(&mut self, _path: BackupPath) -> Result<(), BackupTransactionError> { + todo!("remove visible backup path with rollback journal") + } + + /// Rename one visible backup path with rollback protection. + /// + /// Contract: + /// - appends move intent before touching either path + /// - preserves destination state if `to` exists + /// - appends `moved` only after the visible mutation succeeds + pub(crate) fn rename( + &mut self, + _from: BackupPath, + _to: BackupPath, + ) -> Result<(), BackupTransactionError> { + todo!("rename visible backup path with rollback journal") + } + + /// Copy one visible backup path with rollback protection. + /// + /// Contract: + /// - appends copy intent before touching the destination + /// - preserves destination state if `to` exists + /// - appends `copied` only after the visible mutation succeeds + pub(crate) fn copy( + &mut self, + _from: BackupPath, + _to: BackupPath, + ) -> Result<(), BackupTransactionError> { + todo!("copy visible backup path with rollback journal") + } + + /// Create a visible backup directory with rollback protection. + /// + /// Contract: + /// - records whether the directory existed before this transaction + /// - rollback removes only directories created by this operation, and only + /// when it is safe to do so + pub(crate) fn create_dir(&mut self, _path: BackupPath) -> Result<(), BackupTransactionError> { + todo!("create visible backup directory with rollback journal") + } + + /// Mark this transaction successful and remove rollback data. + /// + /// Contract: + /// - caller must validate MobileBackup2 final success before calling + /// - appends `commit_ready`, then `committed` + /// - after `committed` is durable, recovery must never roll this transaction + /// back; it may only finish cleanup + pub(crate) fn commit(self) -> Result<(), BackupTransactionError> { + todo!("commit backup rollback transaction") + } + + /// Abort this live transaction and roll back installed operations. + /// + /// Contract: + /// - appends `rollback_started` + /// - undoes installed operations in reverse operation order + /// - appends `undone` after each successful inverse mutation + /// - appends `rolled_back` before cleanup + pub(crate) fn rollback(self) -> Result<(), BackupTransactionError> { + todo!("roll back backup transaction") + } + + fn append(&mut self, _record: BackupRecord) -> Result<(), BackupTransactionError> { + todo!("append record and update in-memory transaction state") + } + + fn allocate_op(&mut self) -> OpId { + todo!("allocate the next transaction-local operation id") + } +} + +/// Staged replacement for one visible backup file. +/// +/// The file handle writes only to `.idevice-journal/tmp/`. `finish()` is the +/// only method that may install the tmp file into the visible backup tree. +/// `Drop` intentionally does not roll back because rollback is fallible. +pub(crate) struct FileReplacement<'tx> { + tx: &'tx mut BackupTransaction, + op: OpId, + path: BackupPath, + tmp: JournalPath, + file: File, + bytes_written: u64, + finished: bool, +} + +impl FileReplacement<'_> { + /// Write data into journal tmp storage only. + /// + /// Contract: + /// - never mutates the visible backup path + /// - updates `bytes_written` only after the write succeeds + pub(crate) fn write_all(&mut self, _data: &[u8]) -> Result<(), BackupTransactionError> { + todo!("write replacement data to journal tmp file") + } + + /// Flush tmp, save old destination state, and install tmp. + /// + /// Contract: + /// - appends `temp_written` after the tmp file is flushed + /// - appends old-state records before replacing the visible file + /// - uses the atomic filesystem boundary to install the tmp file + /// - appends `installed` only after the visible mutation succeeds + pub(crate) fn finish(self) -> Result<(), BackupTransactionError> { + todo!("finish and install journal-backed file replacement") + } + + /// Discard tmp without touching the visible backup path. + /// + /// Contract: + /// - used when the device does not send a successful file trailer + /// - never appends `installed` + pub(crate) fn abort(self) -> Result<(), BackupTransactionError> { + todo!("abort journal-backed file replacement") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(seq: u64, record: BackupRecord) -> SequencedRecord { + SequencedRecord { seq, record } + } + + fn tx_id() -> TxId { + TxId("tx-1".into()) + } + + fn backup_path(path: &str) -> BackupPath { + BackupPath::parse_item(path).unwrap() + } + + fn tmp(op: u64) -> JournalPath { + JournalPath::tmp(OpId(op)) + } + + fn old(op: u64) -> JournalPath { + JournalPath::old(OpId(op)) + } + + #[test] + fn replay_derives_committed_replace_state() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::ReplacePrepared { + op: OpId(1), + path: backup_path("aa/bb"), + old: Some(old(1)), + tmp: tmp(1), + old_existed: true, + }, + ), + rec( + 3, + BackupRecord::TempWritten { + op: OpId(1), + size: 12, + }, + ), + rec(4, BackupRecord::OldSaved { op: OpId(1) }), + rec(5, BackupRecord::Installed { op: OpId(1) }), + rec(6, BackupRecord::CommitReady), + rec(7, BackupRecord::Committed), + ]; + + let state = TransactionState::from_records(records).unwrap(); + + assert_eq!(state.tx_id, Some(tx_id())); + assert_eq!(state.status, TransactionStatus::Committed); + assert!(state.installed_operations().is_empty()); + } + + #[test] + fn installed_operations_are_returned_in_reverse_install_order_for_rollback() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + ), + rec(3, BackupRecord::MkdirDone { op: OpId(1) }), + rec( + 4, + BackupRecord::RemovePrepared { + op: OpId(2), + path: backup_path("aa/file"), + old: Some(old(2)), + old_existed: true, + }, + ), + rec(5, BackupRecord::OldSaved { op: OpId(2) }), + rec(6, BackupRecord::Removed { op: OpId(2) }), + ]; + + let state = TransactionState::from_records(records).unwrap(); + + let ops: Vec = state + .installed_operations() + .into_iter() + .map(|op| op.op) + .collect(); + assert_eq!(ops, vec![OpId(2), OpId(1)]); + } + + #[test] + fn installed_operations_follow_reverse_replay_order_not_operation_id_order() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(2), + path: backup_path("later-id"), + existed: false, + }, + ), + rec( + 3, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("earlier-id"), + existed: false, + }, + ), + rec(4, BackupRecord::MkdirDone { op: OpId(2) }), + rec(5, BackupRecord::MkdirDone { op: OpId(1) }), + ]; + + let state = TransactionState::from_records(records).unwrap(); + let ops: Vec = state + .installed_operations() + .into_iter() + .map(|op| op.op) + .collect(); + + assert_eq!(ops, vec![OpId(1), OpId(2)]); + } + + #[test] + fn replay_rejects_installed_before_required_replace_phases() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::ReplacePrepared { + op: OpId(1), + path: backup_path("aa/bb"), + old: Some(old(1)), + tmp: tmp(1), + old_existed: true, + }, + ), + rec(3, BackupRecord::Installed { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_new_replace_install_before_temp_written() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::ReplacePrepared { + op: OpId(1), + path: backup_path("aa/new"), + old: None, + tmp: tmp(1), + old_existed: false, + }, + ), + rec(3, BackupRecord::Installed { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_replace_old_saved_before_temp_written() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::ReplacePrepared { + op: OpId(1), + path: backup_path("aa/bb"), + old: Some(old(1)), + tmp: tmp(1), + old_existed: true, + }, + ), + rec(3, BackupRecord::OldSaved { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_remove_install_when_existing_old_state_was_not_saved() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::RemovePrepared { + op: OpId(1), + path: backup_path("aa/file"), + old: Some(old(1)), + old_existed: true, + }, + ), + rec(3, BackupRecord::Removed { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_allows_remove_install_without_old_state_when_path_did_not_exist() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::RemovePrepared { + op: OpId(1), + path: backup_path("aa/file"), + old: None, + old_existed: false, + }, + ), + rec(3, BackupRecord::Removed { op: OpId(1) }), + ]; + + let state = TransactionState::from_records(records).unwrap(); + + assert_eq!( + state.installed_operations(), + vec![OperationState { + op: OpId(1), + operation: PreparedOperation::Remove { + path: backup_path("aa/file"), + old: None, + old_existed: false, + }, + phase: OperationPhase::Installed, + }] + ); + } + + #[test] + fn replay_rejects_old_state_flag_path_mismatch() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::RemovePrepared { + op: OpId(1), + path: backup_path("aa/file"), + old: None, + old_existed: true, + }, + ), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_old_saved_when_old_state_was_not_needed() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::RemovePrepared { + op: OpId(1), + path: backup_path("aa/file"), + old: None, + old_existed: false, + }, + ), + rec(3, BackupRecord::OldSaved { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_duplicate_begin() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::Begin { + tx_id: TxId("tx-2".into()), + }, + ), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_transaction_record_before_begin() { + let records = vec![rec(1, BackupRecord::CommitReady)]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_committed_without_commit_ready() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec(2, BackupRecord::Committed), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_transition_for_unknown_operation() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec(2, BackupRecord::Installed { op: OpId(99) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_operation_progress_after_commit_ready() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + ), + rec(3, BackupRecord::CommitReady), + rec(4, BackupRecord::MkdirDone { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_operation_before_begin() { + let records = vec![rec( + 1, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + )]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_undone_before_rollback_started() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + ), + rec(3, BackupRecord::MkdirDone { op: OpId(1) }), + rec(4, BackupRecord::Undone { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_undone_outside_reverse_install_order() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + ), + rec(3, BackupRecord::MkdirDone { op: OpId(1) }), + rec( + 4, + BackupRecord::MkdirPrepared { + op: OpId(2), + path: backup_path("aa/bb"), + existed: false, + }, + ), + rec(5, BackupRecord::MkdirDone { op: OpId(2) }), + rec(6, BackupRecord::RollbackStarted), + rec(7, BackupRecord::Undone { op: OpId(1) }), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn replay_rejects_rolled_back_with_installed_operations_remaining() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + ), + rec(3, BackupRecord::MkdirDone { op: OpId(1) }), + rec(4, BackupRecord::RollbackStarted), + rec(5, BackupRecord::RolledBack), + ]; + + assert!(matches!( + TransactionState::from_records(records), + Err(BackupTransactionError::CorruptJournal(_)) + )); + } + + #[test] + fn installed_operations_excludes_undone_operations() { + let records = vec![ + rec(1, BackupRecord::Begin { tx_id: tx_id() }), + rec( + 2, + BackupRecord::MkdirPrepared { + op: OpId(1), + path: backup_path("aa"), + existed: false, + }, + ), + rec(3, BackupRecord::MkdirDone { op: OpId(1) }), + rec(4, BackupRecord::RollbackStarted), + rec(5, BackupRecord::Undone { op: OpId(1) }), + ]; + + let state = TransactionState::from_records(records).unwrap(); + + assert_eq!(state.status, TransactionStatus::RollbackStarted); + assert!(state.installed_operations().is_empty()); + } +}