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