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