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