-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add sync_backup() method to force a vault backup on first backup #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
21bcc8c
713f4c8
f804103
08ba463
ea81c5e
4ee41f5
b20aa2b
ae3fa87
0026a14
c3ac156
65a53eb
5d93559
7b86e91
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,8 +190,7 @@ impl CredentialStore { | |
| /// not exist. | ||
| pub fn delete_credential(&self, credential_id: u64) -> StorageResult<()> { | ||
| self.lock_inner()?.delete_credential(credential_id)?; | ||
| self.notify_vault_changed(); | ||
| Ok(()) | ||
| self.notify_vault_changed() | ||
| } | ||
|
|
||
| /// Stores a credential and optional associated data. | ||
|
|
@@ -214,7 +213,7 @@ impl CredentialStore { | |
| associated_data, | ||
| now, | ||
| )?; | ||
| self.notify_vault_changed(); | ||
| self.notify_vault_changed()?; | ||
thomas-waite marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Ok(id) | ||
| } | ||
|
|
||
|
|
@@ -284,15 +283,15 @@ impl CredentialStore { | |
| let count = inner.danger_delete_all_credentials()?; | ||
| drop(inner); | ||
| if count > 0 { | ||
| self.notify_vault_changed(); | ||
| self.notify_vault_changed()?; | ||
| } | ||
| Ok(count) | ||
| } | ||
|
|
||
| /// Registers a backup manager that will be notified after vault mutations | ||
| /// ([`store_credential`](Self::store_credential), | ||
| /// [`delete_credential`](Self::delete_credential), | ||
| /// [`danger_delete_all_credentials`](Self::danger_delete_all_credentials)). | ||
| /// Backup failures are logged but do not affect the mutation result. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
|
|
@@ -306,6 +305,21 @@ impl CredentialStore { | |
| })? = manager; | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Triggers a one-off backup sync without mutating the vault. | ||
| /// | ||
| /// Call this at initial backup creation to catch up any credentials | ||
| /// that were stored before the backup was set up. The registered | ||
| /// [`WalletKitBackupManager`] receives the same `on_vault_changed` | ||
| /// callback as after a normal vault mutation. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns an error if no backup manager is configured, if the vault | ||
| /// export fails, or if the backup manager callback fails. | ||
| pub fn sync_backup(&self) -> StorageResult<()> { | ||
|
||
| self.notify_vault_changed() | ||
| } | ||
thomas-waite marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /// Implementation not exposed to foreign bindings | ||
|
|
@@ -318,40 +332,38 @@ impl CredentialStore { | |
| .map_err(|_| StorageError::Lock("storage mutex poisoned".to_string())) | ||
| } | ||
|
|
||
| /// Best-effort export + notification to the backup manager, if one is set. | ||
| /// Exports a plaintext vault snapshot and notifies the registered backup | ||
| /// manager via [`on_vault_changed`](WalletKitBackupManager::on_vault_changed). | ||
| /// | ||
| /// Called after vault mutations and by [`sync_backup`](Self::sync_backup). | ||
| /// Returns `Ok(())` if no backup manager is configured (noop). | ||
| /// | ||
| /// Called after any vault mutation (store, delete) so the host app can | ||
| /// sync the updated vault to its backup. Failures are logged but never | ||
| /// propagated — the vault mutation has already succeeded and callers | ||
| /// should not see an error from a backup side-effect. | ||
| fn notify_vault_changed(&self) { | ||
| /// **Note:** errors from the backup callback are propagated to the caller. | ||
| /// Because this runs *after* the vault mutation has been committed, a | ||
| /// returned `Err` does not mean the mutation failed — only the backup | ||
| /// notification. Callers should inspect the error and handle it according | ||
| /// to its nature (e.g. log it, schedule a backup retry via | ||
| /// [`sync_backup`](Self::sync_backup), or surface it to the user) rather | ||
| /// than retrying the already-committed mutation. | ||
| fn notify_vault_changed(&self) -> StorageResult<()> { | ||
| // Hold the backup lock for the entire export+callback path. This | ||
| // serializes concurrent notifications so backups are delivered in | ||
| // mutation order. Recover the guard on poison — the manager is | ||
| // still valid after a prior panic. | ||
| let guard = self | ||
| .backup | ||
| .lock() | ||
| .unwrap_or_else(std::sync::PoisonError::into_inner); | ||
| // mutation order. | ||
| let guard = self.backup.lock().map_err(|_| { | ||
| StorageError::Lock("backup config mutex poisoned".to_string()) | ||
thomas-waite marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| })?; | ||
|
|
||
| let dest_dir = guard.dest_dir(); | ||
| if dest_dir.is_empty() { | ||
| return; // NoopBackupManager — nothing to do. | ||
| return Ok(()); // NoopBackupManager — nothing to do. | ||
thomas-waite marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Export a plaintext snapshot of the vault. The file is sensitive | ||
| // (unencrypted), so we wrap it in a guard that deletes it on drop — | ||
| // no matter how we exit (normal return, early return, or panic). | ||
| let vault_path = match self | ||
| let vault_path = self | ||
| .lock_inner() | ||
| .and_then(|inner| inner.export_vault_for_backup(&dest_dir)) | ||
| { | ||
| Ok(path) => path, | ||
| Err(e) => { | ||
| tracing::error!("Failed to export vault for backup: {e}"); | ||
| return; | ||
| } | ||
| }; | ||
| .and_then(|inner| inner.export_vault_for_backup(&dest_dir))?; | ||
|
|
||
| let _cleanup = { | ||
| struct CleanupFile(String); | ||
|
|
@@ -371,9 +383,7 @@ impl CredentialStore { | |
| // Hand the path to the host app (e.g. iOS) so it can copy/upload | ||
| // the vault to Bedrock. The host must finish with the file during | ||
| // this synchronous call — the guard deletes it on return. | ||
| if let Err(e) = guard.on_vault_changed(vault_path) { | ||
| tracing::error!("Backup manager on_vault_changed failed: {e}"); | ||
| } | ||
| guard.on_vault_changed(vault_path) | ||
| } | ||
|
|
||
| /// Retrieves a full credential including raw bytes by issuer schema ID. | ||
|
|
@@ -1508,4 +1518,58 @@ mod tests { | |
| cleanup_test_storage(&root); | ||
| cleanup_test_storage(&export_dir); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_sync_backup_triggers_notification_without_mutation() { | ||
| use world_id_core::Credential as CoreCredential; | ||
|
|
||
| let (store, manager, root, export_dir) = setup_store_with_backup(); | ||
|
|
||
| // Store a credential so the vault is non-empty. | ||
| let cred: Credential = CoreCredential::new() | ||
| .issuer_schema_id(100) | ||
| .genesis_issued_at(1000) | ||
| .into(); | ||
| store | ||
| .store_credential(&cred, &FieldElement::from(7u64), 9999, None, 1000) | ||
| .expect("store credential"); | ||
| assert_eq!(manager.call_count(), 1); | ||
|
|
||
| // sync_backup triggers a notification without mutating the vault. | ||
| store.sync_backup().expect("sync_backup"); | ||
| assert_eq!(manager.call_count(), 2); | ||
| assert!( | ||
| manager.last_file_existed(), | ||
| "exported vault file should exist during the callback" | ||
| ); | ||
| let path = manager.last_path().unwrap(); | ||
| assert!( | ||
| !std::path::Path::new(&path).exists(), | ||
| "exported vault file should be cleaned up after the callback" | ||
| ); | ||
|
|
||
| // Vault contents are unchanged — still the same credential. | ||
| let credentials = store.list_credentials(None, 1000).expect("list"); | ||
| assert_eq!(credentials.len(), 1); | ||
| assert_eq!(credentials[0].issuer_schema_id, 100); | ||
|
|
||
| cleanup_test_storage(&root); | ||
| cleanup_test_storage(&export_dir); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_sync_backup_on_empty_vault() { | ||
| let (store, manager, root, export_dir) = setup_store_with_backup(); | ||
|
|
||
| // sync_backup on an empty vault should still trigger a notification. | ||
| store.sync_backup().expect("sync_backup"); | ||
| assert_eq!(manager.call_count(), 1); | ||
| assert!( | ||
| manager.last_file_existed(), | ||
| "exported vault file should exist during the callback" | ||
| ); | ||
|
|
||
| cleanup_test_storage(&root); | ||
| cleanup_test_storage(&export_dir); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,9 +121,15 @@ pub trait WalletKitBackupManager: Send + Sync { | |
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returning `Err` is treated as best-effort — the error is logged but | ||
| /// does not affect the vault mutation that triggered this call. Returning | ||
| /// `Result` (rather than `()`) ensures that host-side exceptions are | ||
| /// translated into a Rust `Err` by `UniFFI` instead of panicking. | ||
| /// Returning `Err` propagates the error to the caller of the vault | ||
|
||
| /// mutation (e.g. `store_credential`, `delete_credential`). Because the | ||
| /// vault mutation has already been committed by the time this callback | ||
| /// runs, callers should be aware that an `Err` result does **not** mean | ||
| /// the mutation failed — only that the backup notification failed. | ||
| /// Callers should inspect the returned error and handle it according to | ||
| /// its nature (e.g. log it, schedule a backup retry via | ||
| /// [`sync_backup`](crate::storage::CredentialStore::sync_backup), or | ||
| /// surface it to the user) rather than retrying the already-committed | ||
| /// mutation. | ||
| fn on_vault_changed(&self, vault_file_path: String) -> StorageResult<()>; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.