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