logins/
db.rs

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