diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be15c65e0..1455f48c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ ### Logins - Add `LoginStore.bridgedEngine()`, which exposes the logins sync engine to Desktop's Sync. ([bug 2049263](https://bugzilla.mozilla.org/show_bug.cgi?id=2049263)) +- Add `LoginStore.delete_all()`, which deletes all logins + and `delete_all_axcept_fxa()`, which deletes all logins preserving the FxA session-credentials login + and `LoginStore.wipe_local_except_fxa()`, a variant of `wipe_local()` that preserves the FxA session-credentials login + ([#7467](https://github.com/mozilla/application-services/pull/7467)) ([Bug 2053557](https://bugzilla.mozilla.org/show_bug.cgi?id=2053557)) # v153.0 (_2026-06-15_) diff --git a/components/logins/src/db.rs b/components/logins/src/db.rs index 0bafc7f2f0..663af40cca 100644 --- a/components/logins/src/db.rs +++ b/components/logins/src/db.rs @@ -874,6 +874,33 @@ impl LoginDb { Ok(results.pop().expect("there should be a single result")) } + // Delete all records. Return an array with the ids of the deleted logins + pub fn delete_all(&self) -> Result> { + let ids: Vec = self.db.query_rows_and_then_cached( + "SELECT guid FROM loginsL WHERE is_deleted = 0 + UNION ALL + SELECT guid FROM loginsM WHERE is_overridden = 0", + [], + |row| row.get(0), + )?; + self.delete_many(ids.iter().map(String::as_str).collect())?; + Ok(ids) + } + + // Delete all records, except the FxA login. Return an array with the ids of + // the deleted logins + pub fn delete_all_except_fxa(&self) -> Result> { + let ids: Vec = self.db.query_rows_and_then_cached( + "SELECT guid FROM loginsL WHERE is_deleted = 0 AND origin != :fxa_origin + UNION ALL + SELECT guid FROM loginsM WHERE is_overridden = 0 AND origin != :fxa_origin", + named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN }, + |row| row.get(0), + )?; + self.delete_many(ids.iter().map(String::as_str).collect())?; + Ok(ids) + } + /// Delete the records with the specified IDs. Returns a list of Boolean values /// indicating whether the respective records already existed. pub fn delete_many(&self, ids: Vec<&str>) -> Result> { @@ -1033,6 +1060,25 @@ impl LoginDb { Ok(row_count) } + /// Wipe all local data except the FxA login, returns the number of rows deleted + pub fn wipe_local_except_fxa(&self) -> Result { + info!("Executing wipe_local_except_fxa on password engine!"); + let tx = self.unchecked_transaction()?; + let mut row_count = 0; + row_count += self.execute( + "DELETE FROM loginsL WHERE origin != :fxa_origin", + named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN }, + )?; + row_count += self.execute( + "DELETE FROM loginsM WHERE origin != :fxa_origin", + named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN }, + )?; + row_count += self.execute("DELETE FROM loginsSyncMeta", [])?; + row_count += self.execute("DELETE FROM breachesL", [])?; + tx.commit()?; + Ok(row_count) + } + pub fn shutdown(self) -> Result<()> { self.db.close().map_err(|(_, e)| Error::SqlError(e)) } @@ -2050,6 +2096,102 @@ mod tests { assert!(!result[0]); } + #[test] + fn test_delete_all() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + let login_a = db + .add(LoginEntry { + origin: "https://a.example.com".into(), + http_realm: Some("https://www.example.com".into()), + username: "test_user".into(), + password: "test_password".into(), + ..Default::default() + }) + .unwrap(); + let login_b = db + .add(LoginEntry { + origin: "https://b.example.com".into(), + http_realm: Some("https://www.example.com".into()), + username: "test_user".into(), + password: "test_password".into(), + ..Default::default() + }) + .unwrap(); + + let mut deleted = db.delete_all().unwrap(); + deleted.sort(); + let mut expected = vec![login_a.meta.id.clone(), login_b.meta.id.clone()]; + expected.sort(); + assert_eq!(deleted, expected); + assert!(!db.exists(login_a.guid_str()).unwrap()); + assert!(!db.exists(login_b.guid_str()).unwrap()); + + // On an empty database it's a no-op returning no ids. + assert_eq!(db.delete_all().unwrap(), Vec::::new()); + } + + #[test] + fn test_delete_all_except_fxa() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + let login = db + .add(LoginEntry { + origin: "https://a.example.com".into(), + http_realm: Some("https://www.example.com".into()), + username: "test_user".into(), + password: "test_password".into(), + ..Default::default() + }) + .unwrap(); + let fxa_login = db + .add(LoginEntry { + origin: FXA_CREDENTIALS_ORIGIN.into(), + http_realm: Some("https://www.example.com".into()), + username: "test_user".into(), + password: "test_password".into(), + ..Default::default() + }) + .unwrap(); + + let deleted = db.delete_all_except_fxa().unwrap(); + assert_eq!(deleted, vec![login.meta.id.clone()]); + + // Only the FxA login remains. + assert!(!db.exists(login.guid_str()).unwrap()); + assert!(db.exists(fxa_login.guid_str()).unwrap()); + } + + #[test] + fn test_wipe_local_except_fxa() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + let login = db + .add(LoginEntry { + origin: "https://a.example.com".into(), + http_realm: Some("https://www.example.com".into()), + username: "test_user".into(), + password: "test_password".into(), + ..Default::default() + }) + .unwrap(); + let fxa_login = db + .add(LoginEntry { + origin: FXA_CREDENTIALS_ORIGIN.into(), + http_realm: Some("https://www.example.com".into()), + username: "test_user".into(), + password: "test_password".into(), + ..Default::default() + }) + .unwrap(); + + db.wipe_local_except_fxa().unwrap(); + + // Only the FxA login remains. + assert!(!db.exists(login.guid_str()).unwrap()); + assert!(db.exists(fxa_login.guid_str()).unwrap()); + } + #[test] fn test_delete_local_for_remote_replacement() { ensure_initialized(); diff --git a/components/logins/src/login.rs b/components/logins/src/login.rs index afef2c0fd1..22bfdfe209 100644 --- a/components/logins/src/login.rs +++ b/components/logins/src/login.rs @@ -284,6 +284,13 @@ use serde_derive::*; use sync_guid::Guid; use url::Url; +// The Desktop FxA session-credentials pseudo-login. Firefox stores its account +// credentials as a login under this origin; it must never be synced. This +// mirrors the exclusion the JS `PasswordEngine` does via +// `Utils.getSyncCredentialsHosts()`. Only relevant on Desktop (mobile never has +// such a login), but it's harmless to filter everywhere. +pub(crate) const FXA_CREDENTIALS_ORIGIN: &str = "chrome://FirefoxAccounts"; + // LoginEntry fields that are stored in cleartext #[derive(Debug, Clone, Hash, PartialEq, Eq, Default)] pub struct LoginFields { diff --git a/components/logins/src/logins.udl b/components/logins/src/logins.udl index 3feea9642d..4d6cb02ba3 100644 --- a/components/logins/src/logins.udl +++ b/components/logins/src/logins.udl @@ -199,6 +199,15 @@ interface LoginStore { [Throws=LoginsApiError, Self=ByArc] sequence delete_many(sequence ids); + /// Delete all logins. Returns the ids of the deleted logins. + [Throws=LoginsApiError] + sequence delete_all(); + + /// Delete all logins except the FxA session-credentials login. Returns the + /// ids of the deleted logins. + [Throws=LoginsApiError] + sequence delete_all_except_fxa(); + /// Clear out locally stored logins data /// /// If sync is enabled, then we will try to recover the data on the next sync. @@ -213,6 +222,10 @@ interface LoginStore { [Throws=LoginsApiError] void wipe_local(); + /// Like `wipe_local`, but preserves the FxA session-credentials login. + [Throws=LoginsApiError] + void wipe_local_except_fxa(); + [Throws=LoginsApiError, Self=ByArc] void reset(); diff --git a/components/logins/src/store.rs b/components/logins/src/store.rs index eba7366c89..b347376a06 100644 --- a/components/logins/src/store.rs +++ b/components/logins/src/store.rs @@ -226,6 +226,16 @@ impl LoginStore { self.lock_db()?.delete_many(ids) } + #[handle_error(Error)] + pub fn delete_all(&self) -> ApiResult> { + self.lock_db()?.delete_all() + } + + #[handle_error(Error)] + pub fn delete_all_except_fxa(&self) -> ApiResult> { + self.lock_db()?.delete_all_except_fxa() + } + #[handle_error(Error)] pub fn delete_undecryptable_records_for_remote_replacement( self: Arc, @@ -249,6 +259,12 @@ impl LoginStore { Ok(()) } + #[handle_error(Error)] + pub fn wipe_local_except_fxa(&self) -> ApiResult<()> { + self.lock_db()?.wipe_local_except_fxa()?; + Ok(()) + } + #[handle_error(Error)] pub fn reset(self: Arc) -> ApiResult<()> { // Reset should not exist here - all resets should be done via the diff --git a/components/logins/src/sync/engine.rs b/components/logins/src/sync/engine.rs index c1e1a538d2..81511ea8eb 100644 --- a/components/logins/src/sync/engine.rs +++ b/components/logins/src/sync/engine.rs @@ -8,7 +8,7 @@ use super::SyncStatus; use crate::db::CLONE_ENTIRE_MIRROR_SQL; use crate::encryption::EncryptorDecryptor; use crate::error::*; -use crate::login::EncryptedLogin; +use crate::login::{EncryptedLogin, FXA_CREDENTIALS_ORIGIN}; use crate::schema; use crate::util; use crate::LoginDb; @@ -24,13 +24,6 @@ use sync15::engine::{CollSyncIds, CollectionRequest, EngineSyncAssociation, Sync use sync15::{telemetry, ServerTimestamp}; use sync_guid::Guid; -// The Desktop FxA session-credentials pseudo-login. Firefox stores its account -// credentials as a login under this origin; it must never be synced. This -// mirrors the exclusion the JS `PasswordEngine` does via -// `Utils.getSyncCredentialsHosts()`. Only relevant on Desktop (mobile never has -// such a login), but it's harmless to filter everywhere. -const FXA_CREDENTIALS_ORIGIN: &str = "chrome://FirefoxAccounts"; - // The sync engine. pub struct LoginsSyncEngine { pub store: Arc,