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 now_ms = util::system_time_ms_i64(SystemTime::now());
556        let sql = format!(
557            "UPDATE loginsL
558             SET local_modified                           = :now_millis,
559                 timeLastUsed                             = :time_last_used,
560                 timePasswordChanged                      = :time_password_changed,
561                 httpRealm                                = :http_realm,
562                 formActionOrigin                         = :form_action_origin,
563                 usernameField                            = :username_field,
564                 passwordField                            = :password_field,
565                 timesUsed                                = :times_used,
566                 secFields                                = :sec_fields,
567                 origin                                   = :origin,
568                 -- leave New records as they are, otherwise update them to `changed`
569                 sync_status                              = max(sync_status, {changed})
570             WHERE guid = :guid",
571            changed = SyncStatus::Changed as u8
572        );
573
574        self.db.execute(
575            &sql,
576            named_params! {
577                ":origin": login.fields.origin,
578                ":http_realm": login.fields.http_realm,
579                ":form_action_origin": login.fields.form_action_origin,
580                ":username_field": login.fields.username_field,
581                ":password_field": login.fields.password_field,
582                ":time_last_used": login.meta.time_last_used,
583                ":times_used": login.meta.times_used,
584                ":time_password_changed": login.meta.time_password_changed,
585                ":sec_fields": login.sec_fields,
586                ":guid": &login.meta.id,
587                ":now_millis": now_ms,
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                // An edit is not a use (see bug 2045032)
759                time_last_used: existing.time_last_used,
760                times_used: existing.times_used,
761                time_last_breach_alert_dismissed: None,
762            },
763            fields: LoginFields {
764                origin: entry.origin,
765                form_action_origin: entry.form_action_origin,
766                http_realm: entry.http_realm,
767                username_field: entry.username_field,
768                password_field: entry.password_field,
769            },
770            sec_fields,
771        };
772
773        self.update_existing_login(&result)?;
774        tx.commit()?;
775        Ok(result)
776    }
777
778    pub fn add_or_update(
779        &self,
780        entry: LoginEntry,
781        encdec: &dyn EncryptorDecryptor,
782    ) -> Result<EncryptedLogin> {
783        // Make sure to fixup the entry first, in case that changes the username
784        let entry = entry.fixup()?;
785        match self.find_login_to_update(entry.clone(), encdec)? {
786            Some(login) => self.update(&login.id, entry, encdec),
787            None => self.add(entry, encdec),
788        }
789    }
790
791    pub fn fixup_and_check_for_dupes(
792        &self,
793        guid: &Guid,
794        entry: LoginEntry,
795        encdec: &dyn EncryptorDecryptor,
796    ) -> Result<LoginEntry> {
797        let entry = entry.fixup()?;
798        self.check_for_dupes(guid, &entry, encdec)?;
799        Ok(entry)
800    }
801
802    pub fn check_for_dupes(
803        &self,
804        guid: &Guid,
805        entry: &LoginEntry,
806        encdec: &dyn EncryptorDecryptor,
807    ) -> Result<()> {
808        if self.dupe_exists(guid, entry, encdec)? {
809            return Err(InvalidLogin::DuplicateLogin.into());
810        }
811        Ok(())
812    }
813
814    pub fn dupe_exists(
815        &self,
816        guid: &Guid,
817        entry: &LoginEntry,
818        encdec: &dyn EncryptorDecryptor,
819    ) -> Result<bool> {
820        Ok(self.find_dupe(guid, entry, encdec)?.is_some())
821    }
822
823    pub fn find_dupe(
824        &self,
825        guid: &Guid,
826        entry: &LoginEntry,
827        encdec: &dyn EncryptorDecryptor,
828    ) -> Result<Option<Guid>> {
829        for possible in self.get_by_entry_target(entry)? {
830            if possible.guid() != *guid {
831                let pos_sec_fields = possible.decrypt_fields(encdec)?;
832                if pos_sec_fields.username == entry.username {
833                    return Ok(Some(possible.guid()));
834                }
835            }
836        }
837        Ok(None)
838    }
839
840    // Find saved logins that match the target for a `LoginEntry`
841    //
842    // This means that:
843    //   - `origin` matches
844    //   - Either `form_action_origin` or `http_realm` matches, depending on which one is non-null
845    //
846    // This is used for dupe-checking and `find_login_to_update()`
847    //
848    // Note that `entry` must be a normalized Login (via `fixup()`)
849    fn get_by_entry_target(&self, entry: &LoginEntry) -> Result<Vec<EncryptedLogin>> {
850        // Could be lazy_static-ed...
851        lazy_static::lazy_static! {
852            static ref GET_BY_FORM_ACTION_ORIGIN: String = format!(
853                "SELECT {common_cols} FROM loginsL
854                WHERE is_deleted = 0
855                    AND origin = :origin
856                    AND formActionOrigin = :form_action_origin
857
858                UNION ALL
859
860                SELECT {common_cols} FROM loginsM
861                WHERE is_overridden = 0
862                    AND origin = :origin
863                    AND formActionOrigin = :form_action_origin
864                ",
865                common_cols = schema::COMMON_COLS
866            );
867            static ref GET_BY_HTTP_REALM: String = format!(
868                "SELECT {common_cols} FROM loginsL
869                WHERE is_deleted = 0
870                    AND origin = :origin
871                    AND httpRealm = :http_realm
872
873                UNION ALL
874
875                SELECT {common_cols} FROM loginsM
876                WHERE is_overridden = 0
877                    AND origin = :origin
878                    AND httpRealm = :http_realm
879                ",
880                common_cols = schema::COMMON_COLS
881            );
882        }
883        match (entry.form_action_origin.as_ref(), entry.http_realm.as_ref()) {
884            (Some(form_action_origin), None) => {
885                let params = named_params! {
886                    ":origin": &entry.origin,
887                    ":form_action_origin": form_action_origin,
888                };
889                self.db
890                    .prepare_cached(&GET_BY_FORM_ACTION_ORIGIN)?
891                    .query_and_then(params, EncryptedLogin::from_row)?
892                    .collect()
893            }
894            (None, Some(http_realm)) => {
895                let params = named_params! {
896                    ":origin": &entry.origin,
897                    ":http_realm": http_realm,
898                };
899                self.db
900                    .prepare_cached(&GET_BY_HTTP_REALM)?
901                    .query_and_then(params, EncryptedLogin::from_row)?
902                    .collect()
903            }
904            (Some(_), Some(_)) => Err(InvalidLogin::BothTargets.into()),
905            (None, None) => Err(InvalidLogin::NoTarget.into()),
906        }
907    }
908
909    pub fn exists(&self, id: &str) -> Result<bool> {
910        Ok(self.db.query_row(
911            "SELECT EXISTS(
912                 SELECT 1 FROM loginsL
913                 WHERE guid = :guid AND is_deleted = 0
914                 UNION ALL
915                 SELECT 1 FROM loginsM
916                 WHERE guid = :guid AND is_overridden IS NOT 1
917             )",
918            named_params! { ":guid": id },
919            |row| row.get(0),
920        )?)
921    }
922
923    /// Delete the record with the provided id. Returns true if the record
924    /// existed already.
925    pub fn delete(&self, id: &str) -> Result<bool> {
926        let mut results = self.delete_many(vec![id])?;
927        Ok(results.pop().expect("there should be a single result"))
928    }
929
930    /// Delete the records with the specified IDs. Returns a list of Boolean values
931    /// indicating whether the respective records already existed.
932    pub fn delete_many(&self, ids: Vec<&str>) -> Result<Vec<bool>> {
933        let tx = self.unchecked_transaction_imm()?;
934        let sql = format!(
935            "
936            UPDATE loginsL
937            SET local_modified = :now_ms,
938                sync_status = {status_changed},
939                is_deleted = 1,
940                secFields = '',
941                origin = '',
942                httpRealm = NULL,
943                formActionOrigin = NULL
944            WHERE guid = :guid AND is_deleted IS FALSE
945            ",
946            status_changed = SyncStatus::Changed as u8
947        );
948        let mut stmt = self.db.prepare_cached(&sql)?;
949
950        let mut result = vec![];
951
952        for id in ids {
953            let now_ms = util::system_time_ms_i64(SystemTime::now());
954
955            // For IDs that have, mark is_deleted and clear sensitive fields
956            let update_result = stmt.execute(named_params! { ":now_ms": now_ms, ":guid": id })?;
957
958            let exists = update_result == 1;
959
960            // Mark the mirror as overridden
961            self.execute(
962                "UPDATE loginsM SET is_overridden = 1 WHERE guid = :guid",
963                named_params! { ":guid": id },
964            )?;
965
966            // If we don't have a local record for this ID, but do have it in the mirror
967            // insert a tombstone.
968            self.execute(&format!("
969                INSERT OR IGNORE INTO loginsL
970                        (guid, local_modified, is_deleted, sync_status, origin, timeCreated, timePasswordChanged, secFields)
971                SELECT   guid, :now_ms,        1,          {changed},   '',     timeCreated, :now_ms,             ''
972                FROM loginsM
973                WHERE guid = :guid",
974                changed = SyncStatus::Changed as u8),
975                named_params! { ":now_ms": now_ms, ":guid": id })?;
976
977            result.push(exists);
978        }
979
980        tx.commit()?;
981
982        Ok(result)
983    }
984
985    pub fn delete_undecryptable_records_for_remote_replacement(
986        &self,
987        encdec: &dyn EncryptorDecryptor,
988    ) -> Result<LoginsDeletionMetrics> {
989        // Retrieve a list of guids for logins that cannot be decrypted
990        let corrupted_logins = self
991            .get_all()?
992            .into_iter()
993            .filter(|login| login.clone().decrypt(encdec).is_err())
994            .collect::<Vec<_>>();
995        let ids = corrupted_logins
996            .iter()
997            .map(|login| login.guid_str())
998            .collect::<Vec<_>>();
999
1000        self.delete_local_records_for_remote_replacement(ids)
1001    }
1002
1003    pub fn delete_local_records_for_remote_replacement(
1004        &self,
1005        ids: Vec<&str>,
1006    ) -> Result<LoginsDeletionMetrics> {
1007        let tx = self.unchecked_transaction_imm()?;
1008        let mut local_deleted = 0;
1009        let mut mirror_deleted = 0;
1010
1011        sql_support::each_chunk(&ids, |chunk, _| -> Result<()> {
1012            let deleted = self.execute(
1013                &format!(
1014                    "DELETE FROM loginsL WHERE guid IN ({})",
1015                    sql_support::repeat_sql_values(chunk.len())
1016                ),
1017                rusqlite::params_from_iter(chunk),
1018            )?;
1019            local_deleted += deleted;
1020            Ok(())
1021        })?;
1022
1023        sql_support::each_chunk(&ids, |chunk, _| -> Result<()> {
1024            let deleted = self.execute(
1025                &format!(
1026                    "DELETE FROM loginsM WHERE guid IN ({})",
1027                    sql_support::repeat_sql_values(chunk.len())
1028                ),
1029                rusqlite::params_from_iter(chunk),
1030            )?;
1031            mirror_deleted += deleted;
1032            Ok(())
1033        })?;
1034
1035        tx.commit()?;
1036        Ok(LoginsDeletionMetrics {
1037            local_deleted: local_deleted as u64,
1038            mirror_deleted: mirror_deleted as u64,
1039        })
1040    }
1041
1042    fn mark_mirror_overridden(&self, guid: &str) -> Result<()> {
1043        self.execute_cached(
1044            "UPDATE loginsM SET is_overridden = 1 WHERE guid = :guid",
1045            named_params! { ":guid": guid },
1046        )?;
1047        Ok(())
1048    }
1049
1050    fn ensure_local_overlay_exists(&self, guid: &str) -> Result<()> {
1051        let already_have_local: bool = self.db.query_row(
1052            "SELECT EXISTS(SELECT 1 FROM loginsL WHERE guid = :guid)",
1053            named_params! { ":guid": guid },
1054            |row| row.get(0),
1055        )?;
1056
1057        if already_have_local {
1058            return Ok(());
1059        }
1060
1061        debug!("No overlay; cloning one for {:?}.", guid);
1062        let changed = self.clone_mirror_to_overlay(guid)?;
1063        if changed == 0 {
1064            report_error!(
1065                "logins-local-overlay-error",
1066                "Failed to create local overlay for GUID {guid:?}."
1067            );
1068            return Err(Error::NoSuchRecord(guid.to_owned()));
1069        }
1070        Ok(())
1071    }
1072
1073    fn clone_mirror_to_overlay(&self, guid: &str) -> Result<usize> {
1074        Ok(self.execute_cached(&CLONE_SINGLE_MIRROR_SQL, &[(":guid", &guid as &dyn ToSql)])?)
1075    }
1076
1077    /// Wipe all local data, returns the number of rows deleted
1078    pub fn wipe_local(&self) -> Result<usize> {
1079        info!("Executing wipe_local on password engine!");
1080        let tx = self.unchecked_transaction()?;
1081        let mut row_count = 0;
1082        row_count += self.execute("DELETE FROM loginsL", [])?;
1083        row_count += self.execute("DELETE FROM loginsM", [])?;
1084        row_count += self.execute("DELETE FROM loginsSyncMeta", [])?;
1085        row_count += self.execute("DELETE FROM breachesL", [])?;
1086        tx.commit()?;
1087        Ok(row_count)
1088    }
1089
1090    pub fn shutdown(self) -> Result<()> {
1091        self.db.close().map_err(|(_, e)| Error::SqlError(e))
1092    }
1093}
1094
1095lazy_static! {
1096    static ref GET_ALL_SQL: String = format!(
1097        "SELECT {common_cols} FROM loginsL WHERE is_deleted = 0
1098         UNION ALL
1099         SELECT {common_cols} FROM loginsM WHERE is_overridden = 0",
1100        common_cols = schema::COMMON_COLS,
1101    );
1102    static ref COUNT_ALL_SQL: String = format!(
1103        "SELECT COUNT(*) FROM (
1104          SELECT guid FROM loginsL WHERE is_deleted = 0
1105          UNION ALL
1106          SELECT guid FROM loginsM WHERE is_overridden = 0
1107        )"
1108    );
1109    static ref COUNT_BY_ORIGIN_SQL: String = format!(
1110        "SELECT COUNT(*) FROM (
1111          SELECT guid FROM loginsL WHERE is_deleted = 0 AND origin = :origin
1112          UNION ALL
1113          SELECT guid FROM loginsM WHERE is_overridden = 0 AND origin = :origin
1114        )"
1115    );
1116    static ref COUNT_BY_FORM_ACTION_ORIGIN_SQL: String = format!(
1117        "SELECT COUNT(*) FROM (
1118          SELECT guid FROM loginsL WHERE is_deleted = 0 AND formActionOrigin = :form_action_origin
1119          UNION ALL
1120          SELECT guid FROM loginsM WHERE is_overridden = 0 AND formActionOrigin = :form_action_origin
1121        )"
1122    );
1123    static ref GET_BY_GUID_SQL: String = format!(
1124        "SELECT {common_cols}
1125         FROM loginsL
1126         WHERE is_deleted = 0
1127           AND guid = :guid
1128
1129         UNION ALL
1130
1131         SELECT {common_cols}
1132         FROM loginsM
1133         WHERE is_overridden IS NOT 1
1134           AND guid = :guid
1135         ORDER BY origin ASC
1136
1137         LIMIT 1",
1138        common_cols = schema::COMMON_COLS,
1139    );
1140    pub static ref CLONE_ENTIRE_MIRROR_SQL: String = format!(
1141        "INSERT OR IGNORE INTO loginsL ({common_cols}, local_modified, is_deleted, sync_status)
1142         SELECT {common_cols}, NULL AS local_modified, 0 AS is_deleted, 0 AS sync_status
1143         FROM loginsM",
1144        common_cols = schema::COMMON_COLS,
1145    );
1146    static ref CLONE_SINGLE_MIRROR_SQL: String =
1147        format!("{} WHERE guid = :guid", &*CLONE_ENTIRE_MIRROR_SQL,);
1148}
1149
1150#[cfg(not(feature = "keydb"))]
1151#[cfg(test)]
1152pub mod test_utils {
1153    use super::*;
1154    use crate::encryption::test_utils::decrypt_struct;
1155    use crate::login::test_utils::enc_login;
1156    use crate::SecureLoginFields;
1157    use sync15::ServerTimestamp;
1158
1159    // Insert a login into the local and/or mirror tables.
1160    //
1161    // local_login and mirror_login are specified as Some(password_string)
1162    pub fn insert_login(
1163        db: &LoginDb,
1164        guid: &str,
1165        local_login: Option<&str>,
1166        mirror_login: Option<&str>,
1167    ) {
1168        if let Some(password) = mirror_login {
1169            add_mirror(
1170                db,
1171                &enc_login(guid, password),
1172                &ServerTimestamp(util::system_time_ms_i64(std::time::SystemTime::now())),
1173                local_login.is_some(),
1174            )
1175            .unwrap();
1176        }
1177        if let Some(password) = local_login {
1178            db.insert_new_login(&enc_login(guid, password)).unwrap();
1179        }
1180    }
1181
1182    pub fn insert_encrypted_login(
1183        db: &LoginDb,
1184        local: &EncryptedLogin,
1185        mirror: &EncryptedLogin,
1186        server_modified: &ServerTimestamp,
1187    ) {
1188        db.insert_new_login(local).unwrap();
1189        add_mirror(db, mirror, server_modified, true).unwrap();
1190    }
1191
1192    pub fn add_mirror(
1193        db: &LoginDb,
1194        login: &EncryptedLogin,
1195        server_modified: &ServerTimestamp,
1196        is_overridden: bool,
1197    ) -> Result<()> {
1198        let sql = "
1199            INSERT OR IGNORE INTO loginsM (
1200                is_overridden,
1201                server_modified,
1202
1203                httpRealm,
1204                formActionOrigin,
1205                usernameField,
1206                passwordField,
1207                secFields,
1208                origin,
1209
1210                timesUsed,
1211                timeLastUsed,
1212                timePasswordChanged,
1213                timeCreated,
1214
1215                timeLastBreachAlertDismissed,
1216
1217                guid
1218            ) VALUES (
1219                :is_overridden,
1220                :server_modified,
1221
1222                :http_realm,
1223                :form_action_origin,
1224                :username_field,
1225                :password_field,
1226                :sec_fields,
1227                :origin,
1228
1229                :times_used,
1230                :time_last_used,
1231                :time_password_changed,
1232                :time_created,
1233
1234                :time_last_breach_alert_dismissed,
1235
1236                :guid
1237            )";
1238        let mut stmt = db.prepare_cached(sql)?;
1239
1240        stmt.execute(named_params! {
1241            ":is_overridden": is_overridden,
1242            ":server_modified": server_modified.as_millis(),
1243            ":http_realm": login.fields.http_realm,
1244            ":form_action_origin": login.fields.form_action_origin,
1245            ":username_field": login.fields.username_field,
1246            ":password_field": login.fields.password_field,
1247            ":origin": login.fields.origin,
1248            ":sec_fields": login.sec_fields,
1249            ":times_used": login.meta.times_used,
1250            ":time_last_used": login.meta.time_last_used,
1251            ":time_password_changed": login.meta.time_password_changed,
1252            ":time_created": login.meta.time_created,
1253            ":time_last_breach_alert_dismissed": login.meta.time_last_breach_alert_dismissed,
1254            ":guid": login.guid_str(),
1255        })?;
1256        Ok(())
1257    }
1258
1259    pub fn get_local_guids(db: &LoginDb) -> Vec<String> {
1260        get_guids(db, "SELECT guid FROM loginsL")
1261    }
1262
1263    pub fn get_mirror_guids(db: &LoginDb) -> Vec<String> {
1264        get_guids(db, "SELECT guid FROM loginsM")
1265    }
1266
1267    fn get_guids(db: &LoginDb, sql: &str) -> Vec<String> {
1268        let mut stmt = db.prepare_cached(sql).unwrap();
1269        let mut res: Vec<String> = stmt
1270            .query_map([], |r| r.get(0))
1271            .unwrap()
1272            .map(|r| r.unwrap())
1273            .collect();
1274        res.sort();
1275        res
1276    }
1277
1278    pub fn get_server_modified(db: &LoginDb, guid: &str) -> i64 {
1279        db.conn_ext_query_one(&format!(
1280            "SELECT server_modified FROM loginsM WHERE guid='{}'",
1281            guid
1282        ))
1283        .unwrap()
1284    }
1285
1286    pub fn check_local_login(db: &LoginDb, guid: &str, password: &str, local_modified_gte: i64) {
1287        let row: (String, i64, bool) = db
1288            .query_row(
1289                "SELECT secFields, local_modified, is_deleted FROM loginsL WHERE guid=?",
1290                [guid],
1291                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1292            )
1293            .unwrap();
1294        let enc: SecureLoginFields = decrypt_struct(row.0);
1295        assert_eq!(enc.password, password);
1296        assert!(row.1 >= local_modified_gte);
1297        assert!(!row.2);
1298    }
1299
1300    pub fn check_mirror_login(
1301        db: &LoginDb,
1302        guid: &str,
1303        password: &str,
1304        server_modified: i64,
1305        is_overridden: bool,
1306    ) {
1307        let row: (String, i64, bool) = db
1308            .query_row(
1309                "SELECT secFields, server_modified, is_overridden FROM loginsM WHERE guid=?",
1310                [guid],
1311                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1312            )
1313            .unwrap();
1314        let enc: SecureLoginFields = decrypt_struct(row.0);
1315        assert_eq!(enc.password, password);
1316        assert_eq!(row.1, server_modified);
1317        assert_eq!(row.2, is_overridden);
1318    }
1319}
1320
1321#[cfg(not(feature = "keydb"))]
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325    use crate::db::test_utils::{get_local_guids, get_mirror_guids};
1326    use crate::encryption::test_utils::TEST_ENCDEC;
1327    use crate::sync::merge::LocalLogin;
1328    use nss_as::ensure_initialized;
1329    use std::{thread, time};
1330
1331    #[test]
1332    fn test_username_dupe_semantics() {
1333        ensure_initialized();
1334        let mut login = LoginEntry {
1335            origin: "https://www.example.com".into(),
1336            http_realm: Some("https://www.example.com".into()),
1337            username: "test".into(),
1338            password: "sekret".into(),
1339            ..LoginEntry::default()
1340        };
1341
1342        let db = LoginDb::open_in_memory();
1343        db.add(login.clone(), &*TEST_ENCDEC)
1344            .expect("should be able to add first login");
1345
1346        // We will reject new logins with the same username value...
1347        let exp_err = "Invalid login: Login already exists";
1348        assert_eq!(
1349            db.add(login.clone(), &*TEST_ENCDEC)
1350                .unwrap_err()
1351                .to_string(),
1352            exp_err
1353        );
1354
1355        // Add one with an empty username - not a dupe.
1356        login.username = "".to_string();
1357        db.add(login.clone(), &*TEST_ENCDEC)
1358            .expect("empty login isn't a dupe");
1359
1360        assert_eq!(
1361            db.add(login, &*TEST_ENCDEC).unwrap_err().to_string(),
1362            exp_err
1363        );
1364
1365        // one with a username, 1 without.
1366        assert_eq!(db.get_all().unwrap().len(), 2);
1367    }
1368
1369    #[test]
1370    fn test_add_many() {
1371        ensure_initialized();
1372
1373        let login_a = LoginEntry {
1374            origin: "https://a.example.com".into(),
1375            http_realm: Some("https://www.example.com".into()),
1376            username: "test".into(),
1377            password: "sekret".into(),
1378            ..LoginEntry::default()
1379        };
1380
1381        let login_b = LoginEntry {
1382            origin: "https://b.example.com".into(),
1383            http_realm: Some("https://www.example.com".into()),
1384            username: "test".into(),
1385            password: "sekret".into(),
1386            ..LoginEntry::default()
1387        };
1388
1389        let db = LoginDb::open_in_memory();
1390        let added = db
1391            .add_many(vec![login_a.clone(), login_b.clone()], &*TEST_ENCDEC)
1392            .expect("should be able to add logins");
1393
1394        let [added_a, added_b] = added.as_slice() else {
1395            panic!("there should really be 2")
1396        };
1397
1398        let fetched_a = db
1399            .get_by_id(&added_a.as_ref().unwrap().meta.id)
1400            .expect("should work")
1401            .expect("should get a record");
1402
1403        assert_eq!(fetched_a.fields.origin, login_a.origin);
1404
1405        let fetched_b = db
1406            .get_by_id(&added_b.as_ref().unwrap().meta.id)
1407            .expect("should work")
1408            .expect("should get a record");
1409
1410        assert_eq!(fetched_b.fields.origin, login_b.origin);
1411
1412        assert_eq!(db.count_all().unwrap(), 2);
1413    }
1414
1415    #[test]
1416    fn test_count_by_origin() {
1417        ensure_initialized();
1418
1419        let origin_a = "https://a.example.com";
1420        let login_a = LoginEntry {
1421            origin: origin_a.into(),
1422            http_realm: Some("https://www.example.com".into()),
1423            username: "test".into(),
1424            password: "sekret".into(),
1425            ..LoginEntry::default()
1426        };
1427
1428        let login_b = LoginEntry {
1429            origin: "https://b.example.com".into(),
1430            http_realm: Some("https://www.example.com".into()),
1431            username: "test".into(),
1432            password: "sekret".into(),
1433            ..LoginEntry::default()
1434        };
1435
1436        let origin_umlaut = "https://bücher.example.com";
1437        let login_umlaut = LoginEntry {
1438            origin: origin_umlaut.into(),
1439            http_realm: Some("https://www.example.com".into()),
1440            username: "test".into(),
1441            password: "sekret".into(),
1442            ..LoginEntry::default()
1443        };
1444
1445        let db = LoginDb::open_in_memory();
1446        db.add_many(
1447            vec![login_a.clone(), login_b.clone(), login_umlaut.clone()],
1448            &*TEST_ENCDEC,
1449        )
1450        .expect("should be able to add logins");
1451
1452        assert_eq!(db.count_by_origin(origin_a).unwrap(), 1);
1453        assert_eq!(db.count_by_origin(origin_umlaut).unwrap(), 1);
1454    }
1455
1456    #[test]
1457    fn test_count_by_form_action_origin() {
1458        ensure_initialized();
1459
1460        let origin_a = "https://a.example.com";
1461        let login_a = LoginEntry {
1462            origin: origin_a.into(),
1463            form_action_origin: Some(origin_a.into()),
1464            http_realm: Some("https://www.example.com".into()),
1465            username: "test".into(),
1466            password: "sekret".into(),
1467            ..LoginEntry::default()
1468        };
1469
1470        let login_b = LoginEntry {
1471            origin: "https://b.example.com".into(),
1472            form_action_origin: Some("https://b.example.com".into()),
1473            http_realm: Some("https://www.example.com".into()),
1474            username: "test".into(),
1475            password: "sekret".into(),
1476            ..LoginEntry::default()
1477        };
1478
1479        let origin_umlaut = "https://bücher.example.com";
1480        let login_umlaut = LoginEntry {
1481            origin: origin_umlaut.into(),
1482            form_action_origin: Some(origin_umlaut.into()),
1483            http_realm: Some("https://www.example.com".into()),
1484            username: "test".into(),
1485            password: "sekret".into(),
1486            ..LoginEntry::default()
1487        };
1488
1489        let db = LoginDb::open_in_memory();
1490        db.add_many(
1491            vec![login_a.clone(), login_b.clone(), login_umlaut.clone()],
1492            &*TEST_ENCDEC,
1493        )
1494        .expect("should be able to add logins");
1495
1496        assert_eq!(db.count_by_form_action_origin(origin_a).unwrap(), 1);
1497        assert_eq!(db.count_by_form_action_origin(origin_umlaut).unwrap(), 1);
1498    }
1499
1500    #[test]
1501    #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1502    fn test_count_by_invalid_form_action_origin() {
1503        ensure_initialized();
1504
1505        let login = LoginEntry {
1506            origin: "https://example.com".into(),
1507            form_action_origin: Some("email".into()),
1508            username: "test".into(),
1509            password: "sekret".into(),
1510            ..LoginEntry::default()
1511        };
1512
1513        let db = LoginDb::open_in_memory();
1514        db.add(login, &*TEST_ENCDEC)
1515            .expect("should be able to add login with invalid form_action_origin");
1516        assert_eq!(db.count_by_form_action_origin("email").unwrap(), 1);
1517    }
1518
1519    #[test]
1520    fn test_add_many_with_failed_constraint() {
1521        ensure_initialized();
1522
1523        let login_a = LoginEntry {
1524            origin: "https://example.com".into(),
1525            http_realm: Some("https://www.example.com".into()),
1526            username: "test".into(),
1527            password: "sekret".into(),
1528            ..LoginEntry::default()
1529        };
1530
1531        let login_b = LoginEntry {
1532            // same origin will result in duplicate error
1533            origin: "https://example.com".into(),
1534            http_realm: Some("https://www.example.com".into()),
1535            username: "test".into(),
1536            password: "sekret".into(),
1537            ..LoginEntry::default()
1538        };
1539
1540        let db = LoginDb::open_in_memory();
1541        let added = db
1542            .add_many(vec![login_a.clone(), login_b.clone()], &*TEST_ENCDEC)
1543            .expect("should be able to add logins");
1544
1545        let [added_a, added_b] = added.as_slice() else {
1546            panic!("there should really be 2")
1547        };
1548
1549        // first entry has been saved successfully
1550        let fetched_a = db
1551            .get_by_id(&added_a.as_ref().unwrap().meta.id)
1552            .expect("should work")
1553            .expect("should get a record");
1554
1555        assert_eq!(fetched_a.fields.origin, login_a.origin);
1556
1557        // second entry failed
1558        assert!(!added_b.is_ok());
1559    }
1560
1561    #[test]
1562    fn test_add_with_meta() {
1563        ensure_initialized();
1564
1565        let guid = Guid::random();
1566        let now_ms = util::system_time_ms_i64(SystemTime::now());
1567        let login = LoginEntry {
1568            origin: "https://www.example.com".into(),
1569            http_realm: Some("https://www.example.com".into()),
1570            username: "test".into(),
1571            password: "sekret".into(),
1572            ..LoginEntry::default()
1573        };
1574        let meta = LoginMeta {
1575            id: guid.to_string(),
1576            time_created: now_ms,
1577            time_password_changed: now_ms + 100,
1578            time_last_used: now_ms + 10,
1579            times_used: 42,
1580            time_last_breach_alert_dismissed: None,
1581        };
1582
1583        let db = LoginDb::open_in_memory();
1584        let entry_with_meta = LoginEntryWithMeta {
1585            entry: login.clone(),
1586            meta: meta.clone(),
1587        };
1588
1589        db.add_with_meta(entry_with_meta, &*TEST_ENCDEC)
1590            .expect("should be able to add login with record");
1591
1592        let fetched = db
1593            .get_by_id(&guid)
1594            .expect("should work")
1595            .expect("should get a record");
1596
1597        assert_eq!(fetched.meta, meta);
1598    }
1599
1600    #[test]
1601    fn test_record_potentially_vulnerable_passwords() {
1602        ensure_initialized();
1603        let db = LoginDb::open_in_memory();
1604
1605        // Initially breachesL should be empty
1606        let count: i64 = db
1607            .db
1608            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1609            .unwrap();
1610        assert_eq!(count, 0);
1611
1612        // Record some passwords
1613        db.record_potentially_vulnerable_passwords(
1614            vec!["password1".into(), "password2".into(), "password3".into()],
1615            &*TEST_ENCDEC,
1616        )
1617        .unwrap();
1618
1619        // Verify they were inserted
1620        let count: i64 = db
1621            .db
1622            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1623            .unwrap();
1624        assert_eq!(count, 3);
1625
1626        // Try to insert duplicates - should be filtered out
1627        db.record_potentially_vulnerable_passwords(
1628            vec!["password1".into(), "password4".into()],
1629            &*TEST_ENCDEC,
1630        )
1631        .unwrap();
1632
1633        // Only password4 should have been added
1634        let count: i64 = db
1635            .db
1636            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1637            .unwrap();
1638        assert_eq!(count, 4);
1639
1640        // Try to insert only duplicates - should be a no-op
1641        db.record_potentially_vulnerable_passwords(
1642            vec!["password1".into(), "password2".into()],
1643            &*TEST_ENCDEC,
1644        )
1645        .unwrap();
1646
1647        let count: i64 = db
1648            .db
1649            .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
1650            .unwrap();
1651        assert_eq!(count, 4);
1652    }
1653
1654    #[test]
1655    fn test_add_with_meta_deleted() {
1656        ensure_initialized();
1657
1658        let guid = Guid::random();
1659        let now_ms = util::system_time_ms_i64(SystemTime::now());
1660        let login = LoginEntry {
1661            origin: "https://www.example.com".into(),
1662            http_realm: Some("https://www.example.com".into()),
1663            username: "test".into(),
1664            password: "sekret".into(),
1665            ..LoginEntry::default()
1666        };
1667        let meta = LoginMeta {
1668            id: guid.to_string(),
1669            time_created: now_ms,
1670            time_password_changed: now_ms + 100,
1671            time_last_used: now_ms + 10,
1672            times_used: 42,
1673            time_last_breach_alert_dismissed: None,
1674        };
1675
1676        let db = LoginDb::open_in_memory();
1677        let entry_with_meta = LoginEntryWithMeta {
1678            entry: login.clone(),
1679            meta: meta.clone(),
1680        };
1681
1682        db.add_with_meta(entry_with_meta, &*TEST_ENCDEC)
1683            .expect("should be able to add login with record");
1684
1685        db.delete(&guid).expect("should be able to delete login");
1686
1687        let entry_with_meta2 = LoginEntryWithMeta {
1688            entry: login.clone(),
1689            meta: meta.clone(),
1690        };
1691
1692        db.add_with_meta(entry_with_meta2, &*TEST_ENCDEC)
1693            .expect("should be able to re-add login with record");
1694
1695        let fetched = db
1696            .get_by_id(&guid)
1697            .expect("should work")
1698            .expect("should get a record");
1699
1700        assert_eq!(fetched.meta, meta);
1701    }
1702
1703    #[test]
1704    fn test_unicode_submit() {
1705        ensure_initialized();
1706        let db = LoginDb::open_in_memory();
1707        let added = db
1708            .add(
1709                LoginEntry {
1710                    form_action_origin: Some("http://😍.com".into()),
1711                    origin: "http://😍.com".into(),
1712                    http_realm: None,
1713                    username_field: "😍".into(),
1714                    password_field: "😍".into(),
1715                    username: "😍".into(),
1716                    password: "😍".into(),
1717                },
1718                &*TEST_ENCDEC,
1719            )
1720            .unwrap();
1721        let fetched = db
1722            .get_by_id(&added.meta.id)
1723            .expect("should work")
1724            .expect("should get a record");
1725        assert_eq!(added, fetched);
1726        assert_eq!(fetched.fields.origin, "http://xn--r28h.com");
1727        assert_eq!(
1728            fetched.fields.form_action_origin,
1729            Some("http://xn--r28h.com".to_string())
1730        );
1731        assert_eq!(fetched.fields.username_field, "😍");
1732        assert_eq!(fetched.fields.password_field, "😍");
1733        let sec_fields = fetched.decrypt_fields(&*TEST_ENCDEC).unwrap();
1734        assert_eq!(sec_fields.username, "😍");
1735        assert_eq!(sec_fields.password, "😍");
1736    }
1737
1738    #[test]
1739    fn test_unicode_realm() {
1740        ensure_initialized();
1741        let db = LoginDb::open_in_memory();
1742        let added = db
1743            .add(
1744                LoginEntry {
1745                    form_action_origin: None,
1746                    origin: "http://😍.com".into(),
1747                    http_realm: Some("😍😍".into()),
1748                    username: "😍".into(),
1749                    password: "😍".into(),
1750                    ..Default::default()
1751                },
1752                &*TEST_ENCDEC,
1753            )
1754            .unwrap();
1755        let fetched = db
1756            .get_by_id(&added.meta.id)
1757            .expect("should work")
1758            .expect("should get a record");
1759        assert_eq!(added, fetched);
1760        assert_eq!(fetched.fields.origin, "http://xn--r28h.com");
1761        assert_eq!(fetched.fields.http_realm.unwrap(), "😍😍");
1762    }
1763
1764    fn check_matches(db: &LoginDb, query: &str, expected: &[&str]) {
1765        let mut results = db
1766            .get_by_base_domain(query)
1767            .unwrap()
1768            .into_iter()
1769            .map(|l| l.fields.origin)
1770            .collect::<Vec<String>>();
1771        results.sort_unstable();
1772        let mut sorted = expected.to_owned();
1773        sorted.sort_unstable();
1774        assert_eq!(sorted, results);
1775    }
1776
1777    fn check_good_bad(
1778        good: Vec<&str>,
1779        bad: Vec<&str>,
1780        good_queries: Vec<&str>,
1781        zero_queries: Vec<&str>,
1782    ) {
1783        let db = LoginDb::open_in_memory();
1784        for h in good.iter().chain(bad.iter()) {
1785            db.add(
1786                LoginEntry {
1787                    origin: (*h).into(),
1788                    http_realm: Some((*h).into()),
1789                    password: "test".into(),
1790                    ..Default::default()
1791                },
1792                &*TEST_ENCDEC,
1793            )
1794            .unwrap();
1795        }
1796        for query in good_queries {
1797            check_matches(&db, query, &good);
1798        }
1799        for query in zero_queries {
1800            check_matches(&db, query, &[]);
1801        }
1802    }
1803
1804    #[test]
1805    fn test_get_by_base_domain_invalid() {
1806        ensure_initialized();
1807        check_good_bad(
1808            vec!["https://example.com"],
1809            vec![],
1810            vec![],
1811            vec!["invalid query"],
1812        );
1813    }
1814
1815    #[test]
1816    fn test_get_by_base_domain() {
1817        ensure_initialized();
1818        check_good_bad(
1819            vec![
1820                "https://example.com",
1821                "https://www.example.com",
1822                "http://www.example.com",
1823                "http://www.example.com:8080",
1824                "http://sub.example.com:8080",
1825                "https://sub.example.com:8080",
1826                "https://sub.sub.example.com",
1827                "ftp://sub.example.com",
1828            ],
1829            vec![
1830                "https://badexample.com",
1831                "https://example.co",
1832                "https://example.com.au",
1833            ],
1834            vec!["example.com"],
1835            vec!["foo.com"],
1836        );
1837    }
1838
1839    #[test]
1840    fn test_get_by_base_domain_punicode() {
1841        ensure_initialized();
1842        // punycode! This is likely to need adjusting once we normalize
1843        // on insert.
1844        check_good_bad(
1845            vec![
1846                "http://xn--r28h.com", // punycoded version of "http://😍.com"
1847            ],
1848            vec!["http://💖.com"],
1849            vec!["😍.com", "xn--r28h.com"],
1850            vec![],
1851        );
1852    }
1853
1854    #[test]
1855    fn test_get_by_base_domain_ipv4() {
1856        ensure_initialized();
1857        check_good_bad(
1858            vec!["http://127.0.0.1", "https://127.0.0.1:8000"],
1859            vec!["https://127.0.0.0", "https://example.com"],
1860            vec!["127.0.0.1"],
1861            vec!["127.0.0.2"],
1862        );
1863    }
1864
1865    #[test]
1866    fn test_get_by_base_domain_ipv6() {
1867        ensure_initialized();
1868        check_good_bad(
1869            vec!["http://[::1]", "https://[::1]:8000"],
1870            vec!["https://[0:0:0:0:0:0:1:1]", "https://example.com"],
1871            vec!["[::1]", "[0:0:0:0:0:0:0:1]"],
1872            vec!["[0:0:0:0:0:0:1:2]"],
1873        );
1874    }
1875
1876    #[test]
1877    fn test_add() {
1878        ensure_initialized();
1879        let db = LoginDb::open_in_memory();
1880        let to_add = LoginEntry {
1881            origin: "https://www.example.com".into(),
1882            http_realm: Some("https://www.example.com".into()),
1883            username: "test_user".into(),
1884            password: "test_password".into(),
1885            ..Default::default()
1886        };
1887        let login = db.add(to_add, &*TEST_ENCDEC).unwrap();
1888        let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap();
1889
1890        assert_eq!(login.fields.origin, login2.fields.origin);
1891        assert_eq!(login.fields.http_realm, login2.fields.http_realm);
1892        assert_eq!(login.sec_fields, login2.sec_fields);
1893    }
1894
1895    #[test]
1896    fn test_update() {
1897        ensure_initialized();
1898        let db = LoginDb::open_in_memory();
1899        let login = db
1900            .add(
1901                LoginEntry {
1902                    origin: "https://www.example.com".into(),
1903                    http_realm: Some("https://www.example.com".into()),
1904                    username: "user1".into(),
1905                    password: "password1".into(),
1906                    ..Default::default()
1907                },
1908                &*TEST_ENCDEC,
1909            )
1910            .unwrap();
1911        db.update(
1912            &login.meta.id,
1913            LoginEntry {
1914                origin: "https://www.example2.com".into(),
1915                http_realm: Some("https://www.example2.com".into()),
1916                username: "user2".into(),
1917                password: "password2".into(),
1918                ..Default::default() // TODO: check and fix if needed
1919            },
1920            &*TEST_ENCDEC,
1921        )
1922        .unwrap();
1923
1924        let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap();
1925
1926        assert_eq!(login2.fields.origin, "https://www.example2.com");
1927        assert_eq!(
1928            login2.fields.http_realm,
1929            Some("https://www.example2.com".into())
1930        );
1931        let sec_fields = login2.decrypt_fields(&*TEST_ENCDEC).unwrap();
1932        assert_eq!(sec_fields.username, "user2");
1933        assert_eq!(sec_fields.password, "password2");
1934    }
1935
1936    #[test]
1937    fn test_touch() {
1938        ensure_initialized();
1939        let db = LoginDb::open_in_memory();
1940        let login = db
1941            .add(
1942                LoginEntry {
1943                    origin: "https://www.example.com".into(),
1944                    http_realm: Some("https://www.example.com".into()),
1945                    username: "user1".into(),
1946                    password: "password1".into(),
1947                    ..Default::default()
1948                },
1949                &*TEST_ENCDEC,
1950            )
1951            .unwrap();
1952        // Simulate touch happening at another "time"
1953        thread::sleep(time::Duration::from_millis(50));
1954        db.touch(&login.meta.id).unwrap();
1955        let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap();
1956        assert!(login2.meta.time_last_used > login.meta.time_last_used);
1957        assert_eq!(login2.meta.times_used, login.meta.times_used + 1);
1958    }
1959
1960    #[test]
1961    fn test_update_does_not_count_as_use() {
1962        // A plain update is not a password use.
1963        // It must not bump `times_used` or `time_last_used`. Only `touch()` is
1964        // allowed to do that.
1965        ensure_initialized();
1966        let db = LoginDb::open_in_memory();
1967        let login = db
1968            .add(
1969                LoginEntry {
1970                    origin: "https://www.example.com".into(),
1971                    http_realm: Some("https://www.example.com".into()),
1972                    username: "user1".into(),
1973                    password: "password1".into(),
1974                    ..Default::default()
1975                },
1976                &*TEST_ENCDEC,
1977            )
1978            .unwrap();
1979        // Make sure the "now" an update would use differs from the add time.
1980        thread::sleep(time::Duration::from_millis(50));
1981        db.update(
1982            &login.meta.id,
1983            LoginEntry {
1984                origin: "https://www.example.com".into(),
1985                http_realm: Some("https://www.example.com".into()),
1986                username: "user1".into(),
1987                password: "password2".into(),
1988                ..Default::default()
1989            },
1990            &*TEST_ENCDEC,
1991        )
1992        .unwrap();
1993        let updated = db.get_by_id(&login.meta.id).unwrap().unwrap();
1994        // An edit is not a use: times_used must stay unchanged.
1995        assert_eq!(updated.meta.times_used, login.meta.times_used);
1996        // An edit is not a use: time_last_used must stay unchanged.
1997        assert_eq!(updated.meta.time_last_used, login.meta.time_last_used);
1998    }
1999
2000    #[test]
2001    fn test_breach_alert_dismissal() {
2002        ensure_initialized();
2003        let db = LoginDb::open_in_memory();
2004        let login = db
2005            .add(
2006                LoginEntry {
2007                    origin: "https://www.example.com".into(),
2008                    http_realm: Some("https://www.example.com".into()),
2009                    username: "user1".into(),
2010                    password: "password1".into(),
2011                    ..Default::default()
2012                },
2013                &*TEST_ENCDEC,
2014            )
2015            .unwrap();
2016        // initial state
2017        assert!(login.meta.time_last_breach_alert_dismissed.is_none());
2018
2019        // dismiss
2020        db.record_breach_alert_dismissal(&login.meta.id).unwrap();
2021        let login1 = db.get_by_id(&login.meta.id).unwrap().unwrap();
2022        assert!(login1.meta.time_last_breach_alert_dismissed.is_some());
2023    }
2024
2025    #[test]
2026    fn test_breach_alert_dismissal_with_specific_timestamp() {
2027        ensure_initialized();
2028        let db = LoginDb::open_in_memory();
2029        let login = db
2030            .add(
2031                LoginEntry {
2032                    origin: "https://www.example.com".into(),
2033                    http_realm: Some("https://www.example.com".into()),
2034                    username: "user1".into(),
2035                    password: "password1".into(),
2036                    ..Default::default()
2037                },
2038                &*TEST_ENCDEC,
2039            )
2040            .unwrap();
2041
2042        let dismiss_time = login.meta.time_password_changed + 1000;
2043        db.record_breach_alert_dismissal_time(&login.meta.id, dismiss_time)
2044            .unwrap();
2045
2046        let retrieved = db
2047            .get_by_id(&login.meta.id)
2048            .unwrap()
2049            .unwrap()
2050            .decrypt(&*TEST_ENCDEC)
2051            .unwrap();
2052        assert_eq!(
2053            retrieved.time_last_breach_alert_dismissed,
2054            Some(dismiss_time)
2055        );
2056    }
2057
2058    #[test]
2059    fn test_delete() {
2060        ensure_initialized();
2061        let db = LoginDb::open_in_memory();
2062        let login = db
2063            .add(
2064                LoginEntry {
2065                    origin: "https://www.example.com".into(),
2066                    http_realm: Some("https://www.example.com".into()),
2067                    username: "test_user".into(),
2068                    password: "test_password".into(),
2069                    ..Default::default()
2070                },
2071                &*TEST_ENCDEC,
2072            )
2073            .unwrap();
2074
2075        assert!(db.delete(login.guid_str()).unwrap());
2076
2077        let local_login = db
2078            .query_row(
2079                "SELECT * FROM loginsL WHERE guid = :guid",
2080                named_params! { ":guid": login.guid_str() },
2081                |row| Ok(LocalLogin::test_raw_from_row(row).unwrap()),
2082            )
2083            .unwrap();
2084        assert_eq!(local_login.fields.http_realm, None);
2085        assert_eq!(local_login.fields.form_action_origin, None);
2086
2087        assert!(!db.exists(login.guid_str()).unwrap());
2088    }
2089
2090    #[test]
2091    fn test_delete_many() {
2092        ensure_initialized();
2093        let db = LoginDb::open_in_memory();
2094
2095        let login_a = db
2096            .add(
2097                LoginEntry {
2098                    origin: "https://a.example.com".into(),
2099                    http_realm: Some("https://www.example.com".into()),
2100                    username: "test_user".into(),
2101                    password: "test_password".into(),
2102                    ..Default::default()
2103                },
2104                &*TEST_ENCDEC,
2105            )
2106            .unwrap();
2107
2108        let login_b = db
2109            .add(
2110                LoginEntry {
2111                    origin: "https://b.example.com".into(),
2112                    http_realm: Some("https://www.example.com".into()),
2113                    username: "test_user".into(),
2114                    password: "test_password".into(),
2115                    ..Default::default()
2116                },
2117                &*TEST_ENCDEC,
2118            )
2119            .unwrap();
2120
2121        let result = db
2122            .delete_many(vec![login_a.guid_str(), login_b.guid_str()])
2123            .unwrap();
2124        assert!(result[0]);
2125        assert!(result[1]);
2126        assert!(!db.exists(login_a.guid_str()).unwrap());
2127        assert!(!db.exists(login_b.guid_str()).unwrap());
2128    }
2129
2130    #[test]
2131    fn test_subsequent_delete_many() {
2132        ensure_initialized();
2133        let db = LoginDb::open_in_memory();
2134
2135        let login = db
2136            .add(
2137                LoginEntry {
2138                    origin: "https://a.example.com".into(),
2139                    http_realm: Some("https://www.example.com".into()),
2140                    username: "test_user".into(),
2141                    password: "test_password".into(),
2142                    ..Default::default()
2143                },
2144                &*TEST_ENCDEC,
2145            )
2146            .unwrap();
2147
2148        let result = db.delete_many(vec![login.guid_str()]).unwrap();
2149        assert!(result[0]);
2150        assert!(!db.exists(login.guid_str()).unwrap());
2151
2152        let result = db.delete_many(vec![login.guid_str()]).unwrap();
2153        assert!(!result[0]);
2154    }
2155
2156    #[test]
2157    fn test_delete_many_with_non_existent_id() {
2158        ensure_initialized();
2159        let db = LoginDb::open_in_memory();
2160
2161        let result = db.delete_many(vec![&Guid::random()]).unwrap();
2162        assert!(!result[0]);
2163    }
2164
2165    #[test]
2166    fn test_delete_local_for_remote_replacement() {
2167        ensure_initialized();
2168        let db = LoginDb::open_in_memory();
2169        let login = db
2170            .add(
2171                LoginEntry {
2172                    origin: "https://www.example.com".into(),
2173                    http_realm: Some("https://www.example.com".into()),
2174                    username: "test_user".into(),
2175                    password: "test_password".into(),
2176                    ..Default::default()
2177                },
2178                &*TEST_ENCDEC,
2179            )
2180            .unwrap();
2181
2182        let result = db
2183            .delete_local_records_for_remote_replacement(vec![login.guid_str()])
2184            .unwrap();
2185
2186        let local_guids = get_local_guids(&db);
2187        assert_eq!(local_guids.len(), 0);
2188
2189        let mirror_guids = get_mirror_guids(&db);
2190        assert_eq!(mirror_guids.len(), 0);
2191
2192        assert_eq!(result.local_deleted, 1);
2193    }
2194
2195    mod test_find_login_to_update {
2196        use super::*;
2197
2198        fn make_entry(username: &str, password: &str) -> LoginEntry {
2199            LoginEntry {
2200                origin: "https://www.example.com".into(),
2201                http_realm: Some("the website".into()),
2202                username: username.into(),
2203                password: password.into(),
2204                ..Default::default()
2205            }
2206        }
2207
2208        fn make_saved_login(db: &LoginDb, username: &str, password: &str) -> Login {
2209            db.add(make_entry(username, password), &*TEST_ENCDEC)
2210                .unwrap()
2211                .decrypt(&*TEST_ENCDEC)
2212                .unwrap()
2213        }
2214
2215        #[test]
2216        fn test_match() {
2217            ensure_initialized();
2218            let db = LoginDb::open_in_memory();
2219            let login = make_saved_login(&db, "user", "pass");
2220            assert_eq!(
2221                Some(login),
2222                db.find_login_to_update(make_entry("user", "pass"), &*TEST_ENCDEC)
2223                    .unwrap(),
2224            );
2225        }
2226
2227        #[test]
2228        fn test_non_matches() {
2229            ensure_initialized();
2230            let db = LoginDb::open_in_memory();
2231            // Non-match because the username is different
2232            make_saved_login(&db, "other-user", "pass");
2233            // Non-match because the http_realm is different
2234            db.add(
2235                LoginEntry {
2236                    origin: "https://www.example.com".into(),
2237                    http_realm: Some("the other website".into()),
2238                    username: "user".into(),
2239                    password: "pass".into(),
2240                    ..Default::default()
2241                },
2242                &*TEST_ENCDEC,
2243            )
2244            .unwrap();
2245            // Non-match because it uses form_action_origin instead of http_realm
2246            db.add(
2247                LoginEntry {
2248                    origin: "https://www.example.com".into(),
2249                    form_action_origin: Some("https://www.example.com/".into()),
2250                    username: "user".into(),
2251                    password: "pass".into(),
2252                    ..Default::default()
2253                },
2254                &*TEST_ENCDEC,
2255            )
2256            .unwrap();
2257            assert_eq!(
2258                None,
2259                db.find_login_to_update(make_entry("user", "pass"), &*TEST_ENCDEC)
2260                    .unwrap(),
2261            );
2262        }
2263
2264        #[test]
2265        fn test_match_blank_password() {
2266            ensure_initialized();
2267            let db = LoginDb::open_in_memory();
2268            let login = make_saved_login(&db, "", "pass");
2269            assert_eq!(
2270                Some(login),
2271                db.find_login_to_update(make_entry("user", "pass"), &*TEST_ENCDEC)
2272                    .unwrap(),
2273            );
2274        }
2275
2276        #[test]
2277        fn test_username_match_takes_precedence_over_blank_username() {
2278            ensure_initialized();
2279            let db = LoginDb::open_in_memory();
2280            make_saved_login(&db, "", "pass");
2281            let username_match = make_saved_login(&db, "user", "pass");
2282            assert_eq!(
2283                Some(username_match),
2284                db.find_login_to_update(make_entry("user", "pass"), &*TEST_ENCDEC)
2285                    .unwrap(),
2286            );
2287        }
2288
2289        #[test]
2290        fn test_invalid_login() {
2291            ensure_initialized();
2292            let db = LoginDb::open_in_memory();
2293            assert!(db
2294                .find_login_to_update(
2295                    LoginEntry {
2296                        http_realm: None,
2297                        form_action_origin: None,
2298                        ..LoginEntry::default()
2299                    },
2300                    &*TEST_ENCDEC
2301                )
2302                .is_err());
2303        }
2304
2305        #[test]
2306        fn test_update_with_duplicate_login() {
2307            ensure_initialized();
2308            // If we have duplicate logins in the database, it should be possible to update them
2309            // without triggering a DuplicateLogin error
2310            let db = LoginDb::open_in_memory();
2311            let login = make_saved_login(&db, "user", "pass");
2312            let mut dupe = login.clone().encrypt(&*TEST_ENCDEC).unwrap();
2313            dupe.meta.id = "different-guid".to_string();
2314            db.insert_new_login(&dupe).unwrap();
2315
2316            let mut entry = login.entry();
2317            entry.password = "pass2".to_string();
2318            db.update(&login.id, entry, &*TEST_ENCDEC).unwrap();
2319
2320            let mut entry = login.entry();
2321            entry.password = "pass3".to_string();
2322            db.add_or_update(entry, &*TEST_ENCDEC).unwrap();
2323        }
2324
2325        #[test]
2326        fn test_password_reuse_detection() {
2327            ensure_initialized();
2328            let db = LoginDb::open_in_memory();
2329
2330            // Create two logins with the same password
2331            let login1 = db
2332                .add(
2333                    LoginEntry {
2334                        origin: "https://site1.com".into(),
2335                        http_realm: Some("realm".into()),
2336                        username: "user1".into(),
2337                        password: "shared_password".into(),
2338                        ..Default::default()
2339                    },
2340                    &*TEST_ENCDEC,
2341                )
2342                .unwrap();
2343
2344            let login2 = db
2345                .add(
2346                    LoginEntry {
2347                        origin: "https://site2.com".into(),
2348                        http_realm: Some("realm".into()),
2349                        username: "user2".into(),
2350                        password: "shared_password".into(),
2351                        ..Default::default()
2352                    },
2353                    &*TEST_ENCDEC,
2354                )
2355                .unwrap();
2356
2357            // Initially, neither login is vulnerable
2358            assert!(!db
2359                .is_potentially_vulnerable_password(&login1.meta.id, &*TEST_ENCDEC)
2360                .unwrap());
2361            assert!(!db
2362                .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC)
2363                .unwrap());
2364            // And checking both logins should return empty (none are vulnerable yet)
2365            let vulnerable = db
2366                .are_potentially_vulnerable_passwords(
2367                    &[&login1.meta.id, &login2.meta.id],
2368                    &*TEST_ENCDEC,
2369                )
2370                .unwrap();
2371            assert_eq!(vulnerable.len(), 0);
2372
2373            // Record "shared_password" as a vulnerable password
2374            db.record_potentially_vulnerable_passwords(
2375                vec!["shared_password".into()],
2376                &*TEST_ENCDEC,
2377            )
2378            .unwrap();
2379
2380            // login2 should be recognized as vulnerable (same password as breached login1)
2381            assert!(db
2382                .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC)
2383                .unwrap());
2384            // Batch check: both logins should be vulnerable (they share the same password)
2385            let vulnerable = db
2386                .are_potentially_vulnerable_passwords(
2387                    &[&login1.meta.id, &login2.meta.id],
2388                    &*TEST_ENCDEC,
2389                )
2390                .unwrap();
2391            assert_eq!(vulnerable.len(), 2);
2392            assert!(vulnerable.contains(&login1.meta.id));
2393            assert!(vulnerable.contains(&login2.meta.id));
2394
2395            // Change password of login2 → should no longer be vulnerable
2396            db.update(
2397                &login2.meta.id,
2398                LoginEntry {
2399                    origin: "https://site2.com".into(),
2400                    http_realm: Some("realm".into()),
2401                    username: "user2".into(),
2402                    password: "different_password".into(),
2403                    ..Default::default()
2404                },
2405                &*TEST_ENCDEC,
2406            )
2407            .unwrap();
2408
2409            assert!(!db
2410                .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC)
2411                .unwrap());
2412        }
2413
2414        #[test]
2415        fn test_reset_all_breaches_clears_breach_table() {
2416            ensure_initialized();
2417            let db = LoginDb::open_in_memory();
2418
2419            let login = db
2420                .add(
2421                    LoginEntry {
2422                        origin: "https://example.com".into(),
2423                        http_realm: Some("realm".into()),
2424                        username: "user".into(),
2425                        password: "password123".into(),
2426                        ..Default::default()
2427                    },
2428                    &*TEST_ENCDEC,
2429                )
2430                .unwrap();
2431
2432            db.record_potentially_vulnerable_passwords(vec!["password123".into()], &*TEST_ENCDEC)
2433                .unwrap();
2434
2435            // Verify that breachesL has an entry
2436            let count: i64 = db
2437                .db
2438                .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
2439                .unwrap();
2440            assert_eq!(count, 1);
2441            // And verify via the API that this login is vulnerable
2442            let vulnerable = db
2443                .are_potentially_vulnerable_passwords(&[&login.meta.id], &*TEST_ENCDEC)
2444                .unwrap();
2445            assert_eq!(vulnerable.len(), 1);
2446            assert_eq!(vulnerable[0], login.meta.id);
2447
2448            // Reset all breaches
2449            db.reset_all_breaches().unwrap();
2450
2451            // After reset, breachesL should be empty
2452            let count: i64 = db
2453                .db
2454                .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0))
2455                .unwrap();
2456            assert_eq!(count, 0);
2457            // And verify via the API that no logins are vulnerable anymore
2458            let vulnerable = db
2459                .are_potentially_vulnerable_passwords(&[&login.meta.id], &*TEST_ENCDEC)
2460                .unwrap();
2461            assert_eq!(vulnerable.len(), 0);
2462        }
2463
2464        #[test]
2465        fn test_different_passwords_not_vulnerable() {
2466            ensure_initialized();
2467            let db = LoginDb::open_in_memory();
2468
2469            let login1 = db
2470                .add(
2471                    LoginEntry {
2472                        origin: "https://site1.com".into(),
2473                        http_realm: Some("realm".into()),
2474                        username: "user".into(),
2475                        password: "password_A".into(),
2476                        ..Default::default()
2477                    },
2478                    &*TEST_ENCDEC,
2479                )
2480                .unwrap();
2481
2482            let login2 = db
2483                .add(
2484                    LoginEntry {
2485                        origin: "https://site2.com".into(),
2486                        http_realm: Some("realm".into()),
2487                        username: "user".into(),
2488                        password: "password_B".into(),
2489                        ..Default::default()
2490                    },
2491                    &*TEST_ENCDEC,
2492                )
2493                .unwrap();
2494
2495            db.record_potentially_vulnerable_passwords(vec!["password_A".into()], &*TEST_ENCDEC)
2496                .unwrap();
2497
2498            // login2 has a different password → not vulnerable
2499            assert!(!db
2500                .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC)
2501                .unwrap());
2502            // Batch check: login1 should be vulnerable (its password is in breachesL)
2503            // login2 has a different password, so it's not vulnerable
2504            let vulnerable = db
2505                .are_potentially_vulnerable_passwords(
2506                    &[&login1.meta.id, &login2.meta.id],
2507                    &*TEST_ENCDEC,
2508                )
2509                .unwrap();
2510            assert_eq!(vulnerable.len(), 1);
2511            assert!(vulnerable.contains(&login1.meta.id));
2512        }
2513    }
2514}