Repository navigation
fix(wallet): safe upgrade backups with time-based retention and guarded file deletion #994
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
2433495
c2b7e45
61708ac
095f8c8
38d872f
2b9e407
3c6dde4
7ab12b8
dcb6d8d
14f08fb
65d064a
f9081f3
9624d01
8d2d905
0d1c6a5
e64ba3b
5058b11
89e77f3
820b5a6
430a1ae
72924cd
4dd746c
e3773e0
87d8260
81cdf8a
0c66764
fbbb7f7
0c9e4f9
3fc061d
ea999c4
4046287
15ef632
5ff47a7
c122e55
660e4b3
eea9e76
681ddac
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 |
|---|---|---|
|
|
@@ -188,15 +188,27 @@ pub enum TaskError { | |
| /// An identity-funding account was derived but could not be saved. Saving | ||
| /// it is what lets a restart find the account again, so the operation is | ||
| /// stopped rather than left able to strand a funding lock the app could no | ||
| /// longer spend. The technical cause lives in `Debug` and the logs. | ||
| /// longer spend. Covers non-retryable store failures (constraint, fatal); | ||
| /// retryable ones use [`Self::IdentityFundingAccountPersistBusy`]. | ||
| #[error( | ||
| "Your wallet could not save the account this payment needs. Check that your disk is not full, then try again." | ||
| "Your wallet could not save the information it needs for this payment. Restart the application and try again." | ||
| )] | ||
| IdentityFundingAccountPersistFailed { | ||
| #[source] | ||
| source: Box<platform_wallet::changeset::PersistenceError>, | ||
| }, | ||
|
|
||
| /// Like [`Self::IdentityFundingAccountPersistFailed`], but the store kept | ||
| /// reporting a retryable condition until the retry budget ran out. Upstream | ||
| /// classifies a busy database and a full disk alike, so the copy names both. | ||
| #[error( | ||
| "Your wallet could not save the information it needs for this payment because its data is busy or the disk is full. Close any other copy of Dash Evo Tool, make sure there is free disk space, and try again." | ||
| )] | ||
| IdentityFundingAccountPersistBusy { | ||
| #[source] | ||
| source: Box<platform_wallet::changeset::PersistenceError>, | ||
| }, | ||
|
|
||
| /// Single-key wallets are not supported in this version. Their data is | ||
| /// preserved; HD (recovery-phrase) wallets remain fully functional. | ||
| #[error( | ||
|
|
@@ -423,6 +435,15 @@ pub enum TaskError { | |
| source: platform_wallet_storage::WalletStorageError, | ||
| }, | ||
|
|
||
| /// Another process currently owns the wallet database write lock. | ||
| #[error( | ||
| "Your wallet data is open in another Dash Evo Tool window or command-line session. Close it and try again." | ||
| )] | ||
| WalletStorageInUse { | ||
| #[source] | ||
| source: platform_wallet_storage::WalletStorageError, | ||
| }, | ||
|
|
||
| /// A pinned-PR wallet database could not be upgraded without losing data. | ||
| #[error(transparent)] | ||
| PlatformDatabaseUpgrade { | ||
|
|
@@ -473,11 +494,11 @@ pub enum TaskError { | |
| /// | ||
| /// The migration diagnostic is preserved through the `#[source]` chain for | ||
| /// logs and the `Debug` view; it is kept out of the user-facing `Display` | ||
| /// copy. On the active development branch the storage layout changed in an | ||
| /// incompatible way, so the practical action is to remove the local wallet | ||
| /// data and let the app recreate it (see `docs/kv-keys.md`). | ||
| /// copy. The copy names only the rebuildable `.sqlite` stores and tells the | ||
| /// user to keep the `secrets` folder: the vault holds imported and | ||
| /// masternode keys that no recovery phrase can re-derive. | ||
| #[error( | ||
| "Your wallet data is not compatible with this version of the app and cannot be opened. Remove the local wallet data so the app can create it fresh, then restart." | ||
| "Your wallet data was saved by an app version that this version cannot open, so it was left unchanged. Your recovery phrases and keys are still stored on this device. First open the app version you used before and write down the recovery phrase of every wallet. Separately, write down each imported private key. Close all Dash Evo Tool windows and command-line sessions. Then move each .sqlite file together with its matching .sqlite-wal and .sqlite-shm files, if present, out of the app data folder, leave the secrets folder where it is, and start this app again." | ||
| )] | ||
|
lklimek marked this conversation as resolved.
Resolved
|
||
| WalletDataIncompatible { | ||
| #[source] | ||
|
|
@@ -1240,7 +1261,7 @@ pub enum TaskError { | |
| /// The identity and its private keys are gone, but at least one optional | ||
| /// owner-scoped sidecar could not be removed. | ||
| #[error( | ||
| "The identity was removed, but some DashPay or token-list data may still be stored on this device. The app will keep trying to clear this local data automatically." | ||
| "The identity was removed, but some local identity data or upgrade backups may still be stored on this device. The app will keep trying to clear this local data automatically." | ||
| )] | ||
| IdentitySidecarCleanupIncomplete, | ||
|
|
||
|
|
@@ -2833,7 +2854,7 @@ impl TaskError { | |
|
|
||
| /// Map a wallet-storage open failure to the right user-facing variant. | ||
| /// | ||
| /// Three storage failures get honest, distinct copy; everything else keeps | ||
| /// Four storage failures get honest, distinct copy; everything else keeps | ||
| /// the generic disk/IO message: | ||
| /// | ||
| /// - A forward-version database (written by a newer build, schema beyond | ||
|
|
@@ -2843,14 +2864,19 @@ impl TaskError { | |
| /// - A divergent migration history (e.g. a database written under an | ||
| /// earlier, incompatible storage layout that this build's migrations | ||
| /// cannot reconcile) is surfaced as [`Self::WalletDataIncompatible`] so | ||
| /// the banner tells the user to remove the local wallet data — freeing | ||
| /// disk space or restarting never resolves a structural mismatch. | ||
| /// the banner tells the user to back up keys and set the databases aside | ||
| /// while keeping the vault — freeing disk space or restarting never | ||
| /// resolves a structural mismatch. | ||
| /// - A migration blocked by another SQLite writer is surfaced as | ||
| /// [`Self::WalletStorageInUse`] so the user can close the competing | ||
| /// process and retry instead of following incompatible-data recovery. | ||
| /// - Every other storage failure keeps the generic disk/IO copy via | ||
| /// [`Self::WalletStorage`]. | ||
| /// | ||
| /// Discrimination is on the typed upstream variant | ||
| /// (`WalletStorageError::SchemaVersionUnsupported` / | ||
| /// `WalletStorageError::Migration`), never on its `Display` text. | ||
| /// `WalletStorageError::Migration`) and the typed migration source chain, | ||
| /// never on `Display` text. | ||
| pub fn from_wallet_storage_open_error( | ||
| source: platform_wallet_storage::WalletStorageError, | ||
| ) -> Self { | ||
|
Comment on lines
3041
to
3043
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SEC-001 · MEDIUM · BLOCKING — A permission-denied / read-only migration failure still gets the terminal "move your .sqlite files aside" guidance A permission hiccup and you're already handing the user a six-step 'write down your recovery phrases' eulogy? |
||
|
|
@@ -2862,13 +2888,40 @@ impl TaskError { | |
| found, | ||
| max_supported, | ||
| }, | ||
| other @ platform_wallet_storage::WalletStorageError::Migration(_) | ||
| if Self::wallet_storage_error_is_in_use(&other) => | ||
| { | ||
| Self::WalletStorageInUse { source: other } | ||
| } | ||
| other @ platform_wallet_storage::WalletStorageError::Migration(_) => { | ||
|
lklimek marked this conversation as resolved.
Resolved
|
||
| Self::WalletDataIncompatible { source: other } | ||
| } | ||
|
Claudius-Maginificent marked this conversation as resolved.
Resolved
|
||
| other => Self::WalletStorage { source: other }, | ||
|
Claudius-Maginificent marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| fn wallet_storage_error_is_in_use( | ||
| source: &platform_wallet_storage::WalletStorageError, | ||
| ) -> bool { | ||
| let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(source); | ||
| while let Some(error) = cause { | ||
| if error | ||
| .downcast_ref::<rusqlite::Error>() | ||
| .and_then(rusqlite::Error::sqlite_error_code) | ||
| .is_some_and(|code| { | ||
| matches!( | ||
| code, | ||
| rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked | ||
| ) | ||
| }) | ||
| { | ||
| return true; | ||
| } | ||
| cause = error.source(); | ||
| } | ||
| false | ||
| } | ||
|
|
||
| /// Returns `true` when this is a [`Self::SecretStore`] open failure caused | ||
| /// specifically by a vault passphrase mismatch | ||
| /// ([`SecretStoreError::WrongPassphrase`]). | ||
|
|
@@ -5811,10 +5864,45 @@ mod tests { | |
| .expect_err("divergent checksum must abort") | ||
| } | ||
|
|
||
| fn busy_migration_error() -> refinery::Error { | ||
| use refinery::{Migration, Runner}; | ||
|
|
||
| let dir = tempfile::tempdir().expect("temporary directory"); | ||
| let path = dir.path().join("wallet.sqlite"); | ||
| let writer = rusqlite::Connection::open(&path).expect("writer connection"); | ||
| writer | ||
| .execute_batch("BEGIN IMMEDIATE; CREATE TABLE held (id INTEGER);") | ||
| .expect("hold write lock"); | ||
| let mut contender = rusqlite::Connection::open(&path).expect("contender connection"); | ||
| contender | ||
| .busy_timeout(std::time::Duration::from_millis(1)) | ||
| .expect("short deterministic timeout"); | ||
| let migration = Migration::unapplied("V1__init", "CREATE TABLE data (id INTEGER);") | ||
| .expect("valid migration"); | ||
| Runner::new(&[migration]) | ||
| .run(&mut contender) | ||
| .expect_err("held writer must make the migration busy") | ||
| } | ||
|
|
||
| #[test] | ||
| fn busy_migration_error_maps_to_retryable_wallet_storage_message() { | ||
| let upstream = | ||
| platform_wallet_storage::WalletStorageError::Migration(busy_migration_error()); | ||
| let err = TaskError::from_wallet_storage_open_error(upstream); | ||
| assert!( | ||
| matches!(err, TaskError::WalletStorageInUse { .. }), | ||
| "Expected WalletStorageInUse, got: {err:?}" | ||
| ); | ||
| let message = err.to_string(); | ||
| assert!(message.contains("Close") && message.contains("try again")); | ||
| assert!(!message.contains("incompatible")); | ||
| assert!(std::error::Error::source(&err).is_some()); | ||
| } | ||
|
|
||
| /// A divergent migration history (database written under an | ||
| /// incompatible storage layout) maps to the dedicated | ||
| /// `WalletDataIncompatible` variant. Its `Display` tells the user to | ||
| /// remove the local wallet data, NOT the misleading "free disk space" copy. | ||
| /// `WalletDataIncompatible` variant. Its `Display` must never lead the user | ||
| /// to destroy the secrets vault, and must not blame disk space. | ||
| #[test] | ||
| fn migration_error_maps_to_wallet_data_incompatible() { | ||
| let upstream = | ||
|
|
@@ -5826,9 +5914,18 @@ mod tests { | |
| ); | ||
|
|
||
| let msg = err.to_string(); | ||
| assert!(msg.contains(".sqlite-wal") && msg.contains(".sqlite-shm")); | ||
| assert!(msg.contains("Close all") && msg.contains("write down each imported private key")); | ||
| assert!( | ||
| msg.contains("write down the recovery phrase") | ||
| && msg.contains(".sqlite") | ||
| && msg.contains("leave the secrets folder where it is"), | ||
| "Expected scoped, vault-preserving guidance, got: {msg}" | ||
| ); | ||
| let lower = msg.to_lowercase(); | ||
| assert!( | ||
| msg.contains("not compatible") && msg.contains("Remove"), | ||
| "Expected incompatibility guidance, got: {msg}" | ||
| !lower.contains("remove") && !lower.contains("delete"), | ||
| "Incompatible-data guidance must not tell the user to delete anything, got: {msg}" | ||
| ); | ||
| assert!( | ||
| !msg.contains("disk space"), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -230,14 +230,16 @@ fn identity_load_ticket(task: &BackendTask) -> Option<(Identifier, IdentityLoadT | |
| } | ||
|
|
||
| /// Whether a wallet-backend build error is terminal (storage written by a | ||
| /// newer/incompatible app build). These must surface their actionable | ||
| /// message instead of being logged-and-discarded as a transient deferral | ||
| /// (F50); every other init error is retried by the cold-boot bridge. | ||
| /// newer/incompatible app build, or a compatibility upgrade that fails the | ||
| /// same way on every attempt). These must surface their actionable message | ||
| /// instead of being logged-and-discarded as a transient deferral (F50); every | ||
| /// other init error is retried by the cold-boot bridge. | ||
| pub(crate) fn is_terminal_storage_open_error(error: &TaskError) -> bool { | ||
| matches!( | ||
| error, | ||
| TaskError::WalletDataTooNew { .. } | TaskError::WalletDataIncompatible { .. } | ||
| ) | ||
| match error { | ||
| TaskError::WalletDataTooNew { .. } | TaskError::WalletDataIncompatible { .. } => true, | ||
| TaskError::PlatformDatabaseUpgrade { source } => !source.is_retryable(), | ||
| _ => false, | ||
| } | ||
|
lklimek marked this conversation as resolved.
Resolved
|
||
| } | ||
|
Comment on lines
238
to
246
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SEC-004 · MEDIUM —
|
||
|
|
||
| /// Information about fees for a platform state transition. | ||
|
|
@@ -2331,6 +2333,24 @@ mod tests { | |
| )), | ||
| } | ||
| )); | ||
| use crate::wallet_backend::platform_compatibility::UpgradeError; | ||
| for source in [ | ||
| UpgradeError::Unrecognized, | ||
| UpgradeError::IdentityRoster { source: None }, | ||
| UpgradeError::Verification, | ||
| UpgradeError::TypedValidation(Box::new(std::io::Error::other("fixture"))), | ||
| ] { | ||
| assert!( | ||
| is_terminal_storage_open_error(&TaskError::PlatformDatabaseUpgrade { source }), | ||
| "a deterministic upgrade failure must surface instead of re-running" | ||
| ); | ||
| } | ||
| // A user-clearable upgrade failure keeps offering a retry. | ||
| assert!(!is_terminal_storage_open_error( | ||
| &TaskError::PlatformDatabaseUpgrade { | ||
| source: UpgradeError::from(std::io::Error::from(std::io::ErrorKind::StorageFull)), | ||
| } | ||
| )); | ||
| // A transient pre-wire state must NOT be treated as terminal. | ||
| assert!(!is_terminal_storage_open_error( | ||
| &TaskError::WalletBackendNotYetWired | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.