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