Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_)

Expand Down
142 changes: 142 additions & 0 deletions components/logins/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
let ids: Vec<String> = 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need the second query? I would have thought the loginsL one would be enough. I'm guessing I'm missing something, could you add a comment explaining. The same applies to delete_all_except_fxa.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very good question which I have to admin I cannot answer. I looked at the get_all query and replicated it for the needs here. Throughout the code we union against both tables. 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's the way logins work: https://github.com/mozilla/application-services/blob/main/components/logins/src/db.rs#L7-L24

When we want to fetch a record, we need to look in both loginsL and loginsM for the data.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, so if a record isn't updated then there won't be a row in loginsL. I always forget that and picture it working the opposite way. This all makes sense and seems good to merge to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this bites me regularly too. It's "clever", but borders on "too clever" 😅

[],
|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<Vec<String>> {
let ids: Vec<String> = 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<Vec<bool>> {
Expand Down Expand Up @@ -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<usize> {
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))
}
Expand Down Expand Up @@ -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::<String>::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();
Expand Down
7 changes: 7 additions & 0 deletions components/logins/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions components/logins/src/logins.udl
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ interface LoginStore {
[Throws=LoginsApiError, Self=ByArc]
sequence<boolean> delete_many(sequence<string> ids);

/// Delete all logins. Returns the ids of the deleted logins.
[Throws=LoginsApiError]
sequence<string> delete_all();

/// Delete all logins except the FxA session-credentials login. Returns the
/// ids of the deleted logins.
[Throws=LoginsApiError]
sequence<string> 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.
Expand All @@ -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();

Expand Down
16 changes: 16 additions & 0 deletions components/logins/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ impl LoginStore {
self.lock_db()?.delete_many(ids)
}

#[handle_error(Error)]
pub fn delete_all(&self) -> ApiResult<Vec<String>> {
self.lock_db()?.delete_all()
}

#[handle_error(Error)]
pub fn delete_all_except_fxa(&self) -> ApiResult<Vec<String>> {
self.lock_db()?.delete_all_except_fxa()
}

#[handle_error(Error)]
pub fn delete_undecryptable_records_for_remote_replacement(
self: Arc<Self>,
Expand All @@ -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<Self>) -> ApiResult<()> {
// Reset should not exist here - all resets should be done via the
Expand Down
9 changes: 1 addition & 8 deletions components/logins/src/sync/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<LoginStore>,
Expand Down