logins/sync/
engine.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
5use super::merge::{LocalLogin, MirrorLogin, SyncLoginData};
6use super::update_plan::UpdatePlan;
7use super::SyncStatus;
8use crate::db::CLONE_ENTIRE_MIRROR_SQL;
9use crate::encryption::EncryptorDecryptor;
10use crate::error::*;
11use crate::login::EncryptedLogin;
12use crate::schema;
13use crate::util;
14use crate::LoginDb;
15use crate::LoginStore;
16use interrupt_support::SqlInterruptScope;
17use rusqlite::named_params;
18use sql_support::ConnExt;
19use std::collections::HashSet;
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, UNIX_EPOCH};
22use sync15::bso::{IncomingBso, OutgoingBso, OutgoingEnvelope};
23use sync15::engine::{CollSyncIds, CollectionRequest, EngineSyncAssociation, SyncEngine};
24use sync15::{telemetry, ServerTimestamp};
25use sync_guid::Guid;
26
27// The Desktop FxA session-credentials pseudo-login. Firefox stores its account
28// credentials as a login under this origin; it must never be synced. This
29// mirrors the exclusion the JS `PasswordEngine` does via
30// `Utils.getSyncCredentialsHosts()`. Only relevant on Desktop (mobile never has
31// such a login), but it's harmless to filter everywhere.
32const FXA_CREDENTIALS_ORIGIN: &str = "chrome://FirefoxAccounts";
33
34// The sync engine.
35pub struct LoginsSyncEngine {
36    pub store: Arc<LoginStore>,
37    pub scope: SqlInterruptScope,
38    pub encdec: Arc<dyn EncryptorDecryptor>,
39    // `Mutex` (rather than `RefCell`) so the engine is `Sync`, which the
40    // Desktop `BridgedEngineAdaptor` requires. Only ever locked briefly.
41    pub staged: Mutex<Vec<IncomingBso>>,
42}
43
44impl LoginsSyncEngine {
45    pub fn new(store: Arc<LoginStore>) -> Result<Self> {
46        let db = store.lock_db()?;
47        let scope = db.begin_interrupt_scope()?;
48        let encdec = db.encdec.clone();
49        drop(db);
50        Ok(Self {
51            store,
52            encdec,
53            scope,
54            staged: Mutex::new(vec![]),
55        })
56    }
57
58    fn reconcile(
59        &self,
60        records: Vec<SyncLoginData>,
61        server_now: ServerTimestamp,
62        telem: &mut telemetry::EngineIncoming,
63    ) -> Result<UpdatePlan> {
64        let mut plan = UpdatePlan::default();
65
66        for mut record in records {
67            self.scope.err_if_interrupted()?;
68            debug!("Processing remote change {}", record.guid());
69            let upstream = if let Some(inbound) = record.inbound.take() {
70                inbound
71            } else {
72                debug!("Processing inbound deletion (always prefer)");
73                plan.plan_delete(record.guid.clone());
74                continue;
75            };
76            let upstream_time = record.inbound_ts;
77            match (record.mirror.take(), record.local.take()) {
78                (Some(mirror), Some(local)) => {
79                    debug!("  Conflict between remote and local, Resolving with 3WM");
80                    plan.plan_three_way_merge(
81                        local,
82                        mirror,
83                        upstream,
84                        upstream_time,
85                        server_now,
86                        self.encdec.as_ref(),
87                    )?;
88                    telem.reconciled(1);
89                }
90                (Some(_mirror), None) => {
91                    debug!("  Forwarding mirror to remote");
92                    plan.plan_mirror_update(upstream, upstream_time);
93                    telem.applied(1);
94                }
95                (None, Some(local)) => {
96                    debug!("  Conflicting record without shared parent,  Resolving with 2WM");
97                    plan.plan_two_way_merge(local, (upstream, upstream_time));
98                    telem.reconciled(1);
99                }
100                (None, None) => {
101                    if let Some(dupe) = self.find_dupe_login(&upstream.login)? {
102                        debug!(
103                            "  Incoming recordĀ {} was is a dupe of local record {}",
104                            upstream.guid(),
105                            dupe.guid()
106                        );
107                        let local_modified = UNIX_EPOCH
108                            + Duration::from_millis(dupe.meta.time_password_changed as u64);
109                        let local = LocalLogin::Alive {
110                            login: Box::new(dupe),
111                            local_modified,
112                        };
113                        plan.plan_two_way_merge(local, (upstream, upstream_time));
114                    } else {
115                        debug!("  No dupe found, inserting into mirror");
116                        plan.plan_mirror_insert(upstream, upstream_time, false);
117                    }
118                    telem.applied(1);
119                }
120            }
121        }
122        Ok(plan)
123    }
124
125    fn execute_plan(&self, plan: UpdatePlan) -> Result<()> {
126        // Because rusqlite want a mutable reference to create a transaction
127        // (as a way to save us from ourselves), we side-step that by creating
128        // it manually.
129        let db = self.store.lock_db()?;
130        let tx = db.unchecked_transaction()?;
131        plan.execute(&tx, &self.scope)?;
132        tx.commit()?;
133        Ok(())
134    }
135
136    // Fetch all the data for the provided IDs.
137    // TODO: Might be better taking a fn instead of returning all of it... But that func will likely
138    // want to insert stuff while we're doing this so ugh.
139    fn fetch_login_data(
140        &self,
141        records: Vec<IncomingBso>,
142        telem: &mut telemetry::EngineIncoming,
143    ) -> Result<Vec<SyncLoginData>> {
144        let mut sync_data = Vec::with_capacity(records.len());
145        {
146            let mut seen_ids: HashSet<Guid> = HashSet::with_capacity(records.len());
147            for incoming in records.into_iter() {
148                let id = incoming.envelope.id.clone();
149                match SyncLoginData::from_bso(incoming, self.encdec.as_ref()) {
150                    Ok(v) => sync_data.push(v),
151                    Err(e) => {
152                        match e {
153                            // This is a known error with Desktop logins (see #5233), just log it
154                            // rather than reporting to sentry
155                            Error::InvalidLogin(InvalidLogin::IllegalOrigin { reason: _ }) => {
156                                warn!("logins-deserialize-error: {e}");
157                            }
158                            // For all other errors, report them to Sentry
159                            _ => {
160                                report_error!(
161                                    "logins-deserialize-error",
162                                    "Failed to deserialize record {:?}: {e}",
163                                    id
164                                );
165                            }
166                        };
167                        // Ideally we'd track new_failed, but it's unclear how
168                        // much value it has.
169                        telem.failed(1);
170                    }
171                }
172                seen_ids.insert(id);
173            }
174        }
175        self.scope.err_if_interrupted()?;
176
177        sql_support::each_chunk(
178            &sync_data
179                .iter()
180                .map(|s| s.guid.as_str().to_string())
181                .collect::<Vec<String>>(),
182            |chunk, offset| -> Result<()> {
183                // pairs the bound parameter for the guid with an integer index.
184                let values_with_idx = sql_support::repeat_display(chunk.len(), ",", |i, f| {
185                    write!(f, "({},?)", i + offset)
186                });
187                let query = format!(
188                    "WITH to_fetch(guid_idx, fetch_guid) AS (VALUES {vals})
189                     SELECT
190                         {common_cols},
191                         is_overridden,
192                         server_modified,
193                         NULL as local_modified,
194                         NULL as is_deleted,
195                         NULL as sync_status,
196                         1 as is_mirror,
197                         to_fetch.guid_idx as guid_idx
198                     FROM loginsM
199                     JOIN to_fetch
200                         ON loginsM.guid = to_fetch.fetch_guid
201
202                     UNION ALL
203
204                     SELECT
205                         {common_cols},
206                         NULL as is_overridden,
207                         NULL as server_modified,
208                         local_modified,
209                         is_deleted,
210                         sync_status,
211                         0 as is_mirror,
212                         to_fetch.guid_idx as guid_idx
213                     FROM loginsL
214                     JOIN to_fetch
215                         ON loginsL.guid = to_fetch.fetch_guid",
216                    // give each VALUES item 2 entries, an index and the parameter.
217                    vals = values_with_idx,
218                    common_cols = schema::COMMON_COLS,
219                );
220
221                let db = &self.store.lock_db()?;
222                let mut stmt = db.prepare(&query)?;
223
224                let rows = stmt.query_and_then(rusqlite::params_from_iter(chunk), |row| {
225                    let guid_idx_i = row.get::<_, i64>("guid_idx")?;
226                    // Hitting this means our math is wrong...
227                    assert!(guid_idx_i >= 0);
228
229                    let guid_idx = guid_idx_i as usize;
230                    let is_mirror: bool = row.get("is_mirror")?;
231                    if is_mirror {
232                        sync_data[guid_idx].set_mirror(MirrorLogin::from_row(row)?)?;
233                    } else {
234                        sync_data[guid_idx].set_local(LocalLogin::from_row(row)?)?;
235                    }
236                    self.scope.err_if_interrupted()?;
237                    Ok(())
238                })?;
239                // `rows` is an Iterator<Item = Result<()>>, so we need to collect to handle the errors.
240                rows.collect::<Result<()>>()?;
241                Ok(())
242            },
243        )?;
244        Ok(sync_data)
245    }
246
247    fn fetch_outgoing(&self) -> Result<Vec<OutgoingBso>> {
248        // Taken from iOS. Arbitrarily large, so that clients that want to
249        // process deletions first can; for us it doesn't matter.
250        const TOMBSTONE_SORTINDEX: i32 = 5_000_000;
251        const DEFAULT_SORTINDEX: i32 = 1;
252        let db = self.store.lock_db()?;
253        let mut stmt = db.prepare_cached(&format!(
254            "SELECT L.*, M.enc_unknown_fields
255             FROM loginsL L LEFT JOIN loginsM M ON L.guid = M.guid
256             WHERE sync_status IS NOT {synced}
257               -- Never sync Desktop's FxA session-credentials pseudo-login.
258               AND L.origin IS NOT :fxa_origin",
259            synced = SyncStatus::Synced as u8
260        ))?;
261        let bsos = stmt.query_and_then(
262            named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN },
263            |row| {
264                self.scope.err_if_interrupted()?;
265                Ok(if row.get::<_, bool>("is_deleted")? {
266                    let envelope = OutgoingEnvelope {
267                        id: row.get::<_, String>("guid")?.into(),
268                        sortindex: Some(TOMBSTONE_SORTINDEX),
269                        ..Default::default()
270                    };
271                    OutgoingBso::new_tombstone(envelope)
272                } else {
273                    let unknown = row.get::<_, Option<String>>("enc_unknown_fields")?;
274                    let mut bso =
275                        EncryptedLogin::from_row(row)?.into_bso(self.encdec.as_ref(), unknown)?;
276                    bso.envelope.sortindex = Some(DEFAULT_SORTINDEX);
277                    bso
278                })
279            },
280        )?;
281        bsos.collect::<Result<_>>()
282    }
283
284    fn do_apply_incoming(
285        &self,
286        inbound: Vec<IncomingBso>,
287        timestamp: ServerTimestamp,
288        telem: &mut telemetry::Engine,
289    ) -> Result<Vec<OutgoingBso>> {
290        let mut incoming_telemetry = telemetry::EngineIncoming::new();
291        let data = self.fetch_login_data(inbound, &mut incoming_telemetry)?;
292        let plan = {
293            let result = self.reconcile(data, timestamp, &mut incoming_telemetry);
294            telem.incoming(incoming_telemetry);
295            result
296        }?;
297        self.execute_plan(plan)?;
298        self.fetch_outgoing()
299    }
300
301    // Note this receives the db to prevent a deadlock
302    pub fn set_last_sync(&self, db: &LoginDb, last_sync: ServerTimestamp) -> Result<()> {
303        debug!("Updating last sync to {}", last_sync);
304        let last_sync_millis = last_sync.as_millis();
305        db.put_meta(schema::LAST_SYNC_META_KEY, &last_sync_millis)
306    }
307
308    // Public so the bridged engine (`sync::bridge`) can read the last-sync
309    // timestamp without needing access to the private internals here. Returns
310    // `None` when we've never synced, rather than panicking on a fresh DB.
311    pub fn get_last_sync(&self, db: &LoginDb) -> Result<Option<ServerTimestamp>> {
312        Ok(db
313            .get_meta::<i64>(schema::LAST_SYNC_META_KEY)?
314            .map(ServerTimestamp))
315    }
316
317    fn mark_as_synchronized(&self, guids: &[&str], ts: ServerTimestamp) -> Result<()> {
318        let db = self.store.lock_db()?;
319        let tx = db.unchecked_transaction()?;
320        sql_support::each_chunk(guids, |chunk, _| -> Result<()> {
321            db.execute(
322                &format!(
323                    "DELETE FROM loginsM WHERE guid IN ({vars})",
324                    vars = sql_support::repeat_sql_vars(chunk.len())
325                ),
326                rusqlite::params_from_iter(chunk),
327            )?;
328            self.scope.err_if_interrupted()?;
329
330            db.execute(
331                &format!(
332                    "INSERT OR IGNORE INTO loginsM (
333                         {common_cols}, is_overridden, server_modified
334                     )
335                     SELECT {common_cols}, 0, {modified_ms_i64}
336                     FROM loginsL
337                     WHERE is_deleted = 0 AND guid IN ({vars})",
338                    common_cols = schema::COMMON_COLS,
339                    modified_ms_i64 = ts.as_millis(),
340                    vars = sql_support::repeat_sql_vars(chunk.len())
341                ),
342                rusqlite::params_from_iter(chunk),
343            )?;
344            self.scope.err_if_interrupted()?;
345
346            db.execute(
347                &format!(
348                    "DELETE FROM loginsL WHERE guid IN ({vars})",
349                    vars = sql_support::repeat_sql_vars(chunk.len())
350                ),
351                rusqlite::params_from_iter(chunk),
352            )?;
353            self.scope.err_if_interrupted()?;
354            Ok(())
355        })?;
356        self.set_last_sync(&db, ts)?;
357        tx.commit()?;
358        Ok(())
359    }
360
361    // This exists here as a public function so the store can call it. Ideally
362    // the store would not do that :) Then it can go back into the sync trait
363    // and return an anyhow::Result
364    pub fn do_reset(&self, assoc: &EngineSyncAssociation) -> Result<()> {
365        info!("Executing reset on password engine!");
366        let db = self.store.lock_db()?;
367        let tx = db.unchecked_transaction()?;
368        db.execute_all(&[
369            &CLONE_ENTIRE_MIRROR_SQL,
370            "DELETE FROM loginsM",
371            &format!("UPDATE loginsL SET sync_status = {}", SyncStatus::New as u8),
372        ])?;
373        self.set_last_sync(&db, ServerTimestamp(0))?;
374        match assoc {
375            EngineSyncAssociation::Disconnected => {
376                db.delete_meta(schema::GLOBAL_SYNCID_META_KEY)?;
377                db.delete_meta(schema::COLLECTION_SYNCID_META_KEY)?;
378            }
379            EngineSyncAssociation::Connected(ids) => {
380                db.put_meta(schema::GLOBAL_SYNCID_META_KEY, &ids.global)?;
381                db.put_meta(schema::COLLECTION_SYNCID_META_KEY, &ids.coll)?;
382            }
383        };
384        tx.commit()?;
385        Ok(())
386    }
387
388    // It would be nice if this were a batch-ish api (e.g. takes a slice of records and finds dupes
389    // for each one if they exist)... I can't think of how to write that query, though.
390    // This is subtly different from dupe handling by the main API and maybe
391    // could be consolidated, but for now it remains sync specific.
392    pub(crate) fn find_dupe_login(&self, l: &EncryptedLogin) -> Result<Option<EncryptedLogin>> {
393        let form_submit_host_port = l
394            .fields
395            .form_action_origin
396            .as_ref()
397            .and_then(|s| util::url_host_port(s));
398        let enc_fields = l.decrypt_fields(self.encdec.as_ref())?;
399        let args = named_params! {
400            ":origin": l.fields.origin,
401            ":http_realm": l.fields.http_realm,
402            ":form_submit": form_submit_host_port,
403        };
404        let mut query = format!(
405            "SELECT {common}
406             FROM loginsL
407             WHERE origin IS :origin
408               AND httpRealm IS :http_realm",
409            common = schema::COMMON_COLS,
410        );
411        if form_submit_host_port.is_some() {
412            // Stolen from iOS
413            query += " AND (formActionOrigin = '' OR (instr(formActionOrigin, :form_submit) > 0))";
414        } else {
415            query += " AND formActionOrigin IS :form_submit"
416        }
417        let db = self.store.lock_db()?;
418        let mut stmt = db.prepare_cached(&query)?;
419        for login in stmt
420            .query_and_then(args, EncryptedLogin::from_row)?
421            .collect::<Result<Vec<EncryptedLogin>>>()?
422        {
423            let this_enc_fields = login.decrypt_fields(self.encdec.as_ref())?;
424            if enc_fields.username == this_enc_fields.username {
425                return Ok(Some(login));
426            }
427        }
428        Ok(None)
429    }
430}
431
432impl SyncEngine for LoginsSyncEngine {
433    fn collection_name(&self) -> std::borrow::Cow<'static, str> {
434        "passwords".into()
435    }
436
437    fn stage_incoming(
438        &self,
439        mut inbound: Vec<IncomingBso>,
440        _telem: &mut telemetry::Engine,
441    ) -> anyhow::Result<()> {
442        // We don't have cross-item dependencies like bookmarks does, so we can
443        // just apply now instead of "staging"
444        self.staged.lock().unwrap().append(&mut inbound);
445        Ok(())
446    }
447
448    fn apply(
449        &self,
450        timestamp: ServerTimestamp,
451        telem: &mut telemetry::Engine,
452    ) -> anyhow::Result<Vec<OutgoingBso>> {
453        let inbound = self.staged.lock().unwrap().drain(..).collect();
454        Ok(self.do_apply_incoming(inbound, timestamp, telem)?)
455    }
456
457    fn set_uploaded(&self, new_timestamp: ServerTimestamp, ids: Vec<Guid>) -> anyhow::Result<()> {
458        Ok(self.mark_as_synchronized(
459            &ids.iter().map(Guid::as_str).collect::<Vec<_>>(),
460            new_timestamp,
461        )?)
462    }
463
464    fn get_collection_request(
465        &self,
466        server_timestamp: ServerTimestamp,
467    ) -> anyhow::Result<Option<CollectionRequest>> {
468        let db = self.store.lock_db()?;
469        let since = self.get_last_sync(&db)?.unwrap_or_default();
470        Ok(if since == server_timestamp {
471            None
472        } else {
473            Some(
474                CollectionRequest::new("passwords".into())
475                    .full()
476                    .newer_than(since),
477            )
478        })
479    }
480
481    fn get_sync_assoc(&self) -> anyhow::Result<EngineSyncAssociation> {
482        let db = self.store.lock_db()?;
483        let global = db.get_meta(schema::GLOBAL_SYNCID_META_KEY)?;
484        let coll = db.get_meta(schema::COLLECTION_SYNCID_META_KEY)?;
485        Ok(if let (Some(global), Some(coll)) = (global, coll) {
486            EngineSyncAssociation::Connected(CollSyncIds { global, coll })
487        } else {
488            EngineSyncAssociation::Disconnected
489        })
490    }
491
492    fn reset(&self, assoc: &EngineSyncAssociation) -> anyhow::Result<()> {
493        self.do_reset(assoc)?;
494        Ok(())
495    }
496}
497
498#[cfg(not(feature = "keydb"))]
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::db::test_utils::insert_login;
503    use crate::encryption::test_utils::TEST_ENCDEC;
504    use crate::login::test_utils::enc_login;
505    use crate::{LoginEntry, LoginFields, LoginMeta, SecureLoginFields};
506    use nss_as::ensure_initialized;
507    use std::collections::HashMap;
508    use std::sync::Arc;
509
510    // Wrap sync functions for easier testing
511    fn run_fetch_login_data(
512        engine: &mut LoginsSyncEngine,
513        records: Vec<IncomingBso>,
514    ) -> (Vec<SyncLoginData>, telemetry::EngineIncoming) {
515        let mut telem = sync15::telemetry::EngineIncoming::new();
516        (engine.fetch_login_data(records, &mut telem).unwrap(), telem)
517    }
518
519    fn run_fetch_outgoing(store: LoginStore) -> Vec<OutgoingBso> {
520        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
521        engine.fetch_outgoing().unwrap()
522    }
523
524    #[test]
525    fn test_fetch_login_data() {
526        ensure_initialized();
527        // Test some common cases with fetch_login data
528        let store = LoginStore::new_in_memory();
529        insert_login(
530            &store.lock_db().unwrap(),
531            "updated_remotely",
532            None,
533            Some("password"),
534        );
535        insert_login(
536            &store.lock_db().unwrap(),
537            "deleted_remotely",
538            None,
539            Some("password"),
540        );
541        insert_login(
542            &store.lock_db().unwrap(),
543            "three_way_merge",
544            Some("new-local-password"),
545            Some("password"),
546        );
547
548        let mut engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
549
550        let (res, _) = run_fetch_login_data(
551            &mut engine,
552            vec![
553                IncomingBso::new_test_tombstone(Guid::new("deleted_remotely")),
554                enc_login("added_remotely", "password")
555                    .into_bso(&*TEST_ENCDEC, None)
556                    .unwrap()
557                    .to_test_incoming(),
558                enc_login("updated_remotely", "new-password")
559                    .into_bso(&*TEST_ENCDEC, None)
560                    .unwrap()
561                    .to_test_incoming(),
562                enc_login("three_way_merge", "new-remote-password")
563                    .into_bso(&*TEST_ENCDEC, None)
564                    .unwrap()
565                    .to_test_incoming(),
566            ],
567        );
568        // For simpler testing, extract/decrypt passwords and put them in a hash map
569        #[derive(Debug, PartialEq)]
570        struct SyncPasswords {
571            local: Option<String>,
572            mirror: Option<String>,
573            inbound: Option<String>,
574        }
575        let extracted_passwords: HashMap<String, SyncPasswords> = res
576            .into_iter()
577            .map(|sync_login_data| {
578                let mut guids_seen = HashSet::new();
579                let passwords = SyncPasswords {
580                    local: sync_login_data.local.map(|local_login| {
581                        guids_seen.insert(local_login.guid_str().to_string());
582                        let LocalLogin::Alive { login, .. } = local_login else {
583                            unreachable!("this test is not expecting a tombstone");
584                        };
585                        login.decrypt_fields(&*TEST_ENCDEC).unwrap().password
586                    }),
587                    mirror: sync_login_data.mirror.map(|mirror_login| {
588                        guids_seen.insert(mirror_login.login.meta.id.clone());
589                        mirror_login
590                            .login
591                            .decrypt_fields(&*TEST_ENCDEC)
592                            .unwrap()
593                            .password
594                    }),
595                    inbound: sync_login_data.inbound.map(|incoming| {
596                        guids_seen.insert(incoming.login.meta.id.clone());
597                        incoming
598                            .login
599                            .decrypt_fields(&*TEST_ENCDEC)
600                            .unwrap()
601                            .password
602                    }),
603                };
604                (guids_seen.into_iter().next().unwrap(), passwords)
605            })
606            .collect();
607
608        assert_eq!(extracted_passwords.len(), 4);
609        assert_eq!(
610            extracted_passwords.get("added_remotely").unwrap(),
611            &SyncPasswords {
612                local: None,
613                mirror: None,
614                inbound: Some("password".into()),
615            }
616        );
617        assert_eq!(
618            extracted_passwords.get("updated_remotely").unwrap(),
619            &SyncPasswords {
620                local: None,
621                mirror: Some("password".into()),
622                inbound: Some("new-password".into()),
623            }
624        );
625        assert_eq!(
626            extracted_passwords.get("deleted_remotely").unwrap(),
627            &SyncPasswords {
628                local: None,
629                mirror: Some("password".into()),
630                inbound: None,
631            }
632        );
633        assert_eq!(
634            extracted_passwords.get("three_way_merge").unwrap(),
635            &SyncPasswords {
636                local: Some("new-local-password".into()),
637                mirror: Some("password".into()),
638                inbound: Some("new-remote-password".into()),
639            }
640        );
641    }
642
643    #[test]
644    fn test_sync_local_delete() {
645        ensure_initialized();
646        let store = LoginStore::new_in_memory();
647        insert_login(
648            &store.lock_db().unwrap(),
649            "local-deleted",
650            Some("password"),
651            None,
652        );
653        store.lock_db().unwrap().delete("local-deleted").unwrap();
654        let changeset = run_fetch_outgoing(store);
655        let changes: HashMap<String, serde_json::Value> = changeset
656            .into_iter()
657            .map(|b| {
658                (
659                    b.envelope.id.to_string(),
660                    serde_json::from_str(&b.payload).unwrap(),
661                )
662            })
663            .collect();
664        assert_eq!(changes.len(), 1);
665        assert!(changes["local-deleted"].get("deleted").is_some());
666
667        // hmmm. In theory, we do not need to sync a local-only deletion
668    }
669
670    #[test]
671    fn test_sync_local_readd() {
672        ensure_initialized();
673        let store = LoginStore::new_in_memory();
674        insert_login(
675            &store.lock_db().unwrap(),
676            "local-readded",
677            Some("password"),
678            None,
679        );
680        store.lock_db().unwrap().delete("local-readded").unwrap();
681        insert_login(
682            &store.lock_db().unwrap(),
683            "local-readded",
684            Some("password"),
685            None,
686        );
687        let changeset = run_fetch_outgoing(store);
688        let changes: HashMap<String, serde_json::Value> = changeset
689            .into_iter()
690            .map(|b| {
691                (
692                    b.envelope.id.to_string(),
693                    serde_json::from_str(&b.payload).unwrap(),
694                )
695            })
696            .collect();
697        assert_eq!(changes.len(), 1);
698        assert_eq!(
699            changes["local-readded"].get("password").unwrap(),
700            "password"
701        );
702    }
703
704    #[test]
705    fn test_sync_local_readd_of_remote_deletion() {
706        ensure_initialized();
707        let other_store = LoginStore::new_in_memory();
708        let mut engine = LoginsSyncEngine::new(Arc::new(other_store)).unwrap();
709        let (_res, _telem) = run_fetch_login_data(
710            &mut engine,
711            vec![IncomingBso::new_test_tombstone(Guid::new("remote-readded"))],
712        );
713
714        let store = LoginStore::new_in_memory();
715        insert_login(
716            &store.lock_db().unwrap(),
717            "remote-readded",
718            Some("password"),
719            None,
720        );
721        let changeset = run_fetch_outgoing(store);
722        let changes: HashMap<String, serde_json::Value> = changeset
723            .into_iter()
724            .map(|b| {
725                (
726                    b.envelope.id.to_string(),
727                    serde_json::from_str(&b.payload).unwrap(),
728                )
729            })
730            .collect();
731        assert_eq!(changes.len(), 1);
732        assert_eq!(
733            changes["remote-readded"].get("password").unwrap(),
734            "password"
735        );
736    }
737
738    #[test]
739    fn test_sync_local_readd_redelete_of_remote_login() {
740        ensure_initialized();
741        let other_store = LoginStore::new_in_memory();
742        let mut engine = LoginsSyncEngine::new(Arc::new(other_store)).unwrap();
743        let (_res, _telem) = run_fetch_login_data(
744            &mut engine,
745            vec![IncomingBso::from_test_content(serde_json::json!({
746                "id": "remote-readded-redeleted",
747                "formSubmitURL": "https://www.example.com/submit",
748                "hostname": "https://www.example.com",
749                "username": "test",
750                "password": "test",
751            }))],
752        );
753
754        let store = LoginStore::new_in_memory();
755        store
756            .lock_db()
757            .unwrap()
758            .delete("remote-readded-redeleted")
759            .unwrap();
760        insert_login(
761            &store.lock_db().unwrap(),
762            "remote-readded-redeleted",
763            Some("password"),
764            None,
765        );
766        store
767            .lock_db()
768            .unwrap()
769            .delete("remote-readded-redeleted")
770            .unwrap();
771        let changeset = run_fetch_outgoing(store);
772        let changes: HashMap<String, serde_json::Value> = changeset
773            .into_iter()
774            .map(|b| {
775                (
776                    b.envelope.id.to_string(),
777                    serde_json::from_str(&b.payload).unwrap(),
778                )
779            })
780            .collect();
781        assert_eq!(changes.len(), 1);
782        assert!(changes["remote-readded-redeleted"].get("deleted").is_some());
783    }
784
785    #[test]
786    fn test_fetch_outgoing() {
787        ensure_initialized();
788        let store = LoginStore::new_in_memory();
789        insert_login(
790            &store.lock_db().unwrap(),
791            "changed",
792            Some("new-password"),
793            Some("password"),
794        );
795        insert_login(
796            &store.lock_db().unwrap(),
797            "unchanged",
798            None,
799            Some("password"),
800        );
801        insert_login(&store.lock_db().unwrap(), "added", Some("password"), None);
802        insert_login(&store.lock_db().unwrap(), "deleted", None, Some("password"));
803        store.lock_db().unwrap().delete("deleted").unwrap();
804
805        let changeset = run_fetch_outgoing(store);
806        let changes: HashMap<String, serde_json::Value> = changeset
807            .into_iter()
808            .map(|b| {
809                (
810                    b.envelope.id.to_string(),
811                    serde_json::from_str(&b.payload).unwrap(),
812                )
813            })
814            .collect();
815        assert_eq!(changes.len(), 3);
816        assert_eq!(changes["added"].get("password").unwrap(), "password");
817        assert_eq!(changes["changed"].get("password").unwrap(), "new-password");
818        assert!(changes["deleted"].get("deleted").is_some());
819        assert!(changes["added"].get("deleted").is_none());
820        assert!(changes["changed"].get("deleted").is_none());
821    }
822
823    #[test]
824    fn test_fetch_outgoing_excludes_fxa_credentials() {
825        ensure_initialized();
826        let store = LoginStore::new_in_memory();
827
828        // A normal local login that should be uploaded.
829        insert_login(&store.lock_db().unwrap(), "normal", Some("password"), None);
830
831        // Desktop's FxA session-credentials pseudo-login must never be synced.
832        store
833            .add(LoginEntry {
834                origin: FXA_CREDENTIALS_ORIGIN.to_string(),
835                http_realm: Some("Firefox Accounts credentials".to_string()),
836                username: "uid".to_string(),
837                password: "sync-token".to_string(),
838                ..Default::default()
839            })
840            .unwrap();
841
842        let changeset = run_fetch_outgoing(store);
843        let changes: HashMap<String, serde_json::Value> = changeset
844            .into_iter()
845            .map(|b| {
846                (
847                    b.envelope.id.to_string(),
848                    serde_json::from_str(&b.payload).unwrap(),
849                )
850            })
851            .collect();
852
853        // The normal login still uploads; nothing pointing at the FxA origin
854        // is outgoing.
855        assert!(changes.contains_key("normal"));
856        assert!(changes
857            .values()
858            .all(|payload| payload["hostname"] != FXA_CREDENTIALS_ORIGIN));
859    }
860
861    #[test]
862    fn test_bad_record() {
863        ensure_initialized();
864        let store = LoginStore::new_in_memory();
865        let test_ids = ["dummy_000001", "dummy_000002", "dummy_000003"];
866        for id in test_ids {
867            insert_login(
868                &store.lock_db().unwrap(),
869                id,
870                Some("password"),
871                Some("password"),
872            );
873        }
874        let mut engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
875        engine
876            .mark_as_synchronized(&test_ids, ServerTimestamp::from_millis(100))
877            .unwrap();
878        let (res, telem) = run_fetch_login_data(
879            &mut engine,
880            vec![
881                IncomingBso::new_test_tombstone(Guid::new("dummy_000001")),
882                // invalid
883                IncomingBso::from_test_content(serde_json::json!({
884                    "id": "dummy_000002",
885                    "garbage": "data",
886                    "etc": "not a login"
887                })),
888                // valid
889                IncomingBso::from_test_content(serde_json::json!({
890                    "id": "dummy_000003",
891                    "formSubmitURL": "https://www.example.com/submit",
892                    "hostname": "https://www.example.com",
893                    "username": "test",
894                    "password": "test",
895                })),
896            ],
897        );
898        assert_eq!(telem.get_failed(), 1);
899        assert_eq!(res.len(), 2);
900        assert_eq!(res[0].guid, "dummy_000001");
901        assert_eq!(res[1].guid, "dummy_000003");
902        assert_eq!(engine.fetch_outgoing().unwrap().len(), 0);
903    }
904
905    fn make_enc_login(
906        username: &str,
907        password: &str,
908        fao: Option<String>,
909        realm: Option<String>,
910    ) -> EncryptedLogin {
911        ensure_initialized();
912        let id = Guid::random().to_string();
913        let sec_fields = SecureLoginFields {
914            username: username.into(),
915            password: password.into(),
916        }
917        .encrypt(&*TEST_ENCDEC, &id)
918        .unwrap();
919        EncryptedLogin {
920            meta: LoginMeta {
921                id,
922                ..Default::default()
923            },
924            fields: LoginFields {
925                form_action_origin: fao,
926                http_realm: realm,
927                origin: "http://not-relevant-here.com".into(),
928                ..Default::default()
929            },
930            sec_fields,
931        }
932    }
933
934    #[test]
935    fn find_dupe_login() {
936        ensure_initialized();
937        let store = LoginStore::new_in_memory();
938
939        let to_add = LoginEntry {
940            form_action_origin: Some("https://www.example.com".into()),
941            origin: "http://not-relevant-here.com".into(),
942            username: "test".into(),
943            password: "test".into(),
944            ..Default::default()
945        };
946        let first_id = store.add(to_add).expect("should insert first").id;
947
948        let to_add = LoginEntry {
949            form_action_origin: Some("https://www.example1.com".into()),
950            origin: "http://not-relevant-here.com".into(),
951            username: "test1".into(),
952            password: "test1".into(),
953            ..Default::default()
954        };
955        let second_id = store.add(to_add).expect("should insert second").id;
956
957        let to_add = LoginEntry {
958            http_realm: Some("http://some-realm.com".into()),
959            origin: "http://not-relevant-here.com".into(),
960            username: "test1".into(),
961            password: "test1".into(),
962            ..Default::default()
963        };
964        let no_form_origin_id = store.add(to_add).expect("should insert second").id;
965
966        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
967
968        let to_find = make_enc_login("test", "test", Some("https://www.example.com".into()), None);
969        assert_eq!(
970            engine
971                .find_dupe_login(&to_find)
972                .expect("should work")
973                .expect("should be Some()")
974                .meta
975                .id,
976            first_id
977        );
978
979        let to_find = make_enc_login(
980            "test",
981            "test",
982            Some("https://something-else.com".into()),
983            None,
984        );
985        assert!(engine
986            .find_dupe_login(&to_find)
987            .expect("should work")
988            .is_none());
989
990        let to_find = make_enc_login(
991            "test1",
992            "test1",
993            Some("https://www.example1.com".into()),
994            None,
995        );
996        assert_eq!(
997            engine
998                .find_dupe_login(&to_find)
999                .expect("should work")
1000                .expect("should be Some()")
1001                .meta
1002                .id,
1003            second_id
1004        );
1005
1006        let to_find = make_enc_login(
1007            "other",
1008            "other",
1009            Some("https://www.example1.com".into()),
1010            None,
1011        );
1012        assert!(engine
1013            .find_dupe_login(&to_find)
1014            .expect("should work")
1015            .is_none());
1016
1017        // no form origin.
1018        let to_find = make_enc_login("test1", "test1", None, Some("http://some-realm.com".into()));
1019        assert_eq!(
1020            engine
1021                .find_dupe_login(&to_find)
1022                .expect("should work")
1023                .expect("should be Some()")
1024                .meta
1025                .id,
1026            no_form_origin_id
1027        );
1028    }
1029
1030    #[test]
1031    fn test_roundtrip_unknown() {
1032        ensure_initialized();
1033        // A couple of helpers
1034        fn apply_incoming_payload(engine: &LoginsSyncEngine, payload: serde_json::Value) {
1035            let bso = IncomingBso::from_test_content(payload);
1036            let mut telem = sync15::telemetry::Engine::new(engine.collection_name());
1037            engine.stage_incoming(vec![bso], &mut telem).unwrap();
1038            engine
1039                .apply(ServerTimestamp::from_millis(0), &mut telem)
1040                .unwrap();
1041        }
1042
1043        fn get_outgoing_payload(engine: &LoginsSyncEngine) -> serde_json::Value {
1044            // Edit it so it's considered outgoing.
1045            engine
1046                .store
1047                .update(
1048                    "dummy_000001",
1049                    LoginEntry {
1050                        origin: "https://www.example2.com".into(),
1051                        http_realm: Some("https://www.example2.com".into()),
1052                        username: "test".into(),
1053                        password: "test".into(),
1054                        ..Default::default()
1055                    },
1056                )
1057                .unwrap();
1058            let changeset = engine.fetch_outgoing().unwrap();
1059            assert_eq!(changeset.len(), 1);
1060            serde_json::from_str::<serde_json::Value>(&changeset[0].payload).unwrap()
1061        }
1062
1063        // The test itself...
1064        let store = LoginStore::new_in_memory();
1065        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1066
1067        apply_incoming_payload(
1068            &engine,
1069            serde_json::json!({
1070                "id": "dummy_000001",
1071                "formSubmitURL": "https://www.example.com/submit",
1072                "hostname": "https://www.example.com",
1073                "username": "test",
1074                "password": "test",
1075                "unknown1": "?",
1076                "unknown2": {"sub": "object"},
1077            }),
1078        );
1079
1080        let payload = get_outgoing_payload(&engine);
1081
1082        // The outgoing payload for our item should have the unknown fields.
1083        assert_eq!(payload.get("unknown1").unwrap().as_str().unwrap(), "?");
1084        assert_eq!(
1085            payload.get("unknown2").unwrap(),
1086            &serde_json::json!({"sub": "object"})
1087        );
1088
1089        // test mirror updates - record is already in our mirror, but now it's
1090        // incoming with different unknown fields.
1091        apply_incoming_payload(
1092            &engine,
1093            serde_json::json!({
1094                "id": "dummy_000001",
1095                "formSubmitURL": "https://www.example.com/submit",
1096                "hostname": "https://www.example.com",
1097                "username": "test",
1098                "password": "test",
1099                "unknown2": 99,
1100                "unknown3": {"something": "else"},
1101            }),
1102        );
1103        let payload = get_outgoing_payload(&engine);
1104        // old unknown values were replaced.
1105        assert!(payload.get("unknown1").is_none());
1106        assert_eq!(payload.get("unknown2").unwrap().as_u64().unwrap(), 99);
1107        assert_eq!(
1108            payload
1109                .get("unknown3")
1110                .unwrap()
1111                .as_object()
1112                .unwrap()
1113                .get("something")
1114                .unwrap()
1115                .as_str()
1116                .unwrap(),
1117            "else"
1118        );
1119    }
1120
1121    fn count(engine: &LoginsSyncEngine, table_name: &str) -> u32 {
1122        ensure_initialized();
1123        let sql = format!("SELECT COUNT(*) FROM {table_name}");
1124        engine
1125            .store
1126            .lock_db()
1127            // TODO: get rid of this unwrap
1128            .unwrap()
1129            .try_query_one(&sql, [], false)
1130            .unwrap()
1131            .unwrap()
1132    }
1133
1134    fn do_test_incoming_with_local_unmirrored_tombstone(local_newer: bool) {
1135        ensure_initialized();
1136        fn apply_incoming_payload(engine: &LoginsSyncEngine, payload: serde_json::Value) {
1137            let bso = IncomingBso::from_test_content(payload);
1138            let mut telem = sync15::telemetry::Engine::new(engine.collection_name());
1139            engine.stage_incoming(vec![bso], &mut telem).unwrap();
1140            engine
1141                .apply(ServerTimestamp::from_millis(0), &mut telem)
1142                .unwrap();
1143        }
1144
1145        // The test itself...
1146        let (local_timestamp, remote_timestamp) = if local_newer { (123, 0) } else { (0, 123) };
1147
1148        let store = LoginStore::new_in_memory();
1149        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1150
1151        // apply an incoming record - will be in the mirror.
1152        apply_incoming_payload(
1153            &engine,
1154            serde_json::json!({
1155                "id": "dummy_000001",
1156                "formSubmitURL": "https://www.example.com/submit",
1157                "hostname": "https://www.example.com",
1158                "username": "test",
1159                "password": "test",
1160                "timePasswordChanged": local_timestamp,
1161                "unknown1": "?",
1162                "unknown2": {"sub": "object"},
1163            }),
1164        );
1165
1166        // Reset the engine - this wipes the mirror.
1167        engine.reset(&EngineSyncAssociation::Disconnected).unwrap();
1168        // But the local record does still exist.
1169        assert!(engine
1170            .store
1171            .get("dummy_000001")
1172            .expect("should work")
1173            .is_some());
1174
1175        // Delete the local record.
1176        engine.store.delete("dummy_000001").unwrap();
1177        assert!(engine
1178            .store
1179            .get("dummy_000001")
1180            .expect("should work")
1181            .is_none());
1182
1183        // double-check our test preconditions - should now have 1 in LoginsL and 0 in LoginsM
1184        assert_eq!(count(&engine, "LoginsL"), 1);
1185        assert_eq!(count(&engine, "LoginsM"), 0);
1186
1187        // Now we assume we've been reconnected to sync and have an incoming change for the record.
1188        apply_incoming_payload(
1189            &engine,
1190            serde_json::json!({
1191                "id": "dummy_000001",
1192                "formSubmitURL": "https://www.example.com/submit",
1193                "hostname": "https://www.example.com",
1194                "username": "test",
1195                "password": "test2",
1196                "timePasswordChanged": remote_timestamp,
1197                "unknown1": "?",
1198                "unknown2": {"sub": "object"},
1199            }),
1200        );
1201
1202        // Desktop semantics here are that a local tombstone is treated as though it doesn't exist at all.
1203        // ie, the remote record should be taken whether it is newer or older than the tombstone.
1204        assert!(engine
1205            .store
1206            .get("dummy_000001")
1207            .expect("should work")
1208            .is_some());
1209        // and there should never be an outgoing record.
1210        // XXX - but there is! But this is exceedingly rare, we
1211        // should fix it :)
1212        // assert_eq!(engine.fetch_outgoing().unwrap().len(), 0);
1213
1214        // should now be no records in loginsL and 1 in loginsM
1215        assert_eq!(count(&engine, "LoginsL"), 0);
1216        assert_eq!(count(&engine, "LoginsM"), 1);
1217    }
1218
1219    #[test]
1220    fn test_incoming_non_mirror_tombstone_local_newer() {
1221        do_test_incoming_with_local_unmirrored_tombstone(true);
1222    }
1223
1224    #[test]
1225    fn test_incoming_non_mirror_tombstone_local_older() {
1226        do_test_incoming_with_local_unmirrored_tombstone(false);
1227    }
1228}