logins/
db.rs

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