logins/
db.rs

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