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