diff --git a/apple/Generated/ironstorage_watch.swift b/apple/Generated/ironstorage_watch.swift index ef6d925..fc9f5bb 100644 --- a/apple/Generated/ironstorage_watch.swift +++ b/apple/Generated/ironstorage_watch.swift @@ -563,6 +563,8 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { public protocol WatchCoreProtocol: AnyObject, Sendable { + func applyAuthoritativeSnapshot(snapshot: Data) throws -> WatchSnapshotUpdate + func applySnapshot(snapshot: Data) throws -> WatchSnapshotUpdate func noPersistedSnapshot() throws @@ -633,6 +635,16 @@ open class WatchCore: WatchCoreProtocol, @unchecked Sendable { +open func applyAuthoritativeSnapshot(snapshot: Data)throws -> WatchSnapshotUpdate { + return try FfiConverterTypeWatchSnapshotUpdate_lift(try rustCallWithError(FfiConverterTypeWatchFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_watch_fn_method_watchcore_apply_authoritative_snapshot( + self.uniffiCloneHandle(), + FfiConverterData.lower(snapshot),uniffiCallStatus + ) +}) +} + open func applySnapshot(snapshot: Data)throws -> WatchSnapshotUpdate { return try FfiConverterTypeWatchSnapshotUpdate_lift(try rustCallWithError(FfiConverterTypeWatchFfiError_lift) { uniffiCallStatus in @@ -1384,6 +1396,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_watch_checksum_func_watch_core() != 47212) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_watch_checksum_method_watchcore_apply_authoritative_snapshot() != 29816) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_watch_checksum_method_watchcore_apply_snapshot() != 6162) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_watchFFI.h b/apple/Generated/ironstorage_watchFFI.h index 6c12c9d..fbe60ac 100644 --- a/apple/Generated/ironstorage_watchFFI.h +++ b/apple/Generated/ironstorage_watchFFI.h @@ -253,6 +253,11 @@ uint64_t uniffi_ironstorage_watch_fn_clone_watchcore(uint64_t handle, RustCallSt void uniffi_ironstorage_watch_fn_free_watchcore(uint64_t handle, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT +RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_authoritative_snapshot(uint64_t ptr, RustBuffer snapshot, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_SNAPSHOT #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_SNAPSHOT RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_snapshot(uint64_t ptr, RustBuffer snapshot, RustCallStatus *_Nonnull out_status @@ -563,6 +568,12 @@ void ffi_ironstorage_watch_rust_future_complete_void(uint64_t handle, RustCallSt #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_FUNC_WATCH_CORE uint16_t uniffi_ironstorage_watch_checksum_func_watch_core(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT +uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_apply_authoritative_snapshot(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_SNAPSHOT diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index b72d221..45d6a57 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -46,6 +46,7 @@ private final class WatchSnapshotCoordinator: NSObject, WCSessionDelegate { Notification.Name.ironStorageWatchSnapshotDidChange, .ironStorageLocalStoreDidChange, .ironStorageAuthenticationDidChange, + UIApplication.didBecomeActiveNotification, ] { NotificationCenter.default.addObserver( self, @@ -103,21 +104,7 @@ private final class WatchSnapshotCoordinator: NSObject, WCSessionDelegate { Self.snapshotKey: snapshot, Self.deliveredReceiptKey: Data(transfer.deliveredReceipt), ] - do { - try session.updateApplicationContext(payload) - if session.isReachable { - session.sendMessage(payload, replyHandler: nil) { _ in - session.transferUserInfo(payload) - } - } else { - session.transferUserInfo(payload) - } - } catch { - _ = try? authentication.failWatchSnapshot( - revision: transfer.revision, - detail: "The latest Apple Watch snapshot could not be queued." - ) - } + publish(payload, through: session) notifyStatusChanged() case .failure: // A locked GPG key leaves the previous replacement pending. Authentication @@ -127,6 +114,18 @@ private final class WatchSnapshotCoordinator: NSObject, WCSessionDelegate { } } + private func publish(_ payload: [String: Any], through session: WCSession) { + for transfer in session.outstandingUserInfoTransfers + where transfer.userInfo[Self.snapshotKey] != nil { + transfer.cancel() + } + try? session.updateApplicationContext(payload) + session.transferUserInfo(payload) + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in } + } + } + private func recordUnavailable(unpaired: Bool, detail: String) { guard let authentication else { return } _ = try? authentication.setWatchSnapshotUnavailable(unpaired: unpaired, detail: detail) diff --git a/apple/Sources/Watch/IronStorageWatchApp.swift b/apple/Sources/Watch/IronStorageWatchApp.swift index 47e4518..02787c1 100644 --- a/apple/Sources/Watch/IronStorageWatchApp.swift +++ b/apple/Sources/Watch/IronStorageWatchApp.swift @@ -85,6 +85,12 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio func sceneBecameActive() { restoreSnapshot() + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.activate() + if session.activationState == .activated { + receive(session.receivedApplicationContext, session: session, authoritative: true) + } } func sceneBecameInactive() { @@ -108,7 +114,11 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio } } - private func receive(_ applicationContext: [String: Any], session: WCSession) { + private func receive( + _ applicationContext: [String: Any], + session: WCSession, + authoritative: Bool = false + ) { try? core.syncStarted() guard var snapshot = applicationContext[Self.snapshotKey] as? Data else { try? core.syncFinished() @@ -117,7 +127,11 @@ private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessio } defer { snapshot.resetBytes(in: 0.. Result { - let snapshot = match decode_snapshot(bytes.expose()) { - Ok(snapshot) => snapshot, - Err(error) => { - self.current = None; - return Err(error); - } - }; - let mut pairing_changed = false; - // A valid revocation can only remove secrets, so it must survive a sender reset. - if !snapshot.is_revocation() - && let Some(accepted) = self.accepted - { - if snapshot.revision < accepted.revision { + let snapshot = decode_snapshot(bytes.expose())?; + if let Some(accepted) = self.accepted { + if snapshot.pairing != accepted.pairing || snapshot.revision < accepted.revision { return Ok(WatchSnapshotApply::Stale); } else if snapshot.revision == accepted.revision { - if snapshot.pairing != accepted.pairing || snapshot.digest != accepted.digest { - self.current = None; + if snapshot.digest != accepted.digest { return Err(WatchSnapshotError::RevisionConflict); } if self.current.is_some() { return Ok(WatchSnapshotApply::Duplicate); } - } else if accepted.pairing != snapshot.pairing { - pairing_changed = true; } } let accepted = AcceptedSnapshot { @@ -252,6 +241,37 @@ impl WatchSnapshotReceiver { revision: snapshot.revision, digest: snapshot.digest, }; + let result = if snapshot.is_revocation() { + WatchSnapshotApply::Revoked + } else { + WatchSnapshotApply::Replaced + }; + self.accepted = Some(accepted); + self.current = Some(snapshot); + Ok(result) + } + + pub fn replace_authoritative( + &mut self, + bytes: SecretBytes, + ) -> Result { + let snapshot = decode_snapshot(bytes.expose())?; + let pairing_changed = self + .accepted + .is_some_and(|accepted| accepted.pairing != snapshot.pairing); + if let Some(accepted) = self.accepted + && !pairing_changed + { + if snapshot.revision < accepted.revision { + return Ok(WatchSnapshotApply::Stale); + } + if accepted.revision == snapshot.revision + && accepted.digest == snapshot.digest + && self.current.is_some() + { + return Ok(WatchSnapshotApply::Duplicate); + } + } let result = if snapshot.is_revocation() { WatchSnapshotApply::Revoked } else if pairing_changed { @@ -259,7 +279,11 @@ impl WatchSnapshotReceiver { } else { WatchSnapshotApply::Replaced }; - self.accepted = Some(accepted); + self.accepted = Some(AcceptedSnapshot { + pairing: snapshot.pairing, + revision: snapshot.revision, + digest: snapshot.digest, + }); self.current = Some(snapshot); Ok(result) } @@ -432,14 +456,36 @@ impl WatchRuntime { let apply = match self.receiver.apply(SecretBytes::new(bytes)) { Ok(apply) => apply, Err(error) => { - self.presentation_state = WatchPresentationState::Error; + self.sync_failed(); return Err(error); } }; + Ok(self.finish_apply(apply)) + } + + pub fn apply_authoritative_snapshot( + &mut self, + bytes: Vec, + ) -> Result { + let apply = match self.receiver.replace_authoritative(SecretBytes::new(bytes)) { + Ok(apply) => apply, + Err(error) => { + self.sync_failed(); + return Err(error); + } + }; + Ok(self.finish_apply(apply)) + } + + fn finish_apply(&mut self, apply: WatchSnapshotApply) -> WatchSnapshotUpdate { self.protected_data_available = true; self.presentation_state = match apply { WatchSnapshotApply::Revoked => WatchPresentationState::Empty, - WatchSnapshotApply::Stale => WatchPresentationState::Stale, + WatchSnapshotApply::Stale => match self.receiver.current() { + Some(snapshot) if snapshot.is_revocation() => WatchPresentationState::Empty, + Some(_) => WatchPresentationState::Ready, + None => WatchPresentationState::Stale, + }, WatchSnapshotApply::Replaced | WatchSnapshotApply::Duplicate | WatchSnapshotApply::PairingChanged => WatchPresentationState::Ready, @@ -454,7 +500,7 @@ impl WatchRuntime { } }; let current = self.receiver.current(); - Ok(WatchSnapshotUpdate { + WatchSnapshotUpdate { apply, persistence, revision: current.map(WatchSnapshot::revision), @@ -462,7 +508,7 @@ impl WatchRuntime { .map(|snapshot| u32::try_from(snapshot.entries().len()).unwrap_or(u32::MAX)) .unwrap_or(0), receipt: self.receiver.current_receipt().unwrap_or_default(), - }) + } } pub fn records_at( @@ -573,8 +619,11 @@ impl WatchRuntime { } pub fn sync_failed(&mut self) { - self.receiver.clear_secrets(); - self.presentation_state = WatchPresentationState::Error; + self.presentation_state = if self.receiver.current().is_some() { + WatchPresentationState::Stale + } else { + WatchPresentationState::Error + }; } pub fn protected_data_unavailable(&mut self) { @@ -620,6 +669,8 @@ impl WatchSnapshotSender { ) -> Result<(), WatchSnapshotError> { if unpaired { self.journal.pairing = None; + self.journal.generation = None; + self.journal.revision = 0; self.journal.digest = None; self.journal.snapshot_digest = None; self.journal.delivered_revision = None; @@ -646,29 +697,44 @@ impl WatchSnapshotSender { if platform_pairing_identity.trim().is_empty() { return Err(WatchSnapshotError::InvalidPairing); } - let pairing = digest(platform_pairing_identity.as_bytes()); + let platform_pairing = digest(platform_pairing_identity.as_bytes()); let content = encode_entries(&entries)?; let content_digest = digest(&content); - let pairing_text = HEXLOWER.encode(&pairing); + let pairing_text = HEXLOWER.encode(&platform_pairing); let digest_text = HEXLOWER.encode(&content_digest); if self.journal.pairing.as_deref() != Some(&pairing_text) - || self.journal.digest.as_deref() != Some(&digest_text) + || self.journal.generation.is_none() { + let mut generation = [0_u8; 32]; + OsRng.fill_bytes(&mut generation); + self.journal.pairing = Some(pairing_text); + self.journal.generation = Some(HEXLOWER.encode(&generation)); + self.journal.revision = 0; + self.journal.digest = None; + self.journal.snapshot_digest = None; + self.journal.delivered_revision = None; + self.journal.current_revision = None; + } + if self.journal.digest.as_deref() != Some(&digest_text) { self.journal.revision = self .journal .revision .checked_add(1) .ok_or(WatchSnapshotError::RevisionOverflow)?; - self.journal.pairing = Some(pairing_text); self.journal.digest = Some(digest_text); self.journal.delivered_revision = None; self.journal.current_revision = None; - save_journal(&self.path, &self.journal)?; } if self.journal.revision == 0 { self.journal.revision = 1; - save_journal(&self.path, &self.journal)?; } + let generation = decode_hash( + self.journal + .generation + .as_deref() + .ok_or(WatchSnapshotError::InvalidJournal)?, + )?; + let pairing = snapshot_pairing(platform_pairing, generation); let snapshot = encode_snapshot(pairing, self.journal.revision, &content)?; let snapshot_digest = digest(snapshot.expose()); self.journal.snapshot_digest = Some(HEXLOWER.encode(&snapshot_digest)); @@ -709,12 +775,19 @@ impl WatchSnapshotSender { receipt: &[u8], ) -> Result { let receipt = decode_receipt(receipt)?; - let pairing = decode_hash( + let platform_pairing = decode_hash( self.journal .pairing .as_deref() .ok_or(WatchSnapshotError::NoPendingSnapshot)?, )?; + let generation = decode_hash( + self.journal + .generation + .as_deref() + .ok_or(WatchSnapshotError::NoPendingSnapshot)?, + )?; + let pairing = snapshot_pairing(platform_pairing, generation); if receipt.pairing != pairing || receipt.revision != self.journal.revision { return Ok(self.status()); } @@ -762,6 +835,8 @@ struct SenderJournal { #[serde(default)] pairing: Option, #[serde(default)] + generation: Option, + #[serde(default)] revision: u64, #[serde(default)] digest: Option, @@ -779,6 +854,7 @@ impl Default for SenderJournal { Self { version: JOURNAL_VERSION, pairing: None, + generation: None, revision: 0, digest: None, snapshot_digest: None, @@ -1085,6 +1161,15 @@ fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), WatchSnapsho fn digest(bytes: &[u8]) -> [u8; 32] { Sha256::digest(bytes).into() } + +#[cfg(feature = "full")] +fn snapshot_pairing(platform_pairing: [u8; 32], generation: [u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"IronStorage Watch snapshot generation\0"); + hasher.update(platform_pairing); + hasher.update(generation); + hasher.finalize().into() +} #[cfg(feature = "full")] fn decode_hash(text: &str) -> Result<[u8; 32], WatchSnapshotError> { let bytes = HEXLOWER_PERMISSIVE diff --git a/crates/storage/tests/apple_mobile_audit.rs b/crates/storage/tests/apple_mobile_audit.rs index 6aa5fbf..a8fa142 100644 --- a/crates/storage/tests/apple_mobile_audit.rs +++ b/crates/storage/tests/apple_mobile_audit.rs @@ -62,8 +62,28 @@ fn apple_sources_preserve_the_mobile_security_boundary() { assert!(watch.contains("SecItemCopyMatching")); assert!(app.contains("session.sendMessage(payload")); assert!(app.contains("session.transferUserInfo(payload)")); + assert!(app.contains("session.outstandingUserInfoTransfers")); + assert!( + app.split_once("@main") + .expect("iPhone app delegate") + .0 + .contains("UIApplication.didBecomeActiveNotification") + ); assert!(watch.contains("didReceiveMessage message")); assert!(watch.contains("didReceiveUserInfo userInfo")); + assert!(watch.contains("session.outstandingUserInfoTransfers")); + assert!(watch.contains("session.transferUserInfo(acknowledgement)")); + assert!( + watch + .split_once("func sceneBecameActive()") + .expect("Watch foreground handler") + .1 + .split_once("func sceneBecameInactive()") + .expect("Watch inactive handler") + .0 + .contains("receivedApplicationContext") + ); + assert!(watch.contains("applyAuthoritativeSnapshot")); } #[test] diff --git a/crates/storage/tests/mobile_watch.rs b/crates/storage/tests/mobile_watch.rs index 04ae727..500e7b4 100644 --- a/crates/storage/tests/mobile_watch.rs +++ b/crates/storage/tests/mobile_watch.rs @@ -112,6 +112,12 @@ fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestR receiver.apply(SecretBytes::new( reset_revocation.snapshot().expose().to_vec() ))?, + WatchSnapshotApply::Stale + ); + assert_eq!( + receiver.replace_authoritative(SecretBytes::new( + reset_revocation.snapshot().expose().to_vec() + ))?, WatchSnapshotApply::Revoked ); assert!( @@ -134,12 +140,17 @@ fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestR "paired-watch-b", vec![entry("otp/carol", "Acme", "carol", b"third-secret")], )?; - assert_eq!(changed_watch.revision(), 4); + assert_eq!(changed_watch.revision(), 1); assert_eq!( receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Stale + ); + assert_eq!( + receiver + .replace_authoritative(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::PairingChanged ); - assert_eq!(receiver.current().expect("new pairing").revision(), 4); + assert_eq!(receiver.current().expect("new pairing").revision(), 1); let mut fresh_watch = WatchSnapshotReceiver::default(); assert_eq!( fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, @@ -149,14 +160,21 @@ fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestR let mut damaged = changed_watch.snapshot().expose().to_vec(); damaged[20] ^= 0x55; assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err()); - assert!(fresh_watch.current().is_none()); + assert_eq!( + fresh_watch + .current() + .expect("last valid snapshot") + .entries()[0] + .account(), + "carol" + ); assert_eq!( fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); assert_eq!( fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, - WatchSnapshotApply::Replaced + WatchSnapshotApply::Duplicate ); let persisted = fs::read_to_string(journal)?; @@ -193,6 +211,131 @@ fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult { Ok(()) } +#[test] +fn journal_upgrade_starts_a_new_generation_at_revision_one() -> TestResult { + let directory = tempfile::tempdir()?; + let journal = directory.path().join("watch-snapshot.toml"); + let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")]; + WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?; + + let legacy = fs::read_to_string(&journal)? + .lines() + .filter(|line| !line.starts_with("generation = ")) + .collect::>() + .join("\n") + .replace("revision = 1", "revision = 42"); + fs::write(&journal, legacy)?; + + let reset = WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?; + assert_eq!(reset.revision(), 1); + let reloaded = WatchSnapshotSender::load(journal).prepare("paired-watch", entries())?; + assert_eq!(reloaded.revision(), 1); + assert_eq!(reset.snapshot().expose(), reloaded.snapshot().expose()); + Ok(()) +} + +#[test] +fn duplicate_delivery_after_watch_restart_recovers_a_lost_acknowledgement() -> TestResult { + let directory = tempfile::tempdir()?; + let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml")); + let transfer = sender.prepare( + "paired-watch", + vec![entry("otp/alice", "Acme", "alice", b"secret")], + )?; + let persisted_snapshot = transfer.snapshot().expose().to_vec(); + + let mut interrupted_runtime = WatchRuntime::default(); + let applied = interrupted_runtime.apply_snapshot(persisted_snapshot.clone())?; + assert_eq!(applied.persistence(), WatchPersistenceAction::Replace); + assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending); + + let mut restarted_runtime = WatchRuntime::default(); + restarted_runtime.apply_snapshot(persisted_snapshot.clone())?; + let redelivered = restarted_runtime.apply_snapshot(persisted_snapshot)?; + assert_eq!(redelivered.apply(), WatchSnapshotApply::Duplicate); + assert_eq!(redelivered.persistence(), WatchPersistenceAction::Keep); + assert_eq!( + sender.acknowledge(redelivered.receipt())?.state(), + MobileWatchSnapshotState::Current + ); + Ok(()) +} + +#[test] +fn authoritative_context_switches_sender_generation_and_rejects_delayed_packets() -> TestResult { + let directory = tempfile::tempdir()?; + let mut old_sender = WatchSnapshotSender::load(directory.path().join("old.toml")); + old_sender.prepare( + "paired-watch", + vec![entry("otp/alice", "Acme", "alice", b"old-secret")], + )?; + for revision in 2..5 { + old_sender.prepare( + "paired-watch", + vec![entry( + "otp/alice", + "Acme", + &format!("alice-{revision}"), + b"old-secret", + )], + )?; + } + let old = old_sender.prepare( + "paired-watch", + vec![entry("otp/alice", "Acme", "alice-5", b"old-secret")], + )?; + assert_eq!(old.revision(), 5); + + let mut new_sender = WatchSnapshotSender::load(directory.path().join("new.toml")); + let new = new_sender.prepare( + "paired-watch", + vec![entry("otp/bob", "Acme", "bob", b"new-secret")], + )?; + assert_eq!(new.revision(), 1); + + let mut runtime = WatchRuntime::default(); + runtime.apply_snapshot(old.snapshot().expose().to_vec())?; + let queued_new = runtime.apply_snapshot(new.snapshot().expose().to_vec())?; + assert_eq!(queued_new.apply(), WatchSnapshotApply::Stale); + assert_eq!( + runtime.presentation_at(59)?.state(), + WatchPresentationState::Ready + ); + assert_eq!(runtime.records_at(59)?[0].account(), "alice-5"); + + let replaced = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?; + assert_eq!(replaced.apply(), WatchSnapshotApply::PairingChanged); + assert_eq!(replaced.persistence(), WatchPersistenceAction::Replace); + assert_eq!(runtime.records_at(59)?[0].account(), "bob"); + assert_eq!( + new_sender.acknowledge(replaced.receipt())?.state(), + MobileWatchSnapshotState::Current + ); + + let delayed_old = runtime.apply_snapshot(old.snapshot().expose().to_vec())?; + assert_eq!(delayed_old.apply(), WatchSnapshotApply::Stale); + assert_eq!(runtime.records_at(59)?[0].account(), "bob"); + + let newer = new_sender.prepare( + "paired-watch", + vec![entry("otp/carol", "Acme", "carol", b"newer-secret")], + )?; + runtime.apply_authoritative_snapshot(newer.snapshot().expose().to_vec())?; + let delayed_context = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?; + assert_eq!(delayed_context.apply(), WatchSnapshotApply::Stale); + assert_eq!(runtime.records_at(59)?[0].account(), "carol"); + + assert!(runtime.apply_snapshot(vec![0; 64]).is_err()); + assert_eq!(runtime.records_at(59)?[0].account(), "carol"); + assert_eq!( + runtime.presentation_at(59)?.state(), + WatchPresentationState::Stale + ); + runtime.sync_unavailable(); + assert_eq!(runtime.records_at(59)?[0].account(), "carol"); + Ok(()) +} + #[test] fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> TestResult { let directory = tempfile::tempdir()?; @@ -266,12 +409,12 @@ fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> T runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!( runtime.presentation_at(59)?.state(), - WatchPresentationState::Stale + WatchPresentationState::Empty ); assert!(runtime.apply_snapshot(vec![0; 64]).is_err()); assert_eq!( runtime.presentation_at(59)?.state(), - WatchPresentationState::Error + WatchPresentationState::Stale ); Ok(()) } diff --git a/crates/watch-apple/src/lib.rs b/crates/watch-apple/src/lib.rs index 5299a16..d58eb1c 100644 --- a/crates/watch-apple/src/lib.rs +++ b/crates/watch-apple/src/lib.rs @@ -185,6 +185,18 @@ impl WatchCore { .map_err(Into::into) } + pub fn apply_authoritative_snapshot( + &self, + snapshot: Vec, + ) -> Result { + self.runtime + .lock() + .map_err(|_| lock_error())? + .apply_authoritative_snapshot(snapshot) + .map(Into::into) + .map_err(Into::into) + } + pub fn presentation_at(&self, unix_seconds: u64) -> Result { self.runtime .lock()