logins/
db.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2* License, v. 2.0. If a copy of the MPL was not distributed with this
3* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5/// Logins DB handling
6///
7/// The logins database works differently than other components because "mirror" and "local" mean
8/// different things.  At some point we should probably refactor to make it match them, but here's
9/// how it works for now:
10///
11///   - loginsM is the mirror table, which means it stores what we believe is on the server.  This
12///     means either the last record we fetched from the server or the last record we uploaded.
13///   - loginsL is the local table, which means it stores local changes that have not been sent to
14///     the server.
15///   - When we want to fetch a record, we need to look in both loginsL and loginsM for the data.
16///     If a record is in both tables, then we prefer the loginsL data.  GET_BY_GUID_SQL contains a
17///     clever UNION query to accomplish this.
18///   - If a record is in both the local and mirror tables, we call the local record the "overlay"
19///     and set the is_overridden flag on the mirror record.
20///   - When we sync, the presence of a record in loginsL means that there was a local change that
21///     we need to send to the the server and/or reconcile it with incoming changes from the
22///     server.
23///   - After we sync, we move all records from loginsL to loginsM, overwriting any previous data.
24///     loginsL will be an empty table after this.  See mark_as_synchronized() for the details.
25use crate::error::*;
26use crate::login::*;
27use crate::schema;
28use crate::sync::SyncStatus;
29use crate::util;
30use db_crypto::EncryptorDecryptor;
31use interrupt_support::{SqlInterruptHandle, SqlInterruptScope};
32use lazy_static::lazy_static;
33use rusqlite::{
34    named_params,
35    types::{FromSql, ToSql},
36    Connection,
37};
38use sql_support::ConnExt;
39use std::ops::Deref;
40use std::path::Path;
41use std::sync::Arc;
42use std::time::SystemTime;
43use sync_guid::Guid;
44use url::{Host, Url};
45
46pub struct LoginDb {
47    pub db: Connection,
48    pub encdec: Arc<dyn EncryptorDecryptor>,
49    interrupt_handle: Arc<SqlInterruptHandle>,
50}
51
52pub struct LoginsDeletionMetrics {
53    pub local_deleted: u64,
54    pub mirror_deleted: u64,
55}
56
57impl LoginDb {
58    pub fn with_connection(db: Connection, encdec: Arc<dyn EncryptorDecryptor>) -> Result<Self> {
59        #[cfg(test)]
60        {
61            util::init_test_logging();
62        }
63
64        // `temp_store = 2` is required on Android to force the DB to keep temp
65        // files in memory, since on Android there's no tmp partition. See
66        // https://github.com/mozilla/mentat/issues/505. Ideally we'd only
67        // do this on Android, or allow caller to configure it.
68        db.set_pragma("temp_store", 2)?;
69
70        let mut logins = Self {
71            interrupt_handle: Arc::new(SqlInterruptHandle::new(&db)),
72            encdec,
73            db,
74        };
75        let tx = logins.db.transaction()?;
76        schema::init(&tx)?;
77        tx.commit()?;
78        Ok(logins)
79    }
80
81    pub fn open(path: impl AsRef<Path>, encdec: Arc<dyn EncryptorDecryptor>) -> Result<Self> {
82        Self::with_connection(Connection::open(path)?, encdec)
83    }
84
85    #[cfg(test)]
86    pub fn open_in_memory() -> Self {
87        let encdec: Arc<dyn EncryptorDecryptor> = crate::test_utils::TEST_ENCDEC.clone();
88        Self::with_connection(Connection::open_in_memory().unwrap(), encdec).unwrap()
89    }
90
91    pub fn new_interrupt_handle(&self) -> Arc<SqlInterruptHandle> {
92        Arc::clone(&self.interrupt_handle)
93    }
94
95    #[inline]
96    pub fn begin_interrupt_scope(&self) -> Result<SqlInterruptScope> {
97        Ok(self.interrupt_handle.begin_interrupt_scope()?)
98    }
99}
100
101impl ConnExt for LoginDb {
102    #[inline]
103    fn conn(&self) -> &Connection {
104        &self.db
105    }
106}
107
108impl Deref for LoginDb {
109    type Target = Connection;
110    #[inline]
111    fn deref(&self) -> &Connection {
112        &self.db
113    }
114}
115
116// login specific stuff.
117
118impl LoginDb {
119    pub(crate) fn put_meta(&self, key: &str, value: &dyn ToSql) -> Result<()> {
120        self.execute_cached(
121            "REPLACE INTO loginsSyncMeta (key, value) VALUES (:key, :value)",
122            named_params! { ":key": key, ":value": value },
123        )?;
124        Ok(())
125    }
126
127    pub(crate) fn get_meta<T: FromSql>(&self, key: &str) -> Result<Option<T>> {
128        self.try_query_row(
129            "SELECT value FROM loginsSyncMeta WHERE key = :key",
130            named_params! { ":key": key },
131            |row| Ok::<_, Error>(row.get(0)?),
132            true,
133        )
134    }
135
136    pub(crate) fn delete_meta(&self, key: &str) -> Result<()> {
137        self.execute_cached(
138            "DELETE FROM loginsSyncMeta WHERE key = :key",
139            named_params! { ":key": key },
140        )?;
141        Ok(())
142    }
143
144    pub fn count_all(&self) -> Result<i64> {
145        let mut stmt = self.db.prepare_cached(&COUNT_ALL_SQL)?;
146
147        let count: i64 = stmt.query_row([], |row| row.get(0))?;
148        Ok(count)
149    }
150
151    pub fn count_by_origin(&self, origin: &str) -> Result<i64> {
152        match LoginEntry::validate_and_fixup_origin(origin) {
153            Ok(result) => {
154                let origin = result.unwrap_or(origin.to_string());
155                let mut stmt = self.db.prepare_cached(&COUNT_BY_ORIGIN_SQL)?;
156                let count: i64 =
157                    stmt.query_row(named_params! { ":origin": origin }, |row| row.get(0))?;
158                Ok(count)
159            }
160            Err(e) => {
161                // don't log the input string as it's PII.
162                warn!("count_by_origin was passed an invalid origin: {}", e);
163                Ok(0)
164            }
165        }
166    }
167
168    pub fn count_by_form_action_origin(&self, form_action_origin: &str) -> Result<i64> {
169        match LoginEntry::validate_and_normalize_form_action_origin(form_action_origin) {
170            Ok(result) => {
171                let form_action_origin = result.unwrap_or(form_action_origin.to_string());
172                let mut stmt = self.db.prepare_cached(&COUNT_BY_FORM_ACTION_ORIGIN_SQL)?;
173                let count: i64 = stmt.query_row(
174                    named_params! { ":form_action_origin": form_action_origin },
175                    |row| row.get(0),
176                )?;
177                Ok(count)
178            }
179            Err(e) => {
180                // don't log the input string as it's PII.
181                warn!(
182                    "count_by_form_action_origin was passed an invalid origin: {}",
183                    e
184                );
185                Ok(0)
186            }
187        }
188    }
189
190    pub fn get_all(&self) -> Result<Vec<EncryptedLogin>> {
191        let mut stmt = self.db.prepare_cached(&GET_ALL_SQL)?;
192        let rows = stmt.query_and_then([], EncryptedLogin::from_row)?;
193        rows.collect::<Result<_>>()
194    }
195
196    /// Like `get_all()`, but only the logins with the given guids.  Guids we don't have a login
197    /// for are simply absent from the result, so this can return fewer rows than it was given
198    /// ids.  As with `get_all()` the order of the rows is whatever the query gives us - in
199    /// particular it is not the order of `ids`.
200    pub fn get_many(&self, ids: &[String]) -> Result<Vec<EncryptedLogin>> {
201        let mut logins = Vec::with_capacity(ids.len());
202        sql_support::each_chunk(ids, |chunk, _| -> Result<()> {
203            logins.extend(self.db.query_rows_and_then(
204                &format!(
205                    "SELECT * FROM ({}) WHERE guid IN ({})",
206                    &*GET_ALL_SQL,
207                    sql_support::repeat_sql_values(chunk.len())
208                ),
209                rusqlite::params_from_iter(chunk),
210                EncryptedLogin::from_row,
211            )?);
212            Ok(())
213        })?;
214        Ok(logins)
215    }
216
217    pub fn get_by_base_domain(&self, base_domain: &str) -> Result<Vec<EncryptedLogin>> {
218        // We first parse the input string as a host so it is normalized.
219        let base_host = match Host::parse(base_domain) {
220            Ok(d) => d,
221            Err(e) => {
222                // don't log the input string as it's PII.
223                warn!("get_by_base_domain was passed an invalid domain: {}", e);
224                return Ok(vec![]);
225            }
226        };
227        // We just do a linear scan. Another option is to have an indexed
228        // reverse-host column or similar, but current thinking is that it's
229        // extra complexity for (probably) zero actual benefit given the record
230        // counts are expected to be so low.
231        // A regex would probably make this simpler, but we don't want to drag
232        // in a regex lib just for this.
233        let mut stmt = self.db.prepare_cached(&GET_ALL_SQL)?;
234        let rows = stmt
235            .query_and_then([], EncryptedLogin::from_row)?
236            .filter(|r| {
237                let login = r
238                    .as_ref()
239                    .ok()
240                    .and_then(|login| Url::parse(&login.fields.origin).ok());
241                let this_host = login.as_ref().and_then(|url| url.host());
242                match (&base_host, this_host) {
243                    (Host::Domain(base), Some(Host::Domain(look))) => {
244                        // a fairly long-winded way of saying
245                        // `login.fields.origin == base_domain ||
246                        //  login.fields.origin.ends_with('.' + base_domain);`
247                        let mut rev_input = base.chars().rev();
248                        let mut rev_host = look.chars().rev();
249                        loop {
250                            match (rev_input.next(), rev_host.next()) {
251                                (Some(ref a), Some(ref b)) if a == b => continue,
252                                (None, None) => return true, // exactly equal
253                                (None, Some(ref h)) => return *h == '.',
254                                _ => return false,
255                            }
256                        }
257                    }
258                    // ip addresses must match exactly.
259                    (Host::Ipv4(base), Some(Host::Ipv4(look))) => *base == look,
260                    (Host::Ipv6(base), Some(Host::Ipv6(look))) => *base == look,
261                    // all "mismatches" in domain types are false.
262                    _ => false,
263                }
264            });
265        rows.collect::<Result<_>>()
266    }
267
268    pub fn get_by_id(&self, id: &str) -> Result<Option<EncryptedLogin>> {
269        self.try_query_row(
270            &GET_BY_GUID_SQL,
271            &[(":guid", &id as &dyn ToSql)],
272            EncryptedLogin::from_row,
273            true,
274        )
275    }
276
277    // Match a `LoginEntry` being saved to existing logins in the DB
278    //
279    // When a user is saving new login, there are several cases for how we want to save the data:
280    //
281    //  - Adding a new login: `None` will be returned
282    //  - Updating an existing login: `Some(login)` will be returned and the username will match
283    //    the one for look.
284    //  - Filling in a blank username for an existing login: `Some(login)` will be returned
285    //    with a blank username.
286    //
287    //  Returns an Err if the new login is not valid and could not be fixed up
288    pub fn find_login_to_update(&self, look: LoginEntry) -> Result<Option<Login>> {
289        let look = look.fixup()?;
290        let logins = self
291            .get_by_entry_target(&look)?
292            .into_iter()
293            .map(|enc_login| enc_login.decrypt(self.encdec.as_ref()))
294            .collect::<Result<Vec<Login>>>()?;
295        Ok(logins
296            // First, try to match the username
297            .iter()
298            .find(|login| login.username == look.username)
299            // Fall back on a blank username
300            .or_else(|| logins.iter().find(|login| login.username.is_empty()))
301            // Clone the login to avoid ref issues when returning across the FFI
302            .cloned())
303    }
304
305    pub fn touch(&self, id: &str) -> Result<()> {
306        let tx = self.unchecked_transaction()?;
307        self.ensure_local_overlay_exists(id)?;
308        self.mark_mirror_overridden(id)?;
309        let now_ms = util::system_time_ms_i64(SystemTime::now());
310        // As on iOS, just using a record doesn't flip it's status to changed.
311        // TODO: this might be wrong for lockbox!
312        self.execute_cached(
313            "UPDATE loginsL
314             SET timeLastUsed = :now_millis,
315                 timesUsed = timesUsed + 1,
316                 local_modified = :now_millis
317             WHERE guid = :guid
318                 AND is_deleted = 0",
319            named_params! {
320                ":now_millis": now_ms,
321                ":guid": id,
322            },
323        )?;
324        tx.commit()?;
325        Ok(())
326    }
327
328    /// Records passwords in the breachesL table for password reuse detection.
329    ///
330    /// Encrypts and stores passwords, automatically filtering out duplicates.
331    /// Used by `add_many_with_meta()` to populate the breach database during import.
332    pub fn record_potentially_vulnerable_passwords(&self, passwords: Vec<String>) -> Result<()> {
333        let tx = self.unchecked_transaction()?;
334        self.insert_potentially_vulnerable_passwords(passwords)?;
335        tx.commit()?;
336        Ok(())
337    }
338
339    fn insert_potentially_vulnerable_passwords(&self, passwords: Vec<String>) -> Result<()> {
340        let encrypted_existing_potentially_vulnerable_passwords: Vec<String> = self
341            .db
342            .query_rows_and_then_cached("SELECT encryptedPassword FROM breachesL", [], |row| {
343                row.get(0)
344            })?;
345        let existing_potentially_vulnerable_passwords: Result<Vec<String>> =
346            encrypted_existing_potentially_vulnerable_passwords
347                .iter()
348                .map(|ciphertext| {
349                    let decrypted_bytes = self
350                        .encdec
351                        .decrypt(ciphertext.as_bytes().into())
352                        .map_err(|e| {
353                            Error::DecryptionFailed(format!(
354                                "Failed to decrypt password from breachesL: {}",
355                                e
356                            ))
357                        })?;
358
359                    let password = std::str::from_utf8(&decrypted_bytes).map_err(|e| {
360                        Error::DecryptionFailed(format!(
361                            "Decrypted password from breachesL is not valid UTF-8: {}",
362                            e
363                        ))
364                    })?;
365
366                    Ok(password.into())
367                })
368                .collect();
369
370        let existing: std::collections::HashSet<String> =
371            existing_potentially_vulnerable_passwords?
372                .into_iter()
373                .collect();
374        let difference: Vec<_> = passwords
375            .iter()
376            .filter(|item| !existing.contains(item.as_str()))
377            .collect();
378
379        for password in difference {
380            let encrypted_password_bytes = self
381                .encdec
382                .encrypt(password.as_bytes().into())
383                .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting password)")))?;
384            let encrypted_password =
385                std::str::from_utf8(&encrypted_password_bytes).map_err(|e| {
386                    Error::EncryptionFailed(format!("{e} (encrypting password: data not utf8)"))
387                })?;
388
389            self.execute_cached(
390                "INSERT INTO breachesL (encryptedPassword) VALUES (:encrypted_password)",
391                named_params! {
392                    ":encrypted_password": encrypted_password,
393                },
394            )?;
395        }
396
397        Ok(())
398    }
399
400    /// Checks multiple logins for password reuse in a single batch operation.
401    ///
402    /// Returns the GUIDs of logins whose passwords match any password in the breach database.
403    /// This is more efficient than calling `is_potentially_vulnerable_password()` repeatedly,
404    /// as it decrypts the breach database only once.
405    ///
406    /// Performance: O(M + N) where M = breached passwords, N = logins to check
407    /// - Single check: Use `is_potentially_vulnerable_password()` (simpler)
408    /// - Multiple checks: Use this method (faster)
409    pub fn are_potentially_vulnerable_passwords(&self, guids: &[&str]) -> Result<Vec<String>> {
410        if guids.is_empty() {
411            return Ok(Vec::new());
412        }
413
414        // Load and decrypt all breached passwords once
415        let all_encrypted_passwords: Vec<String> = self.db.query_rows_and_then_cached(
416            "SELECT encryptedPassword FROM breachesL",
417            [],
418            |row| row.get(0),
419        )?;
420
421        let mut breached_passwords = std::collections::HashSet::new();
422        for ciphertext in &all_encrypted_passwords {
423            let decrypted_bytes =
424                self.encdec
425                    .decrypt(ciphertext.as_bytes().into())
426                    .map_err(|e| {
427                        Error::DecryptionFailed(format!(
428                            "Failed to decrypt password from breachesL: {}",
429                            e
430                        ))
431                    })?;
432
433            let decrypted_password = std::str::from_utf8(&decrypted_bytes).map_err(|e| {
434                Error::DecryptionFailed(format!(
435                    "Decrypted password from breachesL is not valid UTF-8: {}",
436                    e
437                ))
438            })?;
439
440            breached_passwords.insert(decrypted_password.to_string());
441        }
442
443        // Check each login against the breached passwords set
444        let mut vulnerable_guids = Vec::new();
445        for guid in guids {
446            if let Some(login) = self.get_by_id(guid)? {
447                let decrypted_login = login.decrypt(self.encdec.as_ref())?;
448                if breached_passwords.contains(&decrypted_login.password) {
449                    vulnerable_guids.push(guid.to_string());
450                }
451            }
452        }
453
454        Ok(vulnerable_guids)
455    }
456
457    pub fn is_potentially_vulnerable_password(&self, guid: &str) -> Result<bool> {
458        // Delegate to batch method for code reuse
459        let vulnerable = self.are_potentially_vulnerable_passwords(&[guid])?;
460        Ok(!vulnerable.is_empty())
461    }
462
463    pub fn reset_all_breaches(&self) -> Result<()> {
464        let tx = self.unchecked_transaction()?;
465        self.execute_cached("DELETE FROM breachesL", [])?;
466        tx.commit()?;
467        Ok(())
468    }
469
470    /// Records that the user dismissed the breach alert for a login using the current time.
471    ///
472    /// For testing or when you need to specify a particular timestamp, use
473    /// [`record_breach_alert_dismissal_time`](Self::record_breach_alert_dismissal_time) instead.
474    pub fn record_breach_alert_dismissal(&self, id: &str) -> Result<()> {
475        let timestamp = util::system_time_ms_i64(SystemTime::now());
476        self.record_breach_alert_dismissal_time(id, timestamp)
477    }
478
479    /// Records that the user dismissed the breach alert for a login at a specific time.
480    ///
481    /// This is primarily useful for testing or when syncing dismissal times from other devices.
482    /// For normal usage, prefer [`record_breach_alert_dismissal`](Self::record_breach_alert_dismissal)
483    /// which automatically uses the current time.
484    pub fn record_breach_alert_dismissal_time(&self, id: &str, timestamp: i64) -> Result<()> {
485        let tx = self.unchecked_transaction()?;
486        self.ensure_local_overlay_exists(id)?;
487        self.mark_mirror_overridden(id)?;
488        self.execute_cached(
489            "UPDATE loginsL
490             SET timeLastBreachAlertDismissed = :now_millis
491             WHERE guid = :guid",
492            named_params! {
493                ":now_millis": timestamp,
494                ":guid": id,
495            },
496        )?;
497        tx.commit()?;
498        Ok(())
499    }
500
501    // The single place we insert new rows or update existing local rows.
502    // just the SQL - no validation or anything.
503    fn insert_new_login(&self, login: &EncryptedLogin) -> Result<()> {
504        let sql = format!(
505            "INSERT OR REPLACE INTO loginsL (
506                origin,
507                httpRealm,
508                formActionOrigin,
509                usernameField,
510                passwordField,
511                timesUsed,
512                secFields,
513                guid,
514                timeCreated,
515                timeLastUsed,
516                timePasswordChanged,
517                timeLastBreachAlertDismissed,
518                local_modified,
519                is_deleted,
520                sync_status
521            ) VALUES (
522                :origin,
523                :http_realm,
524                :form_action_origin,
525                :username_field,
526                :password_field,
527                :times_used,
528                :sec_fields,
529                :guid,
530                :time_created,
531                :time_last_used,
532                :time_password_changed,
533                :time_last_breach_alert_dismissed,
534                :local_modified,
535                0, -- is_deleted
536                {new} -- sync_status
537            )",
538            new = SyncStatus::New as u8
539        );
540
541        self.execute(
542            &sql,
543            named_params! {
544                ":origin": login.fields.origin,
545                ":http_realm": login.fields.http_realm,
546                ":form_action_origin": login.fields.form_action_origin,
547                ":username_field": login.fields.username_field,
548                ":password_field": login.fields.password_field,
549                ":time_created": login.meta.time_created,
550                ":times_used": login.meta.times_used,
551                ":time_last_used": login.meta.time_last_used,
552                ":time_password_changed": login.meta.time_password_changed,
553                ":local_modified": login.meta.time_created,
554                ":time_last_breach_alert_dismissed": login.meta.time_last_breach_alert_dismissed,
555                ":sec_fields": login.sec_fields,
556                ":guid": login.guid(),
557            },
558        )?;
559        Ok(())
560    }
561
562    fn update_existing_login(&self, login: &EncryptedLogin) -> Result<()> {
563        // assumes the "local overlay" exists, so the guid must too.
564        let now_ms = util::system_time_ms_i64(SystemTime::now());
565        let sql = format!(
566            "UPDATE loginsL
567             SET local_modified                           = :now_millis,
568                 timeLastUsed                             = :time_last_used,
569                 timePasswordChanged                      = :time_password_changed,
570                 httpRealm                                = :http_realm,
571                 formActionOrigin                         = :form_action_origin,
572                 usernameField                            = :username_field,
573                 passwordField                            = :password_field,
574                 timesUsed                                = :times_used,
575                 secFields                                = :sec_fields,
576                 origin                                   = :origin,
577                 -- leave New records as they are, otherwise update them to `changed`
578                 sync_status                              = max(sync_status, {changed})
579             WHERE guid = :guid",
580            changed = SyncStatus::Changed as u8
581        );
582
583        self.db.execute(
584            &sql,
585            named_params! {
586                ":origin": login.fields.origin,
587                ":http_realm": login.fields.http_realm,
588                ":form_action_origin": login.fields.form_action_origin,
589                ":username_field": login.fields.username_field,
590                ":password_field": login.fields.password_field,
591                ":time_last_used": login.meta.time_last_used,
592                ":times_used": login.meta.times_used,
593                ":time_password_changed": login.meta.time_password_changed,
594                ":sec_fields": login.sec_fields,
595                ":guid": &login.meta.id,
596                ":now_millis": now_ms,
597            },
598        )?;
599        Ok(())
600    }
601
602    /// Adds multiple logins within a single transaction and returns the successfully saved logins.
603    pub fn add_many(&self, entries: Vec<LoginEntry>) -> Result<Vec<Result<EncryptedLogin>>> {
604        let now_ms = util::system_time_ms_i64(SystemTime::now());
605
606        let entries_with_meta = entries
607            .into_iter()
608            .map(|entry| {
609                let guid = Guid::random();
610                LoginEntryWithMeta {
611                    entry,
612                    meta: LoginMeta {
613                        id: guid.to_string(),
614                        time_created: now_ms,
615                        time_password_changed: now_ms,
616                        time_last_used: now_ms,
617                        times_used: 1,
618                        time_last_breach_alert_dismissed: None,
619                    },
620                }
621            })
622            .collect();
623
624        self.add_many_with_meta(entries_with_meta)
625    }
626
627    /// Adds multiple logins **including metadata** within a single transaction and returns the successfully saved logins.
628    /// Normally, you will use `add_many` instead, and AS Logins will take care of the metadata (setting timestamps, generating an ID) itself.
629    /// However, in some cases, this method is necessary, for example when migrating data from another store that already contains the metadata.
630    ///
631    pub fn add_many_with_meta(
632        &self,
633        entries_with_meta: Vec<LoginEntryWithMeta>,
634    ) -> Result<Vec<Result<EncryptedLogin>>> {
635        let tx = self.unchecked_transaction()?;
636        let mut results = vec![];
637        for mut entry_with_meta in entries_with_meta {
638            let guid = match Self::validate_or_fixup_guid(Guid::from_string(
639                entry_with_meta.meta.id.clone(),
640            )) {
641                Ok(guid) => guid,
642                Err(err) => {
643                    results.push(Err(err));
644                    continue;
645                }
646            };
647            // Keep `meta.id` in sync with the (possibly regenerated) guid; it is used
648            // as the stored/envelope id and when encrypting `sec_fields` below.
649            entry_with_meta.meta.id = guid.to_string();
650            // Timestamps come from the application here, so they are as
651            // untrusted as the rest of the entry.
652            entry_with_meta.meta = entry_with_meta.meta.sanitize_timestamps();
653            match self.fixup_and_check_for_dupes(&guid, entry_with_meta.entry) {
654                Ok(new_entry) => {
655                    let sec_fields = SecureLoginFields {
656                        username: new_entry.username,
657                        password: new_entry.password,
658                    }
659                    .encrypt(self.encdec.as_ref(), &entry_with_meta.meta.id)?;
660                    let encrypted_login = EncryptedLogin {
661                        meta: entry_with_meta.meta,
662                        fields: LoginFields {
663                            origin: new_entry.origin,
664                            form_action_origin: new_entry.form_action_origin,
665                            http_realm: new_entry.http_realm,
666                            username_field: new_entry.username_field,
667                            password_field: new_entry.password_field,
668                        },
669                        sec_fields,
670                    };
671                    let result = self
672                        .insert_new_login(&encrypted_login)
673                        .map(|_| encrypted_login);
674                    results.push(result);
675                }
676
677                Err(error) => results.push(Err(error)),
678            }
679        }
680
681        tx.commit()?;
682
683        Ok(results)
684    }
685
686    /// Validates a caller-supplied guid from the "with meta" import path against the
687    /// sync server's rules (see `Guid::is_valid_for_sync_server`). A guid that is
688    /// invalid for the sync server can never have existed on the server, so
689    /// regenerating it loses no sync identity.
690    ///
691    /// With the `fixup_invalid_guids` feature (enabled on Desktop during migration),
692    /// an invalid guid is silently replaced with a fresh random one. Without it, an
693    /// invalid guid is rejected so the problem surfaces at write time instead of being
694    /// persisted and later crashing the sync uploader (bug 2056116).
695    fn validate_or_fixup_guid(guid: Guid) -> Result<Guid> {
696        if guid.is_valid_for_sync_server() {
697            return Ok(guid);
698        }
699        #[cfg(feature = "fixup_invalid_guids")]
700        {
701            warn!("regenerating a login guid that is invalid for the sync server");
702            Ok(Guid::random())
703        }
704        #[cfg(not(feature = "fixup_invalid_guids"))]
705        {
706            Err(InvalidLogin::IllegalFieldValue {
707                field_info: "guid is not valid for the sync server".into(),
708            }
709            .into())
710        }
711    }
712
713    pub fn add(&self, entry: LoginEntry) -> Result<EncryptedLogin> {
714        let guid = Guid::random();
715        let now_ms = util::system_time_ms_i64(SystemTime::now());
716
717        let entry_with_meta = LoginEntryWithMeta {
718            entry,
719            meta: LoginMeta {
720                id: guid.to_string(),
721                time_created: now_ms,
722                time_password_changed: now_ms,
723                time_last_used: now_ms,
724                times_used: 1,
725                time_last_breach_alert_dismissed: None,
726            },
727        };
728
729        self.add_with_meta(entry_with_meta)
730    }
731
732    /// Adds a login **including metadata**.
733    /// Normally, you will use `add` instead, and AS Logins will take care of the metadata (setting timestamps, generating an ID) itself.
734    /// However, in some cases, this method is necessary, for example when migrating data from another store that already contains the metadata.
735    pub fn add_with_meta(&self, entry_with_meta: LoginEntryWithMeta) -> Result<EncryptedLogin> {
736        let mut results = self.add_many_with_meta(vec![entry_with_meta])?;
737        results.pop().expect("there should be a single result")
738    }
739
740    pub fn update(&self, sguid: &str, entry: LoginEntry) -> Result<EncryptedLogin> {
741        let guid = Guid::new(sguid);
742        let now_ms = util::system_time_ms_i64(SystemTime::now());
743        let tx = self.unchecked_transaction()?;
744
745        let entry = entry.fixup()?;
746
747        // Check if there's an existing login that's the dupe of this login.  That indicates that
748        // something has gone wrong with our underlying logic.  However, if we do see a dupe login,
749        // just log an error and continue.  This avoids a crash on android-components
750        // (mozilla-mobile/android-components#11251).
751
752        if self.check_for_dupes(&guid, &entry).is_err() {
753            // Try to detect if sync is enabled by checking if there are any mirror logins
754            let has_mirror_row: bool = self
755                .db
756                .conn_ext_query_one("SELECT EXISTS (SELECT 1 FROM loginsM)")?;
757            let has_http_realm = entry.http_realm.is_some();
758            let has_form_action_origin = entry.form_action_origin.is_some();
759            report_error!(
760                "logins-duplicate-in-update",
761                "(mirror: {has_mirror_row}, realm: {has_http_realm}, form_origin: {has_form_action_origin})");
762        }
763
764        // Note: This fail with NoSuchRecord if the record doesn't exist.
765        self.ensure_local_overlay_exists(&guid)?;
766        self.mark_mirror_overridden(&guid)?;
767
768        // We must read the existing record so we can correctly manage timePasswordChanged.
769        let existing = match self.get_by_id(sguid)? {
770            Some(e) => e.decrypt(self.encdec.as_ref())?,
771            None => return Err(Error::NoSuchRecord(sguid.to_owned())),
772        };
773        let time_password_changed = if existing.password == entry.password {
774            existing.time_password_changed
775        } else {
776            now_ms
777        };
778
779        // Make the final object here - every column will be updated.
780        let sec_fields = SecureLoginFields {
781            username: entry.username,
782            password: entry.password,
783        }
784        .encrypt(self.encdec.as_ref(), &existing.id)?;
785        let result = EncryptedLogin {
786            meta: LoginMeta {
787                id: existing.id,
788                time_created: existing.time_created,
789                time_password_changed,
790                // An edit is not a use (see bug 2045032)
791                time_last_used: existing.time_last_used,
792                times_used: existing.times_used,
793                time_last_breach_alert_dismissed: None,
794            },
795            fields: LoginFields {
796                origin: entry.origin,
797                form_action_origin: entry.form_action_origin,
798                http_realm: entry.http_realm,
799                username_field: entry.username_field,
800                password_field: entry.password_field,
801            },
802            sec_fields,
803        };
804
805        self.update_existing_login(&result)?;
806        tx.commit()?;
807        Ok(result)
808    }
809
810    pub fn add_or_update(&self, entry: LoginEntry) -> Result<EncryptedLogin> {
811        // Make sure to fixup the entry first, in case that changes the username
812        let entry = entry.fixup()?;
813        match self.find_login_to_update(entry.clone())? {
814            Some(login) => self.update(&login.id, entry),
815            None => self.add(entry),
816        }
817    }
818
819    pub fn fixup_and_check_for_dupes(&self, guid: &Guid, entry: LoginEntry) -> Result<LoginEntry> {
820        let entry = entry.fixup()?;
821        self.check_for_dupes(guid, &entry)?;
822        Ok(entry)
823    }
824
825    pub fn check_for_dupes(&self, guid: &Guid, entry: &LoginEntry) -> Result<()> {
826        if self.dupe_exists(guid, entry)? {
827            return Err(InvalidLogin::DuplicateLogin.into());
828        }
829        Ok(())
830    }
831
832    pub fn dupe_exists(&self, guid: &Guid, entry: &LoginEntry) -> Result<bool> {
833        Ok(self.find_dupe(guid, entry)?.is_some())
834    }
835
836    pub fn find_dupe(&self, guid: &Guid, entry: &LoginEntry) -> Result<Option<Guid>> {
837        for possible in self.get_by_entry_target(entry)? {
838            if possible.guid() != *guid {
839                let pos_sec_fields = possible.decrypt_fields(self.encdec.as_ref())?;
840                if pos_sec_fields.username == entry.username {
841                    return Ok(Some(possible.guid()));
842                }
843            }
844        }
845        Ok(None)
846    }
847
848    // Find saved logins that match the target for a `LoginEntry`
849    //
850    // This means that:
851    //   - `origin` matches
852    //   - Either `form_action_origin` or `http_realm` matches, depending on which one is non-null
853    //
854    // This is used for dupe-checking and `find_login_to_update()`
855    //
856    // Note that `entry` must be a normalized Login (via `fixup()`)
857    fn get_by_entry_target(&self, entry: &LoginEntry) -> Result<Vec<EncryptedLogin>> {
858        // Could be lazy_static-ed...
859        lazy_static::lazy_static! {
860            static ref GET_BY_FORM_ACTION_ORIGIN: String = format!(
861                "SELECT {common_cols} FROM loginsL
862                WHERE is_deleted = 0
863                    AND origin = :origin
864                    AND formActionOrigin = :form_action_origin
865
866                UNION ALL
867
868                SELECT {common_cols} FROM loginsM
869                WHERE is_overridden = 0
870                    AND origin = :origin
871                    AND formActionOrigin = :form_action_origin
872                ",
873                common_cols = schema::COMMON_COLS
874            );
875            static ref GET_BY_HTTP_REALM: String = format!(
876                "SELECT {common_cols} FROM loginsL
877                WHERE is_deleted = 0
878                    AND origin = :origin
879                    AND httpRealm = :http_realm
880
881                UNION ALL
882
883                SELECT {common_cols} FROM loginsM
884                WHERE is_overridden = 0
885                    AND origin = :origin
886                    AND httpRealm = :http_realm
887                ",
888                common_cols = schema::COMMON_COLS
889            );
890        }
891        match (entry.form_action_origin.as_ref(), entry.http_realm.as_ref()) {
892            (Some(form_action_origin), None) => {
893                let params = named_params! {
894                    ":origin": &entry.origin,
895                    ":form_action_origin": form_action_origin,
896                };
897                self.db
898                    .prepare_cached(&GET_BY_FORM_ACTION_ORIGIN)?
899                    .query_and_then(params, EncryptedLogin::from_row)?
900                    .collect()
901            }
902            (None, Some(http_realm)) => {
903                let params = named_params! {
904                    ":origin": &entry.origin,
905                    ":http_realm": http_realm,
906                };
907                self.db
908                    .prepare_cached(&GET_BY_HTTP_REALM)?
909                    .query_and_then(params, EncryptedLogin::from_row)?
910                    .collect()
911            }
912            (Some(_), Some(_)) => Err(InvalidLogin::BothTargets.into()),
913            (None, None) => Err(InvalidLogin::NoTarget.into()),
914        }
915    }
916
917    pub fn exists(&self, id: &str) -> Result<bool> {
918        Ok(self.db.query_row(
919            "SELECT EXISTS(
920                 SELECT 1 FROM loginsL
921                 WHERE guid = :guid AND is_deleted = 0
922                 UNION ALL
923                 SELECT 1 FROM loginsM
924                 WHERE guid = :guid AND is_overridden IS NOT 1
925             )",
926            named_params! { ":guid": id },
927            |row| row.get(0),
928        )?)
929    }
930
931    /// Delete the record with the provided id. Returns true if the record
932    /// existed already.
933    pub fn delete(&self, id: &str) -> Result<bool> {
934        let mut results = self.delete_many(vec![id])?;
935        Ok(results.pop().expect("there should be a single result"))
936    }
937
938    // Delete all records. Return an array with the ids of the deleted logins
939    pub fn delete_all(&self) -> Result<Vec<String>> {
940        let ids: Vec<String> = self.db.query_rows_and_then_cached(
941            "SELECT guid FROM loginsL WHERE is_deleted = 0
942             UNION ALL
943             SELECT guid FROM loginsM WHERE is_overridden = 0",
944            [],
945            |row| row.get(0),
946        )?;
947        self.delete_many(ids.iter().map(String::as_str).collect())?;
948        Ok(ids)
949    }
950
951    // Delete all records, except the FxA login. Return an array with the ids of
952    // the deleted logins
953    pub fn delete_all_except_fxa(&self) -> Result<Vec<String>> {
954        let ids: Vec<String> = self.db.query_rows_and_then_cached(
955            "SELECT guid FROM loginsL WHERE is_deleted = 0 AND origin != :fxa_origin
956             UNION ALL
957             SELECT guid FROM loginsM WHERE is_overridden = 0 AND origin != :fxa_origin",
958            named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN },
959            |row| row.get(0),
960        )?;
961        self.delete_many(ids.iter().map(String::as_str).collect())?;
962        Ok(ids)
963    }
964
965    /// Delete the records with the specified IDs. Returns a list of Boolean values
966    /// indicating whether the respective records already existed.
967    pub fn delete_many(&self, ids: Vec<&str>) -> Result<Vec<bool>> {
968        let tx = self.unchecked_transaction_imm()?;
969        let sql = format!(
970            "
971            UPDATE loginsL
972            SET local_modified = :now_ms,
973                sync_status = {status_changed},
974                is_deleted = 1,
975                secFields = '',
976                origin = '',
977                httpRealm = NULL,
978                formActionOrigin = NULL
979            WHERE guid = :guid AND is_deleted IS FALSE
980            ",
981            status_changed = SyncStatus::Changed as u8
982        );
983        let mut stmt = self.db.prepare_cached(&sql)?;
984
985        let mut result = vec![];
986
987        for id in ids {
988            let now_ms = util::system_time_ms_i64(SystemTime::now());
989
990            // For IDs that have, mark is_deleted and clear sensitive fields
991            let update_result = stmt.execute(named_params! { ":now_ms": now_ms, ":guid": id })?;
992
993            let exists = update_result == 1;
994
995            // Mark the mirror as overridden
996            self.execute(
997                "UPDATE loginsM SET is_overridden = 1 WHERE guid = :guid",
998                named_params! { ":guid": id },
999            )?;
1000
1001            // If we don't have a local record for this ID, but do have it in the mirror
1002            // insert a tombstone.
1003            self.execute(&format!("
1004                INSERT OR IGNORE INTO loginsL
1005                        (guid, local_modified, is_deleted, sync_status, origin, timeCreated, timePasswordChanged, secFields)
1006                SELECT   guid, :now_ms,        1,          {changed},   '',     timeCreated, :now_ms,             ''
1007                FROM loginsM
1008                WHERE guid = :guid",
1009                changed = SyncStatus::Changed as u8),
1010                named_params! { ":now_ms": now_ms, ":guid": id })?;
1011
1012            result.push(exists);
1013        }
1014
1015        tx.commit()?;
1016
1017        Ok(result)
1018    }
1019
1020    pub fn delete_undecryptable_records_for_remote_replacement(
1021        &self,
1022    ) -> Result<LoginsDeletionMetrics> {
1023        // Retrieve a list of guids for logins that cannot be decrypted
1024        let corrupted_logins = self
1025            .get_all()?
1026            .into_iter()
1027            .filter(|login| login.clone().decrypt(self.encdec.as_ref()).is_err())
1028            .collect::<Vec<_>>();
1029        let ids = corrupted_logins
1030            .iter()
1031            .map(|login| login.guid_str())
1032            .collect::<Vec<_>>();
1033
1034        self.delete_local_records_for_remote_replacement(ids)
1035    }
1036
1037    pub fn delete_local_records_for_remote_replacement(
1038        &self,
1039        ids: Vec<&str>,
1040    ) -> Result<LoginsDeletionMetrics> {
1041        let tx = self.unchecked_transaction_imm()?;
1042        let mut local_deleted = 0;
1043        let mut mirror_deleted = 0;
1044
1045        sql_support::each_chunk(&ids, |chunk, _| -> Result<()> {
1046            let deleted = self.execute(
1047                &format!(
1048                    "DELETE FROM loginsL WHERE guid IN ({})",
1049                    sql_support::repeat_sql_values(chunk.len())
1050                ),
1051                rusqlite::params_from_iter(chunk),
1052            )?;
1053            local_deleted += deleted;
1054            Ok(())
1055        })?;
1056
1057        sql_support::each_chunk(&ids, |chunk, _| -> Result<()> {
1058            let deleted = self.execute(
1059                &format!(
1060                    "DELETE FROM loginsM WHERE guid IN ({})",
1061                    sql_support::repeat_sql_values(chunk.len())
1062                ),
1063                rusqlite::params_from_iter(chunk),
1064            )?;
1065            mirror_deleted += deleted;
1066            Ok(())
1067        })?;
1068
1069        tx.commit()?;
1070        Ok(LoginsDeletionMetrics {
1071            local_deleted: local_deleted as u64,
1072            mirror_deleted: mirror_deleted as u64,
1073        })
1074    }
1075
1076    fn mark_mirror_overridden(&self, guid: &str) -> Result<()> {
1077        self.execute_cached(
1078            "UPDATE loginsM SET is_overridden = 1 WHERE guid = :guid",
1079            named_params! { ":guid": guid },
1080        )?;
1081        Ok(())
1082    }
1083
1084    fn ensure_local_overlay_exists(&self, guid: &str) -> Result<()> {
1085        let already_have_local: bool = self.db.query_row(
1086            "SELECT EXISTS(SELECT 1 FROM loginsL WHERE guid = :guid)",
1087            named_params! { ":guid": guid },
1088            |row| row.get(0),
1089        )?;
1090
1091        if already_have_local {
1092            return Ok(());
1093        }
1094
1095        debug!("No overlay; cloning one for {:?}.", guid);
1096        let changed = self.clone_mirror_to_overlay(guid)?;
1097        if changed == 0 {
1098            report_error!(
1099                "logins-local-overlay-error",
1100                "Failed to create local overlay for GUID {guid:?}."
1101            );
1102            return Err(Error::NoSuchRecord(guid.to_owned()));
1103        }
1104        Ok(())
1105    }
1106
1107    fn clone_mirror_to_overlay(&self, guid: &str) -> Result<usize> {
1108        Ok(self.execute_cached(&CLONE_SINGLE_MIRROR_SQL, &[(":guid", &guid as &dyn ToSql)])?)
1109    }
1110
1111    /// Wipe all local data, returns the number of rows deleted
1112    pub fn wipe_local(&self) -> Result<usize> {
1113        info!("Executing wipe_local on password engine!");
1114        let tx = self.unchecked_transaction()?;
1115        let mut row_count = 0;
1116        row_count += self.execute("DELETE FROM loginsL", [])?;
1117        row_count += self.execute("DELETE FROM loginsM", [])?;
1118        row_count += self.execute("DELETE FROM loginsSyncMeta", [])?;
1119        row_count += self.execute("DELETE FROM breachesL", [])?;
1120        tx.commit()?;
1121        Ok(row_count)
1122    }
1123
1124    /// Wipe all local data except the FxA login, returns the number of rows deleted
1125    pub fn wipe_local_except_fxa(&self) -> Result<usize> {
1126        info!("Executing wipe_local_except_fxa on password engine!");
1127        let tx = self.unchecked_transaction()?;
1128        let mut row_count = 0;
1129        row_count += self.execute(
1130            "DELETE FROM loginsL WHERE origin != :fxa_origin",
1131            named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN },
1132        )?;
1133        row_count += self.execute(
1134            "DELETE FROM loginsM WHERE origin != :fxa_origin",
1135            named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN },
1136        )?;
1137        row_count += self.execute("DELETE FROM loginsSyncMeta", [])?;
1138        row_count += self.execute("DELETE FROM breachesL", [])?;
1139        tx.commit()?;
1140        Ok(row_count)
1141    }
1142
1143    pub fn shutdown(self) -> Result<()> {
1144        self.db.close().map_err(|(_, e)| Error::SqlError(e))
1145    }
1146}
1147
1148lazy_static! {
1149    static ref GET_ALL_SQL: String = format!(
1150        "SELECT {common_cols} FROM loginsL WHERE is_deleted = 0
1151         UNION ALL
1152         SELECT {common_cols} FROM loginsM WHERE is_overridden = 0",
1153        common_cols = schema::COMMON_COLS,
1154    );
1155    static ref COUNT_ALL_SQL: String = format!(
1156        "SELECT COUNT(*) FROM (
1157          SELECT guid FROM loginsL WHERE is_deleted = 0
1158          UNION ALL
1159          SELECT guid FROM loginsM WHERE is_overridden = 0
1160        )"
1161    );
1162    static ref COUNT_BY_ORIGIN_SQL: String = format!(
1163        "SELECT COUNT(*) FROM (
1164          SELECT guid FROM loginsL WHERE is_deleted = 0 AND origin = :origin
1165          UNION ALL
1166          SELECT guid FROM loginsM WHERE is_overridden = 0 AND origin = :origin
1167        )"
1168    );
1169    static ref COUNT_BY_FORM_ACTION_ORIGIN_SQL: String = format!(
1170        "SELECT COUNT(*) FROM (
1171          SELECT guid FROM loginsL WHERE is_deleted = 0 AND formActionOrigin = :form_action_origin
1172          UNION ALL
1173          SELECT guid FROM loginsM WHERE is_overridden = 0 AND formActionOrigin = :form_action_origin
1174        )"
1175    );
1176    static ref GET_BY_GUID_SQL: String = format!(
1177        "SELECT {common_cols}
1178         FROM loginsL
1179         WHERE is_deleted = 0
1180           AND guid = :guid
1181
1182         UNION ALL
1183
1184         SELECT {common_cols}
1185         FROM loginsM
1186         WHERE is_overridden IS NOT 1
1187           AND guid = :guid
1188         ORDER BY origin ASC
1189
1190         LIMIT 1",
1191        common_cols = schema::COMMON_COLS,
1192    );
1193    pub static ref CLONE_ENTIRE_MIRROR_SQL: String = format!(
1194        "INSERT OR IGNORE INTO loginsL ({common_cols}, local_modified, is_deleted, sync_status)
1195         SELECT {common_cols}, NULL AS local_modified, 0 AS is_deleted, 0 AS sync_status
1196         FROM loginsM",
1197        common_cols = schema::COMMON_COLS,
1198    );
1199    static ref CLONE_SINGLE_MIRROR_SQL: String =
1200        format!("{} WHERE guid = :guid", &*CLONE_ENTIRE_MIRROR_SQL,);
1201}
1202
1203#[cfg(not(feature = "keydb"))]
1204#[cfg(test)]
1205pub mod test_utils {
1206    use super::*;
1207    use crate::login::test_utils::enc_login;
1208    use crate::test_utils::decrypt_struct;
1209    use crate::SecureLoginFields;
1210    use sync15::ServerTimestamp;
1211
1212    // Insert a login into the local and/or mirror tables.
1213    //
1214    // local_login and mirror_login are specified as Some(password_string)
1215    pub fn insert_login(
1216        db: &LoginDb,
1217        guid: &str,
1218        local_login: Option<&str>,
1219        mirror_login: Option<&str>,
1220    ) {
1221        if let Some(password) = mirror_login {
1222            add_mirror(
1223                db,
1224                &enc_login(guid, password),
1225                &ServerTimestamp(util::system_time_ms_i64(std::time::SystemTime::now())),
1226                local_login.is_some(),
1227            )
1228            .unwrap();
1229        }
1230        if let Some(password) = local_login {
1231            db.insert_new_login(&enc_login(guid, password)).unwrap();
1232        }
1233    }
1234
1235    pub fn insert_encrypted_login(
1236        db: &LoginDb,
1237        local: &EncryptedLogin,
1238        mirror: &EncryptedLogin,
1239        server_modified: &ServerTimestamp,
1240    ) {
1241        db.insert_new_login(local).unwrap();
1242        add_mirror(db, mirror, server_modified, true).unwrap();
1243    }
1244
1245    pub fn add_mirror(
1246        db: &LoginDb,
1247        login: &EncryptedLogin,
1248        server_modified: &ServerTimestamp,
1249        is_overridden: bool,
1250    ) -> Result<()> {
1251        let sql = "
1252            INSERT OR IGNORE INTO loginsM (
1253                is_overridden,
1254                server_modified,
1255
1256                httpRealm,
1257                formActionOrigin,
1258                usernameField,
1259                passwordField,
1260                secFields,
1261                origin,
1262
1263                timesUsed,
1264                timeLastUsed,
1265                timePasswordChanged,
1266                timeCreated,
1267
1268                timeLastBreachAlertDismissed,
1269
1270                guid
1271            ) VALUES (
1272                :is_overridden,
1273                :server_modified,
1274
1275                :http_realm,
1276                :form_action_origin,
1277                :username_field,
1278                :password_field,
1279                :sec_fields,
1280                :origin,
1281
1282                :times_used,
1283                :time_last_used,
1284                :time_password_changed,
1285                :time_created,
1286
1287                :time_last_breach_alert_dismissed,
1288
1289                :guid
1290            )";
1291        let mut stmt = db.prepare_cached(sql)?;
1292
1293        stmt.execute(named_params! {
1294            ":is_overridden": is_overridden,
1295            ":server_modified": server_modified.as_millis(),
1296            ":http_realm": login.fields.http_realm,
1297            ":form_action_origin": login.fields.form_action_origin,
1298            ":username_field": login.fields.username_field,
1299            ":password_field": login.fields.password_field,
1300            ":origin": login.fields.origin,
1301            ":sec_fields": login.sec_fields,
1302            ":times_used": login.meta.times_used,
1303            ":time_last_used": login.meta.time_last_used,
1304            ":time_password_changed": login.meta.time_password_changed,
1305            ":time_created": login.meta.time_created,
1306            ":time_last_breach_alert_dismissed": login.meta.time_last_breach_alert_dismissed,
1307            ":guid": login.guid_str(),
1308        })?;
1309        Ok(())
1310    }
1311
1312    pub fn get_local_guids(db: &LoginDb) -> Vec<String> {
1313        get_guids(db, "SELECT guid FROM loginsL")
1314    }
1315
1316    pub fn get_mirror_guids(db: &LoginDb) -> Vec<String> {
1317        get_guids(db, "SELECT guid FROM loginsM")
1318    }
1319
1320    fn get_guids(db: &LoginDb, sql: &str) -> Vec<String> {
1321        let mut stmt = db.prepare_cached(sql).unwrap();
1322        let mut res: Vec<String> = stmt
1323            .query_map([], |r| r.get(0))
1324            .unwrap()
1325            .map(|r| r.unwrap())
1326            .collect();
1327        res.sort();
1328        res
1329    }
1330
1331    pub fn get_server_modified(db: &LoginDb, guid: &str) -> i64 {
1332        db.conn_ext_query_one(&format!(
1333            "SELECT server_modified FROM loginsM WHERE guid='{}'",
1334            guid
1335        ))
1336        .unwrap()
1337    }
1338
1339    pub fn check_local_login(db: &LoginDb, guid: &str, password: &str, local_modified_gte: i64) {
1340        let row: (String, i64, bool) = db
1341            .query_row(
1342                "SELECT secFields, local_modified, is_deleted FROM loginsL WHERE guid=?",
1343                [guid],
1344                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1345            )
1346            .unwrap();
1347        let enc: SecureLoginFields = decrypt_struct(row.0);
1348        assert_eq!(enc.password, password);
1349        assert!(row.1 >= local_modified_gte);
1350        assert!(!row.2);
1351    }
1352
1353    pub fn check_mirror_login(
1354        db: &LoginDb,
1355        guid: &str,
1356        password: &str,
1357        server_modified: i64,
1358        is_overridden: bool,
1359    ) {
1360        let row: (String, i64, bool) = db
1361            .query_row(
1362                "SELECT secFields, server_modified, is_overridden FROM loginsM WHERE guid=?",
1363                [guid],
1364                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1365            )
1366            .unwrap();
1367        let enc: SecureLoginFields = decrypt_struct(row.0);
1368        assert_eq!(enc.password, password);
1369        assert_eq!(row.1, server_modified);
1370        assert_eq!(row.2, is_overridden);
1371    }
1372}
1373
1374#[cfg(not(feature = "keydb"))]
1375#[cfg(test)]
1376mod tests {
1377    use super::*;
1378    use crate::db::test_utils::{get_local_guids, get_mirror_guids};
1379    use crate::sync::merge::LocalLogin;
1380    use crate::test_utils::TEST_ENCDEC;
1381    use nss_as::ensure_initialized;
1382    use std::{thread, time};
1383
1384    #[test]
1385    fn test_username_dupe_semantics() {
1386        ensure_initialized();
1387        let mut login = LoginEntry {
1388            origin: "https://www.example.com".into(),
1389            http_realm: Some("https://www.example.com".into()),
1390            username: "test".into(),
1391            password: "sekret".into(),
1392            ..LoginEntry::default()
1393        };
1394
1395        let db = LoginDb::open_in_memory();
1396        db.add(login.clone())
1397            .expect("should be able to add first login");
1398
1399        // We will reject new logins with the same username value...
1400        let exp_err = "Invalid login: Login already exists";
1401        assert_eq!(db.add(login.clone()).unwrap_err().to_string(), exp_err);
1402
1403        // Add one with an empty username - not a dupe.
1404        login.username = "".to_string();
1405        db.add(login.clone()).expect("empty login isn't a dupe");
1406
1407        assert_eq!(db.add(login).unwrap_err().to_string(), exp_err);
1408
1409        // one with a username, 1 without.
1410        assert_eq!(db.get_all().unwrap().len(), 2);
1411    }
1412
1413    #[test]
1414    fn test_get_many() {
1415        ensure_initialized();
1416
1417        let db = LoginDb::open_in_memory();
1418        let mut added = Vec::new();
1419        for origin in ["https://a.example.com", "https://b.example.com"] {
1420            added.push(
1421                db.add(LoginEntry {
1422                    origin: origin.into(),
1423                    http_realm: Some("https://www.example.com".into()),
1424                    username: "test".into(),
1425                    password: "sekret".into(),
1426                    ..LoginEntry::default()
1427                })
1428                .expect("should be able to add login"),
1429            );
1430        }
1431        let ids = added.iter().map(|l| l.meta.id.clone()).collect::<Vec<_>>();
1432
1433        // Neither `get_many()` nor `get_all()` promises an order, so compare them sorted.
1434        let by_origin = |logins: Vec<EncryptedLogin>| {
1435            let mut logins = logins;
1436            logins.sort_by(|l, r| l.fields.origin.cmp(&r.fields.origin));
1437            logins
1438        };
1439
1440        // Asking for every id gives us exactly what `get_all()` does.
1441        assert_eq!(
1442            by_origin(db.get_many(&ids).unwrap()),
1443            by_origin(db.get_all().unwrap())
1444        );
1445
1446        // A subset gives us just that subset...
1447        assert_eq!(db.get_many(&ids[1..]).unwrap(), added[1..]);
1448
1449        // ...and ids we don't have a login for are absent rather than an error.
1450        assert_eq!(
1451            db.get_many(&[ids[0].clone(), "no-such-guid".to_string()])
1452                .unwrap(),
1453            added[..1]
1454        );
1455        assert_eq!(db.get_many(&[]).unwrap(), Vec::new());
1456    }
1457
1458    #[test]
1459    fn test_add_many() {
1460        ensure_initialized();
1461
1462        let login_a = LoginEntry {
1463            origin: "https://a.example.com".into(),
1464            http_realm: Some("https://www.example.com".into()),
1465            username: "test".into(),
1466            password: "sekret".into(),
1467            ..LoginEntry::default()
1468        };
1469
1470        let login_b = LoginEntry {
1471            origin: "https://b.example.com".into(),
1472            http_realm: Some("https://www.example.com".into()),
1473            username: "test".into(),
1474            password: "sekret".into(),
1475            ..LoginEntry::default()
1476        };
1477
1478        let db = LoginDb::open_in_memory();
1479        let added = db
1480            .add_many(vec![login_a.clone(), login_b.clone()])
1481            .expect("should be able to add logins");
1482
1483        let [added_a, added_b] = added.as_slice() else {
1484            panic!("there should really be 2")
1485        };
1486
1487        let fetched_a = db
1488            .get_by_id(&added_a.as_ref().unwrap().meta.id)
1489            .expect("should work")
1490            .expect("should get a record");
1491
1492        assert_eq!(fetched_a.fields.origin, login_a.origin);
1493
1494        let fetched_b = db
1495            .get_by_id(&added_b.as_ref().unwrap().meta.id)
1496            .expect("should work")
1497            .expect("should get a record");
1498
1499        assert_eq!(fetched_b.fields.origin, login_b.origin);
1500
1501        assert_eq!(db.count_all().unwrap(), 2);
1502    }
1503
1504    #[test]
1505    fn test_count_by_origin() {
1506        ensure_initialized();
1507
1508        let origin_a = "https://a.example.com";
1509        let login_a = LoginEntry {
1510            origin: origin_a.into(),
1511            http_realm: Some("https://www.example.com".into()),
1512            username: "test".into(),
1513            password: "sekret".into(),
1514            ..LoginEntry::default()
1515        };
1516
1517        let login_b = LoginEntry {
1518            origin: "https://b.example.com".into(),
1519            http_realm: Some("https://www.example.com".into()),
1520            username: "test".into(),
1521            password: "sekret".into(),
1522            ..LoginEntry::default()
1523        };
1524
1525        let origin_umlaut = "https://bücher.example.com";
1526        let login_umlaut = LoginEntry {
1527            origin: origin_umlaut.into(),
1528            http_realm: Some("https://www.example.com".into()),
1529            username: "test".into(),
1530            password: "sekret".into(),
1531            ..LoginEntry::default()
1532        };
1533
1534        let db = LoginDb::open_in_memory();
1535        db.add_many(vec![login_a.clone(), login_b.clone(), login_umlaut.clone()])
1536            .expect("should be able to add logins");
1537
1538        assert_eq!(db.count_by_origin(origin_a).unwrap(), 1);
1539        assert_eq!(db.count_by_origin(origin_umlaut).unwrap(), 1);
1540    }
1541
1542    #[test]
1543    fn test_count_by_form_action_origin() {
1544        ensure_initialized();
1545
1546        let origin_a = "https://a.example.com";
1547        let login_a = LoginEntry {
1548            origin: origin_a.into(),
1549            form_action_origin: Some(origin_a.into()),
1550            http_realm: Some("https://www.example.com".into()),
1551            username: "test".into(),
1552            password: "sekret".into(),
1553            ..LoginEntry::default()
1554        };
1555
1556        let login_b = LoginEntry {
1557            origin: "https://b.example.com".into(),
1558            form_action_origin: Some("https://b.example.com".into()),
1559            http_realm: Some("https://www.example.com".into()),
1560            username: "test".into(),
1561            password: "sekret".into(),
1562            ..LoginEntry::default()
1563        };
1564
1565        let origin_umlaut = "https://bücher.example.com";
1566        let login_umlaut = LoginEntry {
1567            origin: origin_umlaut.into(),
1568            form_action_origin: Some(origin_umlaut.into()),
1569            http_realm: Some("https://www.example.com".into()),
1570            username: "test".into(),
1571            password: "sekret".into(),
1572            ..LoginEntry::default()
1573        };
1574
1575        let db = LoginDb::open_in_memory();
1576        db.add_many(vec![login_a.clone(), login_b.clone(), login_umlaut.clone()])
1577            .expect("should be able to add logins");
1578
1579        assert_eq!(db.count_by_form_action_origin(origin_a).unwrap(), 1);
1580        assert_eq!(db.count_by_form_action_origin(origin_umlaut).unwrap(), 1);
1581    }
1582
1583    #[test]
1584    #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1585    fn test_count_by_invalid_form_action_origin() {
1586        ensure_initialized();
1587
1588        let login = LoginEntry {
1589            origin: "https://example.com".into(),
1590            form_action_origin: Some("email".into()),
1591            username: "test".into(),
1592            password: "sekret".into(),
1593            ..LoginEntry::default()
1594        };
1595
1596        let db = LoginDb::open_in_memory();
1597        db.add(login)
1598            .expect("should be able to add login with invalid form_action_origin");
1599        assert_eq!(db.count_by_form_action_origin("email").unwrap(), 1);
1600    }
1601
1602    #[test]
1603    fn test_add_many_with_failed_constraint() {
1604        ensure_initialized();
1605
1606        let login_a = LoginEntry {
1607            origin: "https://example.com".into(),
1608            http_realm: Some("https://www.example.com".into()),
1609            username: "test".into(),
1610            password: "sekret".into(),
1611            ..LoginEntry::default()
1612        };
1613
1614        let login_b = LoginEntry {
1615            // same origin will result in duplicate error
1616            origin: "https://example.com".into(),
1617            http_realm: Some("https://www.example.com".into()),
1618            username: "test".into(),
1619            password: "sekret".into(),
1620            ..LoginEntry::default()
1621        };
1622
1623        let db = LoginDb::open_in_memory();
1624        let added = db
1625            .add_many(vec![login_a.clone(), login_b.clone()])
1626            .expect("should be able to add logins");
1627
1628        let [added_a, added_b] = added.as_slice() else {
1629            panic!("there should really be 2")
1630        };
1631
1632        // first entry has been saved successfully
1633        let fetched_a = db
1634            .get_by_id(&added_a.as_ref().unwrap().meta.id)
1635            .expect("should work")
1636            .expect("should get a record");
1637
1638        assert_eq!(fetched_a.fields.origin, login_a.origin);
1639
1640        // second entry failed
1641        assert!(!added_b.is_ok());
1642    }
1643
1644    #[test]
1645    fn test_add_with_meta() {
1646        ensure_initialized();
1647
1648        let guid = Guid::random();
1649        let now_ms = util::system_time_ms_i64(SystemTime::now());
1650        let login = LoginEntry {
1651            origin: "https://www.example.com".into(),
1652            http_realm: Some("https://www.example.com".into()),
1653            username: "test".into(),
1654            password: "sekret".into(),
1655            ..LoginEntry::default()
1656        };
1657        let meta = LoginMeta {
1658            id: guid.to_string(),
1659            time_created: now_ms,
1660            time_password_changed: now_ms + 100,
1661            time_last_used: now_ms + 10,
1662            times_used: 42,
1663            time_last_breach_alert_dismissed: None,
1664        };
1665
1666        let db = LoginDb::open_in_memory();
1667        let entry_with_meta = LoginEntryWithMeta {
1668            entry: login.clone(),
1669            meta: meta.clone(),
1670        };
1671
1672        db.add_with_meta(entry_with_meta)
1673            .expect("should be able to add login with record");
1674
1675        let fetched = db
1676            .get_by_id(&guid)
1677            .expect("should work")
1678            .expect("should get a record");
1679
1680        assert_eq!(fetched.meta, meta);
1681    }
1682
1683    /// A record with absurd `timeCreated` used to make every subsequent read of
1684    /// the whole store fail, which emptied about:logins and broke sync on every
1685    /// device the record reached. Reading must heal it instead. Bug 2066257.
1686    #[test]
1687    fn test_get_heals_corrupt_timestamp_already_in_db() {
1688        ensure_initialized();
1689
1690        let db = LoginDb::open_in_memory();
1691        let login = db
1692            .add(LoginEntry {
1693                origin: "https://www.example.com".into(),
1694                http_realm: Some("https://www.example.com".into()),
1695                username: "user".into(),
1696                password: "password".into(),
1697                ..Default::default()
1698            })
1699            .unwrap();
1700
1701        // Corrupt the row behind the store's back, the way a version without this fix - or a
1702        // sync peer talking to one - would have left it.
1703        const CORRUPT: i64 = 18446744071857664;
1704        db.execute(
1705            "UPDATE loginsL
1706             SET timeCreated = :corrupt,
1707                 timePasswordChanged = :corrupt,
1708                 timeLastUsed = :corrupt,
1709                 timeLastBreachAlertDismissed = :corrupt,
1710                 local_modified = -1
1711             WHERE guid = :guid",
1712            named_params! { ":corrupt": CORRUPT, ":guid": &login.meta.id },
1713        )
1714        .unwrap();
1715
1716        let fetched = [
1717            db.get_by_id(&login.meta.id).unwrap().unwrap(),
1718            db.get_all().unwrap().pop().unwrap(),
1719        ];
1720        for fetched in fetched {
1721            assert_eq!(fetched.meta.time_created, 0);
1722            assert_eq!(fetched.meta.time_password_changed, 0);
1723            assert_eq!(fetched.meta.time_last_used, 0);
1724            assert_eq!(fetched.meta.time_last_breach_alert_dismissed, Some(0));
1725        }
1726    }
1727
1728    /// The store must not accept a timestamp it cannot hand back out again.
1729    #[test]
1730    fn test_add_with_meta_repairs_absurd_timestamps() {
1731        ensure_initialized();
1732
1733        let db = LoginDb::open_in_memory();
1734        let guid = Guid::random();
1735        let added = db
1736            .add_with_meta(LoginEntryWithMeta {
1737                entry: LoginEntry {
1738                    origin: "https://www.example.com".into(),
1739                    http_realm: Some("https://www.example.com".into()),
1740                    username: "user".into(),
1741                    password: "password".into(),
1742                    ..Default::default()
1743                },
1744                meta: LoginMeta {
1745                    id: guid.to_string(),
1746                    time_created: 18446744071857664,
1747                    time_password_changed: i64::MAX,
1748                    time_last_used: -1,
1749                    times_used: 1,
1750                    time_last_breach_alert_dismissed: Some(i64::MAX),
1751                },
1752            })
1753            .unwrap();
1754
1755        assert_eq!(added.meta.time_created, 0);
1756        assert_eq!(added.meta.time_password_changed, 0);
1757        assert_eq!(added.meta.time_last_used, 0);
1758        assert_eq!(added.meta.time_last_breach_alert_dismissed, Some(0));
1759    }
1760
1761    #[test]
1762    fn test_add_with_meta_invalid_guid() {
1763        ensure_initialized();
1764
1765        let now_ms = util::system_time_ms_i64(SystemTime::now());
1766        // A guid containing a comma is invalid for the sync server.
1767        let meta = LoginMeta {
1768            id: "invalid,guid".to_string(),
1769            time_created: now_ms,
1770            time_password_changed: now_ms,
1771            time_last_used: now_ms,
1772            times_used: 1,
1773            time_last_breach_alert_dismissed: None,
1774        };
1775        let db = LoginDb::open_in_memory();
1776        let result = db.add_with_meta(LoginEntryWithMeta {
1777            entry: LoginEntry {
1778                origin: "https://www.example.com".into(),
1779                http_realm: Some("https://www.example.com".into()),
1780                username: "test".into(),
1781                password: "sekret".into(),
1782                ..LoginEntry::default()
1783            },
1784            meta,
1785        });
1786
1787        // Without the fixup feature the invalid guid is rejected; with it, the guid
1788        // is regenerated to one that is valid for the sync server.
1789        #[cfg(not(feature = "fixup_invalid_guids"))]
1790        assert!(result.is_err());
1791
1792        #[cfg(feature = "fixup_invalid_guids")]
1793        {
1794            let login = result.expect("invalid guid should be repaired");
1795            assert!(Guid::new(&login.meta.id).is_valid_for_sync_server());
1796        }
1797    }
1798
1799    #[test]
1800    fn test_add_with_meta_duplicate_id() {
1801        ensure_initialized();
1802
1803        let guid = Guid::random();
1804        let now_ms = util::system_time_ms_i64(SystemTime::now());
1805        let meta = LoginMeta {
1806            id: guid.to_string(),
1807            time_created: now_ms,
1808            time_password_changed: now_ms,
1809            time_last_used: now_ms,
1810            times_used: 1,
1811            time_last_breach_alert_dismissed: None,
1812        };
1813
1814        let db = LoginDb::open_in_memory();
1815        db.add_with_meta(LoginEntryWithMeta {
1816            entry: LoginEntry {
1817                origin: "https://www.example.com".into(),
1818                http_realm: Some("https://www.example.com".into()),
1819                username: "test".into(),
1820                password: "sekret".into(),
1821                ..LoginEntry::default()
1822            },
1823            meta: meta.clone(),
1824        })
1825        .expect("should be able to add login with record");
1826
1827        // Adding a second login that reuses the same id (different origin so the
1828        // dupe-check passes) succeeds and replaces the existing record.
1829        db.add_with_meta(LoginEntryWithMeta {
1830            entry: LoginEntry {
1831                origin: "https://www.other.com".into(),
1832                http_realm: Some("https://www.other.com".into()),
1833                username: "test".into(),
1834                password: "sekret".into(),
1835                ..LoginEntry::default()
1836            },
1837            meta,
1838        })
1839        .expect("should be able to re-add a login with the same id");
1840
1841        let fetched = db
1842            .get_by_id(&guid)
1843            .expect("should work")
1844            .expect("should get a record");
1845        assert_eq!(fetched.fields.origin, "https://www.other.com");
1846    }
1847
1848    #[test]
1849    fn test_record_potentially_vulnerable_passwords() {
1850        ensure_initialized();
1851        let db = LoginDb::open_in_memory();
1852
1853        // Initially breachesL should be empty
1854        let count: i64 = db
1855            .db
1856            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1857            .unwrap();
1858        assert_eq!(count, 0);
1859
1860        // Record some passwords
1861        db.record_potentially_vulnerable_passwords(vec![
1862            "password1".into(),
1863            "password2".into(),
1864            "password3".into(),
1865        ])
1866        .unwrap();
1867
1868        // Verify they were inserted
1869        let count: i64 = db
1870            .db
1871            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1872            .unwrap();
1873        assert_eq!(count, 3);
1874
1875        // Try to insert duplicates - should be filtered out
1876        db.record_potentially_vulnerable_passwords(vec!["password1".into(), "password4".into()])
1877            .unwrap();
1878
1879        // Only password4 should have been added
1880        let count: i64 = db
1881            .db
1882            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1883            .unwrap();
1884        assert_eq!(count, 4);
1885
1886        // Try to insert only duplicates - should be a no-op
1887        db.record_potentially_vulnerable_passwords(vec!["password1".into(), "password2".into()])
1888            .unwrap();
1889
1890        let count: i64 = db
1891            .db
1892            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1893            .unwrap();
1894        assert_eq!(count, 4);
1895    }
1896
1897    #[test]
1898    fn test_add_with_meta_deleted() {
1899        ensure_initialized();
1900
1901        let guid = Guid::random();
1902        let now_ms = util::system_time_ms_i64(SystemTime::now());
1903        let login = LoginEntry {
1904            origin: "https://www.example.com".into(),
1905            http_realm: Some("https://www.example.com".into()),
1906            username: "test".into(),
1907            password: "sekret".into(),
1908            ..LoginEntry::default()
1909        };
1910        let meta = LoginMeta {
1911            id: guid.to_string(),
1912            time_created: now_ms,
1913            time_password_changed: now_ms + 100,
1914            time_last_used: now_ms + 10,
1915            times_used: 42,
1916            time_last_breach_alert_dismissed: None,
1917        };
1918
1919        let db = LoginDb::open_in_memory();
1920        let entry_with_meta = LoginEntryWithMeta {
1921            entry: login.clone(),
1922            meta: meta.clone(),
1923        };
1924
1925        db.add_with_meta(entry_with_meta)
1926            .expect("should be able to add login with record");
1927
1928        db.delete(&guid).expect("should be able to delete login");
1929
1930        let entry_with_meta2 = LoginEntryWithMeta {
1931            entry: login.clone(),
1932            meta: meta.clone(),
1933        };
1934
1935        db.add_with_meta(entry_with_meta2)
1936            .expect("should be able to re-add login with record");
1937
1938        let fetched = db
1939            .get_by_id(&guid)
1940            .expect("should work")
1941            .expect("should get a record");
1942
1943        assert_eq!(fetched.meta, meta);
1944    }
1945
1946    #[test]
1947    fn test_unicode_submit() {
1948        ensure_initialized();
1949        let db = LoginDb::open_in_memory();
1950        let added = db
1951            .add(LoginEntry {
1952                form_action_origin: Some("http://😍.com".into()),
1953                origin: "http://😍.com".into(),
1954                http_realm: None,
1955                username_field: "😍".into(),
1956                password_field: "😍".into(),
1957                username: "😍".into(),
1958                password: "😍".into(),
1959            })
1960            .unwrap();
1961        let fetched = db
1962            .get_by_id(&added.meta.id)
1963            .expect("should work")
1964            .expect("should get a record");
1965        assert_eq!(added, fetched);
1966        assert_eq!(fetched.fields.origin, "http://xn--r28h.com");
1967        assert_eq!(
1968            fetched.fields.form_action_origin,
1969            Some("http://xn--r28h.com".to_string())
1970        );
1971        assert_eq!(fetched.fields.username_field, "😍");
1972        assert_eq!(fetched.fields.password_field, "😍");
1973        let sec_fields = fetched.decrypt_fields(db.encdec.as_ref()).unwrap();
1974        assert_eq!(sec_fields.username, "😍");
1975        assert_eq!(sec_fields.password, "😍");
1976    }
1977
1978    #[test]
1979    fn test_unicode_realm() {
1980        ensure_initialized();
1981        let db = LoginDb::open_in_memory();
1982        let added = db
1983            .add(LoginEntry {
1984                form_action_origin: None,
1985                origin: "http://😍.com".into(),
1986                http_realm: Some("😍😍".into()),
1987                username: "😍".into(),
1988                password: "😍".into(),
1989                ..Default::default()
1990            })
1991            .unwrap();
1992        let fetched = db
1993            .get_by_id(&added.meta.id)
1994            .expect("should work")
1995            .expect("should get a record");
1996        assert_eq!(added, fetched);
1997        assert_eq!(fetched.fields.origin, "http://xn--r28h.com");
1998        assert_eq!(fetched.fields.http_realm.unwrap(), "😍😍");
1999    }
2000
2001    fn check_matches(db: &LoginDb, query: &str, expected: &[&str]) {
2002        let mut results = db
2003            .get_by_base_domain(query)
2004            .unwrap()
2005            .into_iter()
2006            .map(|l| l.fields.origin)
2007            .collect::<Vec<String>>();
2008        results.sort_unstable();
2009        let mut sorted = expected.to_owned();
2010        sorted.sort_unstable();
2011        assert_eq!(sorted, results);
2012    }
2013
2014    fn check_good_bad(
2015        good: Vec<&str>,
2016        bad: Vec<&str>,
2017        good_queries: Vec<&str>,
2018        zero_queries: Vec<&str>,
2019    ) {
2020        let db = LoginDb::open_in_memory();
2021        for h in good.iter().chain(bad.iter()) {
2022            db.add(LoginEntry {
2023                origin: (*h).into(),
2024                http_realm: Some((*h).into()),
2025                password: "test".into(),
2026                ..Default::default()
2027            })
2028            .unwrap();
2029        }
2030        for query in good_queries {
2031            check_matches(&db, query, &good);
2032        }
2033        for query in zero_queries {
2034            check_matches(&db, query, &[]);
2035        }
2036    }
2037
2038    #[test]
2039    fn test_get_by_base_domain_invalid() {
2040        ensure_initialized();
2041        check_good_bad(
2042            vec!["https://example.com"],
2043            vec![],
2044            vec![],
2045            vec!["invalid query"],
2046        );
2047    }
2048
2049    #[test]
2050    fn test_get_by_base_domain() {
2051        ensure_initialized();
2052        check_good_bad(
2053            vec![
2054                "https://example.com",
2055                "https://www.example.com",
2056                "http://www.example.com",
2057                "http://www.example.com:8080",
2058                "http://sub.example.com:8080",
2059                "https://sub.example.com:8080",
2060                "https://sub.sub.example.com",
2061                "ftp://sub.example.com",
2062            ],
2063            vec![
2064                "https://badexample.com",
2065                "https://example.co",
2066                "https://example.com.au",
2067            ],
2068            vec!["example.com"],
2069            vec!["foo.com"],
2070        );
2071    }
2072
2073    #[test]
2074    fn test_get_by_base_domain_punicode() {
2075        ensure_initialized();
2076        // punycode! This is likely to need adjusting once we normalize
2077        // on insert.
2078        check_good_bad(
2079            vec![
2080                "http://xn--r28h.com", // punycoded version of "http://😍.com"
2081            ],
2082            vec!["http://💖.com"],
2083            vec!["😍.com", "xn--r28h.com"],
2084            vec![],
2085        );
2086    }
2087
2088    #[test]
2089    fn test_get_by_base_domain_ipv4() {
2090        ensure_initialized();
2091        check_good_bad(
2092            vec!["http://127.0.0.1", "https://127.0.0.1:8000"],
2093            vec!["https://127.0.0.0", "https://example.com"],
2094            vec!["127.0.0.1"],
2095            vec!["127.0.0.2"],
2096        );
2097    }
2098
2099    #[test]
2100    fn test_get_by_base_domain_ipv6() {
2101        ensure_initialized();
2102        check_good_bad(
2103            vec!["http://[::1]", "https://[::1]:8000"],
2104            vec!["https://[0:0:0:0:0:0:1:1]", "https://example.com"],
2105            vec!["[::1]", "[0:0:0:0:0:0:0:1]"],
2106            vec!["[0:0:0:0:0:0:1:2]"],
2107        );
2108    }
2109
2110    #[test]
2111    fn test_add() {
2112        ensure_initialized();
2113        let db = LoginDb::open_in_memory();
2114        let to_add = LoginEntry {
2115            origin: "https://www.example.com".into(),
2116            http_realm: Some("https://www.example.com".into()),
2117            username: "test_user".into(),
2118            password: "test_password".into(),
2119            ..Default::default()
2120        };
2121        let login = db.add(to_add).unwrap();
2122        let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap();
2123
2124        assert_eq!(login.fields.origin, login2.fields.origin);
2125        assert_eq!(login.fields.http_realm, login2.fields.http_realm);
2126        assert_eq!(login.sec_fields, login2.sec_fields);
2127    }
2128
2129    #[test]
2130    fn test_update() {
2131        ensure_initialized();
2132        let db = LoginDb::open_in_memory();
2133        let login = db
2134            .add(LoginEntry {
2135                origin: "https://www.example.com".into(),
2136                http_realm: Some("https://www.example.com".into()),
2137                username: "user1".into(),
2138                password: "password1".into(),
2139                ..Default::default()
2140            })
2141            .unwrap();
2142        db.update(
2143            &login.meta.id,
2144            LoginEntry {
2145                origin: "https://www.example2.com".into(),
2146                http_realm: Some("https://www.example2.com".into()),
2147                username: "user2".into(),
2148                password: "password2".into(),
2149                ..Default::default() // TODO: check and fix if needed
2150            },
2151        )
2152        .unwrap();
2153
2154        let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap();
2155
2156        assert_eq!(login2.fields.origin, "https://www.example2.com");
2157        assert_eq!(
2158            login2.fields.http_realm,
2159            Some("https://www.example2.com".into())
2160        );
2161        let sec_fields = login2.decrypt_fields(db.encdec.as_ref()).unwrap();
2162        assert_eq!(sec_fields.username, "user2");
2163        assert_eq!(sec_fields.password, "password2");
2164    }
2165
2166    #[test]
2167    fn test_touch() {
2168        ensure_initialized();
2169        let db = LoginDb::open_in_memory();
2170        let login = db
2171            .add(LoginEntry {
2172                origin: "https://www.example.com".into(),
2173                http_realm: Some("https://www.example.com".into()),
2174                username: "user1".into(),
2175                password: "password1".into(),
2176                ..Default::default()
2177            })
2178            .unwrap();
2179        // Simulate touch happening at another "time"
2180        thread::sleep(time::Duration::from_millis(50));
2181        db.touch(&login.meta.id).unwrap();
2182        let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap();
2183        assert!(login2.meta.time_last_used > login.meta.time_last_used);
2184        assert_eq!(login2.meta.times_used, login.meta.times_used + 1);
2185    }
2186
2187    #[test]
2188    fn test_update_does_not_count_as_use() {
2189        // A plain update is not a password use.
2190        // It must not bump `times_used` or `time_last_used`. Only `touch()` is
2191        // allowed to do that.
2192        ensure_initialized();
2193        let db = LoginDb::open_in_memory();
2194        let login = db
2195            .add(LoginEntry {
2196                origin: "https://www.example.com".into(),
2197                http_realm: Some("https://www.example.com".into()),
2198                username: "user1".into(),
2199                password: "password1".into(),
2200                ..Default::default()
2201            })
2202            .unwrap();
2203        // Make sure the "now" an update would use differs from the add time.
2204        thread::sleep(time::Duration::from_millis(50));
2205        db.update(
2206            &login.meta.id,
2207            LoginEntry {
2208                origin: "https://www.example.com".into(),
2209                http_realm: Some("https://www.example.com".into()),
2210                username: "user1".into(),
2211                password: "password2".into(),
2212                ..Default::default()
2213            },
2214        )
2215        .unwrap();
2216        let updated = db.get_by_id(&login.meta.id).unwrap().unwrap();
2217        // An edit is not a use: times_used must stay unchanged.
2218        assert_eq!(updated.meta.times_used, login.meta.times_used);
2219        // An edit is not a use: time_last_used must stay unchanged.
2220        assert_eq!(updated.meta.time_last_used, login.meta.time_last_used);
2221    }
2222
2223    #[test]
2224    fn test_breach_alert_dismissal() {
2225        ensure_initialized();
2226        let db = LoginDb::open_in_memory();
2227        let login = db
2228            .add(LoginEntry {
2229                origin: "https://www.example.com".into(),
2230                http_realm: Some("https://www.example.com".into()),
2231                username: "user1".into(),
2232                password: "password1".into(),
2233                ..Default::default()
2234            })
2235            .unwrap();
2236        // initial state
2237        assert!(login.meta.time_last_breach_alert_dismissed.is_none());
2238
2239        // dismiss
2240        db.record_breach_alert_dismissal(&login.meta.id).unwrap();
2241        let login1 = db.get_by_id(&login.meta.id).unwrap().unwrap();
2242        assert!(login1.meta.time_last_breach_alert_dismissed.is_some());
2243    }
2244
2245    #[test]
2246    fn test_breach_alert_dismissal_with_specific_timestamp() {
2247        ensure_initialized();
2248        let db = LoginDb::open_in_memory();
2249        let login = db
2250            .add(LoginEntry {
2251                origin: "https://www.example.com".into(),
2252                http_realm: Some("https://www.example.com".into()),
2253                username: "user1".into(),
2254                password: "password1".into(),
2255                ..Default::default()
2256            })
2257            .unwrap();
2258
2259        let dismiss_time = login.meta.time_password_changed + 1000;
2260        db.record_breach_alert_dismissal_time(&login.meta.id, dismiss_time)
2261            .unwrap();
2262
2263        let retrieved = db
2264            .get_by_id(&login.meta.id)
2265            .unwrap()
2266            .unwrap()
2267            .decrypt(db.encdec.as_ref())
2268            .unwrap();
2269        assert_eq!(
2270            retrieved.time_last_breach_alert_dismissed,
2271            Some(dismiss_time)
2272        );
2273    }
2274
2275    #[test]
2276    fn test_delete() {
2277        ensure_initialized();
2278        let db = LoginDb::open_in_memory();
2279        let login = db
2280            .add(LoginEntry {
2281                origin: "https://www.example.com".into(),
2282                http_realm: Some("https://www.example.com".into()),
2283                username: "test_user".into(),
2284                password: "test_password".into(),
2285                ..Default::default()
2286            })
2287            .unwrap();
2288
2289        assert!(db.delete(login.guid_str()).unwrap());
2290
2291        let local_login = db
2292            .query_row(
2293                "SELECT * FROM loginsL WHERE guid = :guid",
2294                named_params! { ":guid": login.guid_str() },
2295                |row| Ok(LocalLogin::test_raw_from_row(row).unwrap()),
2296            )
2297            .unwrap();
2298        assert_eq!(local_login.fields.http_realm, None);
2299        assert_eq!(local_login.fields.form_action_origin, None);
2300
2301        assert!(!db.exists(login.guid_str()).unwrap());
2302    }
2303
2304    #[test]
2305    fn test_delete_many() {
2306        ensure_initialized();
2307        let db = LoginDb::open_in_memory();
2308
2309        let login_a = db
2310            .add(LoginEntry {
2311                origin: "https://a.example.com".into(),
2312                http_realm: Some("https://www.example.com".into()),
2313                username: "test_user".into(),
2314                password: "test_password".into(),
2315                ..Default::default()
2316            })
2317            .unwrap();
2318
2319        let login_b = db
2320            .add(LoginEntry {
2321                origin: "https://b.example.com".into(),
2322                http_realm: Some("https://www.example.com".into()),
2323                username: "test_user".into(),
2324                password: "test_password".into(),
2325                ..Default::default()
2326            })
2327            .unwrap();
2328
2329        let result = db
2330            .delete_many(vec![login_a.guid_str(), login_b.guid_str()])
2331            .unwrap();
2332        assert!(result[0]);
2333        assert!(result[1]);
2334        assert!(!db.exists(login_a.guid_str()).unwrap());
2335        assert!(!db.exists(login_b.guid_str()).unwrap());
2336    }
2337
2338    #[test]
2339    fn test_subsequent_delete_many() {
2340        ensure_initialized();
2341        let db = LoginDb::open_in_memory();
2342
2343        let login = db
2344            .add(LoginEntry {
2345                origin: "https://a.example.com".into(),
2346                http_realm: Some("https://www.example.com".into()),
2347                username: "test_user".into(),
2348                password: "test_password".into(),
2349                ..Default::default()
2350            })
2351            .unwrap();
2352
2353        let result = db.delete_many(vec![login.guid_str()]).unwrap();
2354        assert!(result[0]);
2355        assert!(!db.exists(login.guid_str()).unwrap());
2356
2357        let result = db.delete_many(vec![login.guid_str()]).unwrap();
2358        assert!(!result[0]);
2359    }
2360
2361    #[test]
2362    fn test_delete_many_with_non_existent_id() {
2363        ensure_initialized();
2364        let db = LoginDb::open_in_memory();
2365
2366        let result = db.delete_many(vec![&Guid::random()]).unwrap();
2367        assert!(!result[0]);
2368    }
2369
2370    #[test]
2371    fn test_delete_all() {
2372        ensure_initialized();
2373        let db = LoginDb::open_in_memory();
2374        let login_a = db
2375            .add(LoginEntry {
2376                origin: "https://a.example.com".into(),
2377                http_realm: Some("https://www.example.com".into()),
2378                username: "test_user".into(),
2379                password: "test_password".into(),
2380                ..Default::default()
2381            })
2382            .unwrap();
2383        let login_b = db
2384            .add(LoginEntry {
2385                origin: "https://b.example.com".into(),
2386                http_realm: Some("https://www.example.com".into()),
2387                username: "test_user".into(),
2388                password: "test_password".into(),
2389                ..Default::default()
2390            })
2391            .unwrap();
2392
2393        let mut deleted = db.delete_all().unwrap();
2394        deleted.sort();
2395        let mut expected = vec![login_a.meta.id.clone(), login_b.meta.id.clone()];
2396        expected.sort();
2397        assert_eq!(deleted, expected);
2398        assert!(!db.exists(login_a.guid_str()).unwrap());
2399        assert!(!db.exists(login_b.guid_str()).unwrap());
2400
2401        // On an empty database it's a no-op returning no ids.
2402        assert_eq!(db.delete_all().unwrap(), Vec::<String>::new());
2403    }
2404
2405    #[test]
2406    fn test_delete_all_except_fxa() {
2407        ensure_initialized();
2408        let db = LoginDb::open_in_memory();
2409        let login = db
2410            .add(LoginEntry {
2411                origin: "https://a.example.com".into(),
2412                http_realm: Some("https://www.example.com".into()),
2413                username: "test_user".into(),
2414                password: "test_password".into(),
2415                ..Default::default()
2416            })
2417            .unwrap();
2418        let fxa_login = db
2419            .add(LoginEntry {
2420                origin: FXA_CREDENTIALS_ORIGIN.into(),
2421                http_realm: Some("https://www.example.com".into()),
2422                username: "test_user".into(),
2423                password: "test_password".into(),
2424                ..Default::default()
2425            })
2426            .unwrap();
2427
2428        let deleted = db.delete_all_except_fxa().unwrap();
2429        assert_eq!(deleted, vec![login.meta.id.clone()]);
2430
2431        // Only the FxA login remains.
2432        assert!(!db.exists(login.guid_str()).unwrap());
2433        assert!(db.exists(fxa_login.guid_str()).unwrap());
2434    }
2435
2436    #[test]
2437    fn test_wipe_local_except_fxa() {
2438        ensure_initialized();
2439        let db = LoginDb::open_in_memory();
2440        let login = db
2441            .add(LoginEntry {
2442                origin: "https://a.example.com".into(),
2443                http_realm: Some("https://www.example.com".into()),
2444                username: "test_user".into(),
2445                password: "test_password".into(),
2446                ..Default::default()
2447            })
2448            .unwrap();
2449        let fxa_login = db
2450            .add(LoginEntry {
2451                origin: FXA_CREDENTIALS_ORIGIN.into(),
2452                http_realm: Some("https://www.example.com".into()),
2453                username: "test_user".into(),
2454                password: "test_password".into(),
2455                ..Default::default()
2456            })
2457            .unwrap();
2458
2459        db.wipe_local_except_fxa().unwrap();
2460
2461        // Only the FxA login remains.
2462        assert!(!db.exists(login.guid_str()).unwrap());
2463        assert!(db.exists(fxa_login.guid_str()).unwrap());
2464    }
2465
2466    #[test]
2467    fn test_delete_local_for_remote_replacement() {
2468        ensure_initialized();
2469        let db = LoginDb::open_in_memory();
2470        let login = db
2471            .add(LoginEntry {
2472                origin: "https://www.example.com".into(),
2473                http_realm: Some("https://www.example.com".into()),
2474                username: "test_user".into(),
2475                password: "test_password".into(),
2476                ..Default::default()
2477            })
2478            .unwrap();
2479
2480        let result = db
2481            .delete_local_records_for_remote_replacement(vec![login.guid_str()])
2482            .unwrap();
2483
2484        let local_guids = get_local_guids(&db);
2485        assert_eq!(local_guids.len(), 0);
2486
2487        let mirror_guids = get_mirror_guids(&db);
2488        assert_eq!(mirror_guids.len(), 0);
2489
2490        assert_eq!(result.local_deleted, 1);
2491    }
2492
2493    mod test_find_login_to_update {
2494        use super::*;
2495
2496        fn make_entry(username: &str, password: &str) -> LoginEntry {
2497            LoginEntry {
2498                origin: "https://www.example.com".into(),
2499                http_realm: Some("the website".into()),
2500                username: username.into(),
2501                password: password.into(),
2502                ..Default::default()
2503            }
2504        }
2505
2506        fn make_saved_login(db: &LoginDb, username: &str, password: &str) -> Login {
2507            db.add(make_entry(username, password))
2508                .unwrap()
2509                .decrypt(db.encdec.as_ref())
2510                .unwrap()
2511        }
2512
2513        #[test]
2514        fn test_match() {
2515            ensure_initialized();
2516            let db = LoginDb::open_in_memory();
2517            let login = make_saved_login(&db, "user", "pass");
2518            assert_eq!(
2519                Some(login),
2520                db.find_login_to_update(make_entry("user", "pass")).unwrap(),
2521            );
2522        }
2523
2524        #[test]
2525        fn test_non_matches() {
2526            ensure_initialized();
2527            let db = LoginDb::open_in_memory();
2528            // Non-match because the username is different
2529            make_saved_login(&db, "other-user", "pass");
2530            // Non-match because the http_realm is different
2531            db.add(LoginEntry {
2532                origin: "https://www.example.com".into(),
2533                http_realm: Some("the other website".into()),
2534                username: "user".into(),
2535                password: "pass".into(),
2536                ..Default::default()
2537            })
2538            .unwrap();
2539            // Non-match because it uses form_action_origin instead of http_realm
2540            db.add(LoginEntry {
2541                origin: "https://www.example.com".into(),
2542                form_action_origin: Some("https://www.example.com/".into()),
2543                username: "user".into(),
2544                password: "pass".into(),
2545                ..Default::default()
2546            })
2547            .unwrap();
2548            assert_eq!(
2549                None,
2550                db.find_login_to_update(make_entry("user", "pass")).unwrap(),
2551            );
2552        }
2553
2554        #[test]
2555        fn test_match_blank_password() {
2556            ensure_initialized();
2557            let db = LoginDb::open_in_memory();
2558            let login = make_saved_login(&db, "", "pass");
2559            assert_eq!(
2560                Some(login),
2561                db.find_login_to_update(make_entry("user", "pass")).unwrap(),
2562            );
2563        }
2564
2565        #[test]
2566        fn test_username_match_takes_precedence_over_blank_username() {
2567            ensure_initialized();
2568            let db = LoginDb::open_in_memory();
2569            make_saved_login(&db, "", "pass");
2570            let username_match = make_saved_login(&db, "user", "pass");
2571            assert_eq!(
2572                Some(username_match),
2573                db.find_login_to_update(make_entry("user", "pass")).unwrap(),
2574            );
2575        }
2576
2577        #[test]
2578        fn test_invalid_login() {
2579            ensure_initialized();
2580            let db = LoginDb::open_in_memory();
2581            assert!(db
2582                .find_login_to_update(LoginEntry {
2583                    http_realm: None,
2584                    form_action_origin: None,
2585                    ..LoginEntry::default()
2586                })
2587                .is_err());
2588        }
2589
2590        #[test]
2591        fn test_update_with_duplicate_login() {
2592            ensure_initialized();
2593            // If we have duplicate logins in the database, it should be possible to update them
2594            // without triggering a DuplicateLogin error
2595            let db = LoginDb::open_in_memory();
2596            let login = make_saved_login(&db, "user", "pass");
2597            let mut dupe = login.clone().encrypt(&*TEST_ENCDEC).unwrap();
2598            dupe.meta.id = "different-guid".to_string();
2599            db.insert_new_login(&dupe).unwrap();
2600
2601            let mut entry = login.entry();
2602            entry.password = "pass2".to_string();
2603            db.update(&login.id, entry).unwrap();
2604
2605            let mut entry = login.entry();
2606            entry.password = "pass3".to_string();
2607            db.add_or_update(entry).unwrap();
2608        }
2609
2610        #[test]
2611        fn test_password_reuse_detection() {
2612            ensure_initialized();
2613            let db = LoginDb::open_in_memory();
2614
2615            // Create two logins with the same password
2616            let login1 = db
2617                .add(LoginEntry {
2618                    origin: "https://site1.com".into(),
2619                    http_realm: Some("realm".into()),
2620                    username: "user1".into(),
2621                    password: "shared_password".into(),
2622                    ..Default::default()
2623                })
2624                .unwrap();
2625
2626            let login2 = db
2627                .add(LoginEntry {
2628                    origin: "https://site2.com".into(),
2629                    http_realm: Some("realm".into()),
2630                    username: "user2".into(),
2631                    password: "shared_password".into(),
2632                    ..Default::default()
2633                })
2634                .unwrap();
2635
2636            // Initially, neither login is vulnerable
2637            assert!(!db
2638                .is_potentially_vulnerable_password(&login1.meta.id)
2639                .unwrap());
2640            assert!(!db
2641                .is_potentially_vulnerable_password(&login2.meta.id)
2642                .unwrap());
2643            // And checking both logins should return empty (none are vulnerable yet)
2644            let vulnerable = db
2645                .are_potentially_vulnerable_passwords(&[&login1.meta.id, &login2.meta.id])
2646                .unwrap();
2647            assert_eq!(vulnerable.len(), 0);
2648
2649            // Record "shared_password" as a vulnerable password
2650            db.record_potentially_vulnerable_passwords(vec!["shared_password".into()])
2651                .unwrap();
2652
2653            // login2 should be recognized as vulnerable (same password as breached login1)
2654            assert!(db
2655                .is_potentially_vulnerable_password(&login2.meta.id)
2656                .unwrap());
2657            // Batch check: both logins should be vulnerable (they share the same password)
2658            let vulnerable = db
2659                .are_potentially_vulnerable_passwords(&[&login1.meta.id, &login2.meta.id])
2660                .unwrap();
2661            assert_eq!(vulnerable.len(), 2);
2662            assert!(vulnerable.contains(&login1.meta.id));
2663            assert!(vulnerable.contains(&login2.meta.id));
2664
2665            // Change password of login2 → should no longer be vulnerable
2666            db.update(
2667                &login2.meta.id,
2668                LoginEntry {
2669                    origin: "https://site2.com".into(),
2670                    http_realm: Some("realm".into()),
2671                    username: "user2".into(),
2672                    password: "different_password".into(),
2673                    ..Default::default()
2674                },
2675            )
2676            .unwrap();
2677
2678            assert!(!db
2679                .is_potentially_vulnerable_password(&login2.meta.id)
2680                .unwrap());
2681        }
2682
2683        #[test]
2684        fn test_reset_all_breaches_clears_breach_table() {
2685            ensure_initialized();
2686            let db = LoginDb::open_in_memory();
2687
2688            let login = db
2689                .add(LoginEntry {
2690                    origin: "https://example.com".into(),
2691                    http_realm: Some("realm".into()),
2692                    username: "user".into(),
2693                    password: "password123".into(),
2694                    ..Default::default()
2695                })
2696                .unwrap();
2697
2698            db.record_potentially_vulnerable_passwords(vec!["password123".into()])
2699                .unwrap();
2700
2701            // Verify that breachesL has an entry
2702            let count: i64 = db
2703                .db
2704                .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
2705                .unwrap();
2706            assert_eq!(count, 1);
2707            // And verify via the API that this login is vulnerable
2708            let vulnerable = db
2709                .are_potentially_vulnerable_passwords(&[&login.meta.id])
2710                .unwrap();
2711            assert_eq!(vulnerable.len(), 1);
2712            assert_eq!(vulnerable[0], login.meta.id);
2713
2714            // Reset all breaches
2715            db.reset_all_breaches().unwrap();
2716
2717            // After reset, breachesL should be empty
2718            let count: i64 = db
2719                .db
2720                .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
2721                .unwrap();
2722            assert_eq!(count, 0);
2723            // And verify via the API that no logins are vulnerable anymore
2724            let vulnerable = db
2725                .are_potentially_vulnerable_passwords(&[&login.meta.id])
2726                .unwrap();
2727            assert_eq!(vulnerable.len(), 0);
2728        }
2729
2730        #[test]
2731        fn test_different_passwords_not_vulnerable() {
2732            ensure_initialized();
2733            let db = LoginDb::open_in_memory();
2734
2735            let login1 = db
2736                .add(LoginEntry {
2737                    origin: "https://site1.com".into(),
2738                    http_realm: Some("realm".into()),
2739                    username: "user".into(),
2740                    password: "password_A".into(),
2741                    ..Default::default()
2742                })
2743                .unwrap();
2744
2745            let login2 = db
2746                .add(LoginEntry {
2747                    origin: "https://site2.com".into(),
2748                    http_realm: Some("realm".into()),
2749                    username: "user".into(),
2750                    password: "password_B".into(),
2751                    ..Default::default()
2752                })
2753                .unwrap();
2754
2755            db.record_potentially_vulnerable_passwords(vec!["password_A".into()])
2756                .unwrap();
2757
2758            // login2 has a different password → not vulnerable
2759            assert!(!db
2760                .is_potentially_vulnerable_password(&login2.meta.id)
2761                .unwrap());
2762            // Batch check: login1 should be vulnerable (its password is in breachesL)
2763            // login2 has a different password, so it's not vulnerable
2764            let vulnerable = db
2765                .are_potentially_vulnerable_passwords(&[&login1.meta.id, &login2.meta.id])
2766                .unwrap();
2767            assert_eq!(vulnerable.len(), 1);
2768            assert!(vulnerable.contains(&login1.meta.id));
2769        }
2770    }
2771}