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| -> Result<Option<OutgoingBso>> {
262                self.scope.err_if_interrupted()?;
263                let guid: Guid = row.get::<_, String>("guid")?.into();
264                // A guid we consider invalid for the sync server used to panic the
265                // uploader (bug 2056116). We can't serialize such a record, so skip it
266                // rather than let a single login block the whole sync.
267                if !guid.is_valid_for_sync_server() {
268                    // Report the length rather than the guid itself, which is arbitrary
269                    // data we'd rather not send to Sentry.
270                    report_error!(
271                        "logins-invalid-outgoing-guid",
272                        "skipping outgoing login with a guid that is invalid for the sync server (len {})",
273                        guid.len()
274                    );
275                    return Ok(None);
276                }
277                Ok(Some(if row.get::<_, bool>("is_deleted")? {
278                    let envelope = OutgoingEnvelope {
279                        id: guid,
280                        sortindex: Some(TOMBSTONE_SORTINDEX),
281                        ..Default::default()
282                    };
283                    OutgoingBso::new_tombstone(envelope)
284                } else {
285                    let unknown = row.get::<_, Option<String>>("enc_unknown_fields")?;
286                    let mut bso =
287                        EncryptedLogin::from_row(row)?.into_bso(db.encdec.as_ref(), unknown)?;
288                    bso.envelope.sortindex = Some(DEFAULT_SORTINDEX);
289                    bso
290                }))
291            },
292        )?;
293        bsos.filter_map(|r| r.transpose()).collect::<Result<_>>()
294    }
295
296    fn do_apply_incoming(
297        &self,
298        inbound: Vec<IncomingBso>,
299        timestamp: ServerTimestamp,
300        telem: &mut telemetry::Engine,
301    ) -> Result<Vec<OutgoingBso>> {
302        let mut incoming_telemetry = telemetry::EngineIncoming::new();
303        let data = self.fetch_login_data(inbound, &mut incoming_telemetry)?;
304        let plan = {
305            let result = self.reconcile(data, timestamp, &mut incoming_telemetry);
306            telem.incoming(incoming_telemetry);
307            result
308        }?;
309        self.execute_plan(plan)?;
310        self.fetch_outgoing()
311    }
312
313    // Note this receives the db to prevent a deadlock
314    pub fn set_last_sync(&self, db: &LoginDb, last_sync: ServerTimestamp) -> Result<()> {
315        debug!("Updating last sync to {}", last_sync);
316        let last_sync_millis = last_sync.as_millis();
317        db.put_meta(schema::LAST_SYNC_META_KEY, &last_sync_millis)
318    }
319
320    // Public so the bridged engine (`sync::bridge`) can read the last-sync
321    // timestamp without needing access to the private internals here. Returns
322    // `None` when we've never synced, rather than panicking on a fresh DB.
323    pub fn get_last_sync(&self, db: &LoginDb) -> Result<Option<ServerTimestamp>> {
324        Ok(db
325            .get_meta::<i64>(schema::LAST_SYNC_META_KEY)?
326            .map(ServerTimestamp))
327    }
328
329    fn mark_as_synchronized(&self, guids: &[&str], ts: ServerTimestamp) -> Result<()> {
330        let db = self.store.lock_db()?;
331        let tx = db.unchecked_transaction()?;
332        sql_support::each_chunk(guids, |chunk, _| -> Result<()> {
333            db.execute(
334                &format!(
335                    "DELETE FROM loginsM WHERE guid IN ({vars})",
336                    vars = sql_support::repeat_sql_vars(chunk.len())
337                ),
338                rusqlite::params_from_iter(chunk),
339            )?;
340            self.scope.err_if_interrupted()?;
341
342            db.execute(
343                &format!(
344                    "INSERT OR IGNORE INTO loginsM (
345                         {common_cols}, is_overridden, server_modified
346                     )
347                     SELECT {common_cols}, 0, {modified_ms_i64}
348                     FROM loginsL
349                     WHERE is_deleted = 0 AND guid IN ({vars})",
350                    common_cols = schema::COMMON_COLS,
351                    modified_ms_i64 = ts.as_millis(),
352                    vars = sql_support::repeat_sql_vars(chunk.len())
353                ),
354                rusqlite::params_from_iter(chunk),
355            )?;
356            self.scope.err_if_interrupted()?;
357
358            db.execute(
359                &format!(
360                    "DELETE FROM loginsL WHERE guid IN ({vars})",
361                    vars = sql_support::repeat_sql_vars(chunk.len())
362                ),
363                rusqlite::params_from_iter(chunk),
364            )?;
365            self.scope.err_if_interrupted()?;
366            Ok(())
367        })?;
368        self.set_last_sync(&db, ts)?;
369        tx.commit()?;
370        Ok(())
371    }
372
373    // This exists here as a public function so the store can call it. Ideally
374    // the store would not do that :) Then it can go back into the sync trait
375    // and return an anyhow::Result
376    pub fn do_reset(&self, assoc: &EngineSyncAssociation) -> Result<()> {
377        info!("Executing reset on password engine!");
378        let db = self.store.lock_db()?;
379        let tx = db.unchecked_transaction()?;
380        db.execute_all(&[
381            &CLONE_ENTIRE_MIRROR_SQL,
382            "DELETE FROM loginsM",
383            &format!("UPDATE loginsL SET sync_status = {}", SyncStatus::New as u8),
384        ])?;
385        self.set_last_sync(&db, ServerTimestamp(0))?;
386        match assoc {
387            EngineSyncAssociation::Disconnected => {
388                db.delete_meta(schema::GLOBAL_SYNCID_META_KEY)?;
389                db.delete_meta(schema::COLLECTION_SYNCID_META_KEY)?;
390            }
391            EngineSyncAssociation::Connected(ids) => {
392                db.put_meta(schema::GLOBAL_SYNCID_META_KEY, &ids.global)?;
393                db.put_meta(schema::COLLECTION_SYNCID_META_KEY, &ids.coll)?;
394            }
395        };
396        tx.commit()?;
397        Ok(())
398    }
399
400    // It would be nice if this were a batch-ish api (e.g. takes a slice of records and finds dupes
401    // for each one if they exist)... I can't think of how to write that query, though.
402    // This is subtly different from dupe handling by the main API and maybe
403    // could be consolidated, but for now it remains sync specific.
404    pub(crate) fn find_dupe_login(&self, l: &EncryptedLogin) -> Result<Option<EncryptedLogin>> {
405        let form_submit_host_port = l
406            .fields
407            .form_action_origin
408            .as_ref()
409            .and_then(|s| util::url_host_port(s));
410        let encdec = self.encdec()?;
411        let enc_fields = l.decrypt_fields(encdec.as_ref())?;
412        let args = named_params! {
413            ":origin": l.fields.origin,
414            ":http_realm": l.fields.http_realm,
415            ":form_submit": form_submit_host_port,
416        };
417        let mut query = format!(
418            "SELECT {common}
419             FROM loginsL
420             WHERE origin IS :origin
421               AND httpRealm IS :http_realm",
422            common = schema::COMMON_COLS,
423        );
424        if form_submit_host_port.is_some() {
425            // Stolen from iOS
426            query += " AND (formActionOrigin = '' OR (instr(formActionOrigin, :form_submit) > 0))";
427        } else {
428            query += " AND formActionOrigin IS :form_submit"
429        }
430        let db = self.store.lock_db()?;
431        let mut stmt = db.prepare_cached(&query)?;
432        for login in stmt
433            .query_and_then(args, EncryptedLogin::from_row)?
434            .collect::<Result<Vec<EncryptedLogin>>>()?
435        {
436            let this_enc_fields = login.decrypt_fields(encdec.as_ref())?;
437            if enc_fields.username == this_enc_fields.username {
438                return Ok(Some(login));
439            }
440        }
441        Ok(None)
442    }
443}
444
445impl SyncEngine for LoginsSyncEngine {
446    fn collection_name(&self) -> std::borrow::Cow<'static, str> {
447        "passwords".into()
448    }
449
450    fn stage_incoming(
451        &self,
452        mut inbound: Vec<IncomingBso>,
453        _telem: &mut telemetry::Engine,
454    ) -> anyhow::Result<()> {
455        // We don't have cross-item dependencies like bookmarks does, so we can
456        // just apply now instead of "staging"
457        self.staged.lock().unwrap().append(&mut inbound);
458        Ok(())
459    }
460
461    fn apply(
462        &self,
463        timestamp: ServerTimestamp,
464        telem: &mut telemetry::Engine,
465    ) -> anyhow::Result<Vec<OutgoingBso>> {
466        let inbound = self.staged.lock().unwrap().drain(..).collect();
467        let outgoing = self.do_apply_incoming(inbound, timestamp, telem)?;
468        // The engine owns its last-sync timestamp but during a sync, that
469        // value is known differently in desktop v mobile. Record a
470        // timestamp if we are given one.
471        if timestamp != ServerTimestamp(0) {
472            let db = self.store.lock_db()?;
473            self.set_last_sync(&db, timestamp)?;
474        }
475        Ok(outgoing)
476    }
477
478    fn set_uploaded(&self, new_timestamp: ServerTimestamp, ids: Vec<Guid>) -> anyhow::Result<()> {
479        Ok(self.mark_as_synchronized(
480            &ids.iter().map(Guid::as_str).collect::<Vec<_>>(),
481            new_timestamp,
482        )?)
483    }
484
485    // For the Desktop bridge which makes the collection requests.
486    fn last_sync(&self) -> anyhow::Result<Option<ServerTimestamp>> {
487        let db = self.store.lock_db()?;
488        Ok(self.get_last_sync(&db)?)
489    }
490
491    // Force a full re-download next sync without a full reset. Desktop's bridged
492    // engine base calls this for every engine, so logins must implement it
493    // rather than fall back to the no-op default.
494    fn reset_last_sync(&self) -> anyhow::Result<()> {
495        let db = self.store.lock_db()?;
496        self.set_last_sync(&db, ServerTimestamp(0))?;
497        Ok(())
498    }
499
500    fn get_collection_request(
501        &self,
502        server_timestamp: ServerTimestamp,
503    ) -> anyhow::Result<Option<CollectionRequest>> {
504        let db = self.store.lock_db()?;
505        let since = self.get_last_sync(&db)?.unwrap_or_default();
506        Ok(if since == server_timestamp {
507            None
508        } else {
509            Some(
510                CollectionRequest::new("passwords".into())
511                    .full()
512                    .newer_than(since),
513            )
514        })
515    }
516
517    fn get_sync_assoc(&self) -> anyhow::Result<EngineSyncAssociation> {
518        let db = self.store.lock_db()?;
519        let global = db.get_meta(schema::GLOBAL_SYNCID_META_KEY)?;
520        let coll = db.get_meta(schema::COLLECTION_SYNCID_META_KEY)?;
521        Ok(if let (Some(global), Some(coll)) = (global, coll) {
522            EngineSyncAssociation::Connected(CollSyncIds { global, coll })
523        } else {
524            EngineSyncAssociation::Disconnected
525        })
526    }
527
528    fn reset(&self, assoc: &EngineSyncAssociation) -> anyhow::Result<()> {
529        self.do_reset(assoc)?;
530        Ok(())
531    }
532
533    fn wipe(&self) -> anyhow::Result<()> {
534        self.store.wipe_local().map_err(Into::into)
535    }
536}
537
538#[cfg(not(feature = "keydb"))]
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::db::test_utils::insert_login;
543    use crate::encryption::test_utils::TEST_ENCDEC;
544    use crate::login::test_utils::enc_login;
545    use crate::{LoginEntry, LoginFields, LoginMeta, SecureLoginFields};
546    use nss_as::ensure_initialized;
547    use std::collections::HashMap;
548    use std::sync::Arc;
549
550    // Wrap sync functions for easier testing
551    fn run_fetch_login_data(
552        engine: &mut LoginsSyncEngine,
553        records: Vec<IncomingBso>,
554    ) -> (Vec<SyncLoginData>, telemetry::EngineIncoming) {
555        let mut telem = sync15::telemetry::EngineIncoming::new();
556        (engine.fetch_login_data(records, &mut telem).unwrap(), telem)
557    }
558
559    fn run_fetch_outgoing(store: LoginStore) -> Vec<OutgoingBso> {
560        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
561        engine.fetch_outgoing().unwrap()
562    }
563
564    #[test]
565    fn test_fetch_login_data() {
566        ensure_initialized();
567        // Test some common cases with fetch_login data
568        let store = LoginStore::new_in_memory();
569        insert_login(
570            &store.lock_db().unwrap(),
571            "updated_remotely",
572            None,
573            Some("password"),
574        );
575        insert_login(
576            &store.lock_db().unwrap(),
577            "deleted_remotely",
578            None,
579            Some("password"),
580        );
581        insert_login(
582            &store.lock_db().unwrap(),
583            "three_way_merge",
584            Some("new-local-password"),
585            Some("password"),
586        );
587
588        let mut engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
589
590        let (res, _) = run_fetch_login_data(
591            &mut engine,
592            vec![
593                IncomingBso::new_test_tombstone(Guid::new("deleted_remotely")),
594                enc_login("added_remotely", "password")
595                    .into_bso(&*TEST_ENCDEC, None)
596                    .unwrap()
597                    .to_test_incoming(),
598                enc_login("updated_remotely", "new-password")
599                    .into_bso(&*TEST_ENCDEC, None)
600                    .unwrap()
601                    .to_test_incoming(),
602                enc_login("three_way_merge", "new-remote-password")
603                    .into_bso(&*TEST_ENCDEC, None)
604                    .unwrap()
605                    .to_test_incoming(),
606            ],
607        );
608        // For simpler testing, extract/decrypt passwords and put them in a hash map
609        #[derive(Debug, PartialEq)]
610        struct SyncPasswords {
611            local: Option<String>,
612            mirror: Option<String>,
613            inbound: Option<String>,
614        }
615        let extracted_passwords: HashMap<String, SyncPasswords> = res
616            .into_iter()
617            .map(|sync_login_data| {
618                let mut guids_seen = HashSet::new();
619                let passwords = SyncPasswords {
620                    local: sync_login_data.local.map(|local_login| {
621                        guids_seen.insert(local_login.guid_str().to_string());
622                        let LocalLogin::Alive { login, .. } = local_login else {
623                            unreachable!("this test is not expecting a tombstone");
624                        };
625                        login.decrypt_fields(&*TEST_ENCDEC).unwrap().password
626                    }),
627                    mirror: sync_login_data.mirror.map(|mirror_login| {
628                        guids_seen.insert(mirror_login.login.meta.id.clone());
629                        mirror_login
630                            .login
631                            .decrypt_fields(&*TEST_ENCDEC)
632                            .unwrap()
633                            .password
634                    }),
635                    inbound: sync_login_data.inbound.map(|incoming| {
636                        guids_seen.insert(incoming.login.meta.id.clone());
637                        incoming
638                            .login
639                            .decrypt_fields(&*TEST_ENCDEC)
640                            .unwrap()
641                            .password
642                    }),
643                };
644                (guids_seen.into_iter().next().unwrap(), passwords)
645            })
646            .collect();
647
648        assert_eq!(extracted_passwords.len(), 4);
649        assert_eq!(
650            extracted_passwords.get("added_remotely").unwrap(),
651            &SyncPasswords {
652                local: None,
653                mirror: None,
654                inbound: Some("password".into()),
655            }
656        );
657        assert_eq!(
658            extracted_passwords.get("updated_remotely").unwrap(),
659            &SyncPasswords {
660                local: None,
661                mirror: Some("password".into()),
662                inbound: Some("new-password".into()),
663            }
664        );
665        assert_eq!(
666            extracted_passwords.get("deleted_remotely").unwrap(),
667            &SyncPasswords {
668                local: None,
669                mirror: Some("password".into()),
670                inbound: None,
671            }
672        );
673        assert_eq!(
674            extracted_passwords.get("three_way_merge").unwrap(),
675            &SyncPasswords {
676                local: Some("new-local-password".into()),
677                mirror: Some("password".into()),
678                inbound: Some("new-remote-password".into()),
679            }
680        );
681    }
682
683    #[test]
684    fn test_sync_local_delete() {
685        ensure_initialized();
686        let store = LoginStore::new_in_memory();
687        insert_login(
688            &store.lock_db().unwrap(),
689            "local-deleted",
690            Some("password"),
691            None,
692        );
693        store.lock_db().unwrap().delete("local-deleted").unwrap();
694        let changeset = run_fetch_outgoing(store);
695        let changes: HashMap<String, serde_json::Value> = changeset
696            .into_iter()
697            .map(|b| {
698                (
699                    b.envelope.id.to_string(),
700                    serde_json::from_str(&b.payload).unwrap(),
701                )
702            })
703            .collect();
704        assert_eq!(changes.len(), 1);
705        assert!(changes["local-deleted"].get("deleted").is_some());
706
707        // hmmm. In theory, we do not need to sync a local-only deletion
708    }
709
710    #[test]
711    fn test_sync_local_readd() {
712        ensure_initialized();
713        let store = LoginStore::new_in_memory();
714        insert_login(
715            &store.lock_db().unwrap(),
716            "local-readded",
717            Some("password"),
718            None,
719        );
720        store.lock_db().unwrap().delete("local-readded").unwrap();
721        insert_login(
722            &store.lock_db().unwrap(),
723            "local-readded",
724            Some("password"),
725            None,
726        );
727        let changeset = run_fetch_outgoing(store);
728        let changes: HashMap<String, serde_json::Value> = changeset
729            .into_iter()
730            .map(|b| {
731                (
732                    b.envelope.id.to_string(),
733                    serde_json::from_str(&b.payload).unwrap(),
734                )
735            })
736            .collect();
737        assert_eq!(changes.len(), 1);
738        assert_eq!(
739            changes["local-readded"].get("password").unwrap(),
740            "password"
741        );
742    }
743
744    #[test]
745    fn test_sync_local_readd_of_remote_deletion() {
746        ensure_initialized();
747        let other_store = LoginStore::new_in_memory();
748        let mut engine = LoginsSyncEngine::new(Arc::new(other_store)).unwrap();
749        let (_res, _telem) = run_fetch_login_data(
750            &mut engine,
751            vec![IncomingBso::new_test_tombstone(Guid::new("remote-readded"))],
752        );
753
754        let store = LoginStore::new_in_memory();
755        insert_login(
756            &store.lock_db().unwrap(),
757            "remote-readded",
758            Some("password"),
759            None,
760        );
761        let changeset = run_fetch_outgoing(store);
762        let changes: HashMap<String, serde_json::Value> = changeset
763            .into_iter()
764            .map(|b| {
765                (
766                    b.envelope.id.to_string(),
767                    serde_json::from_str(&b.payload).unwrap(),
768                )
769            })
770            .collect();
771        assert_eq!(changes.len(), 1);
772        assert_eq!(
773            changes["remote-readded"].get("password").unwrap(),
774            "password"
775        );
776    }
777
778    #[test]
779    fn test_sync_local_readd_redelete_of_remote_login() {
780        ensure_initialized();
781        let other_store = LoginStore::new_in_memory();
782        let mut engine = LoginsSyncEngine::new(Arc::new(other_store)).unwrap();
783        let (_res, _telem) = run_fetch_login_data(
784            &mut engine,
785            vec![IncomingBso::from_test_content(serde_json::json!({
786                "id": "remote-readded-redeleted",
787                "formSubmitURL": "https://www.example.com/submit",
788                "hostname": "https://www.example.com",
789                "username": "test",
790                "password": "test",
791            }))],
792        );
793
794        let store = LoginStore::new_in_memory();
795        store
796            .lock_db()
797            .unwrap()
798            .delete("remote-readded-redeleted")
799            .unwrap();
800        insert_login(
801            &store.lock_db().unwrap(),
802            "remote-readded-redeleted",
803            Some("password"),
804            None,
805        );
806        store
807            .lock_db()
808            .unwrap()
809            .delete("remote-readded-redeleted")
810            .unwrap();
811        let changeset = run_fetch_outgoing(store);
812        let changes: HashMap<String, serde_json::Value> = changeset
813            .into_iter()
814            .map(|b| {
815                (
816                    b.envelope.id.to_string(),
817                    serde_json::from_str(&b.payload).unwrap(),
818                )
819            })
820            .collect();
821        assert_eq!(changes.len(), 1);
822        assert!(changes["remote-readded-redeleted"].get("deleted").is_some());
823    }
824
825    #[test]
826    fn test_fetch_outgoing() {
827        ensure_initialized();
828        let store = LoginStore::new_in_memory();
829        insert_login(
830            &store.lock_db().unwrap(),
831            "changed",
832            Some("new-password"),
833            Some("password"),
834        );
835        insert_login(
836            &store.lock_db().unwrap(),
837            "unchanged",
838            None,
839            Some("password"),
840        );
841        insert_login(&store.lock_db().unwrap(), "added", Some("password"), None);
842        insert_login(&store.lock_db().unwrap(), "deleted", None, Some("password"));
843        store.lock_db().unwrap().delete("deleted").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        assert_eq!(changes.len(), 3);
856        assert_eq!(changes["added"].get("password").unwrap(), "password");
857        assert_eq!(changes["changed"].get("password").unwrap(), "new-password");
858        assert!(changes["deleted"].get("deleted").is_some());
859        assert!(changes["added"].get("deleted").is_none());
860        assert!(changes["changed"].get("deleted").is_none());
861    }
862
863    #[test]
864    fn test_fetch_outgoing_skips_invalid_guid() {
865        ensure_initialized();
866        let store = LoginStore::new_in_memory();
867        // A local login with a guid we consider invalid for the sync server (contains
868        // a comma), inserted directly to mimic a record that was stored before guids
869        // were validated (bug 2056116).
870        insert_login(
871            &store.lock_db().unwrap(),
872            "invalid,guid",
873            Some("password"),
874            None,
875        );
876        // A normal local login that should still be uploaded.
877        insert_login(&store.lock_db().unwrap(), "valid", Some("password"), None);
878
879        // Must not panic, and must upload only the valid record.
880        let changeset = run_fetch_outgoing(store);
881        let ids: Vec<String> = changeset
882            .iter()
883            .map(|b| b.envelope.id.to_string())
884            .collect();
885        assert_eq!(ids, vec!["valid".to_string()]);
886    }
887
888    #[test]
889    fn test_fetch_outgoing_excludes_fxa_credentials() {
890        ensure_initialized();
891        let store = LoginStore::new_in_memory();
892
893        // A normal local login that should be uploaded.
894        insert_login(&store.lock_db().unwrap(), "normal", Some("password"), None);
895
896        // Desktop's FxA session-credentials pseudo-login must never be synced.
897        store
898            .add(LoginEntry {
899                origin: FXA_CREDENTIALS_ORIGIN.to_string(),
900                http_realm: Some("Firefox Accounts credentials".to_string()),
901                username: "uid".to_string(),
902                password: "sync-token".to_string(),
903                ..Default::default()
904            })
905            .unwrap();
906
907        let changeset = run_fetch_outgoing(store);
908        let changes: HashMap<String, serde_json::Value> = changeset
909            .into_iter()
910            .map(|b| {
911                (
912                    b.envelope.id.to_string(),
913                    serde_json::from_str(&b.payload).unwrap(),
914                )
915            })
916            .collect();
917
918        // The normal login still uploads; nothing pointing at the FxA origin
919        // is outgoing.
920        assert!(changes.contains_key("normal"));
921        assert!(changes
922            .values()
923            .all(|payload| payload["hostname"] != FXA_CREDENTIALS_ORIGIN));
924    }
925
926    #[test]
927    fn test_bad_record() {
928        ensure_initialized();
929        let store = LoginStore::new_in_memory();
930        let test_ids = ["dummy_000001", "dummy_000002", "dummy_000003"];
931        for id in test_ids {
932            insert_login(
933                &store.lock_db().unwrap(),
934                id,
935                Some("password"),
936                Some("password"),
937            );
938        }
939        let mut engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
940        engine
941            .mark_as_synchronized(&test_ids, ServerTimestamp::from_millis(100))
942            .unwrap();
943        let (res, telem) = run_fetch_login_data(
944            &mut engine,
945            vec![
946                IncomingBso::new_test_tombstone(Guid::new("dummy_000001")),
947                // invalid
948                IncomingBso::from_test_content(serde_json::json!({
949                    "id": "dummy_000002",
950                    "garbage": "data",
951                    "etc": "not a login"
952                })),
953                // valid
954                IncomingBso::from_test_content(serde_json::json!({
955                    "id": "dummy_000003",
956                    "formSubmitURL": "https://www.example.com/submit",
957                    "hostname": "https://www.example.com",
958                    "username": "test",
959                    "password": "test",
960                })),
961            ],
962        );
963        assert_eq!(telem.get_failed(), 1);
964        assert_eq!(res.len(), 2);
965        assert_eq!(res[0].guid, "dummy_000001");
966        assert_eq!(res[1].guid, "dummy_000003");
967        assert_eq!(engine.fetch_outgoing().unwrap().len(), 0);
968    }
969
970    fn make_enc_login(
971        username: &str,
972        password: &str,
973        fao: Option<String>,
974        realm: Option<String>,
975    ) -> EncryptedLogin {
976        ensure_initialized();
977        let id = Guid::random().to_string();
978        let sec_fields = SecureLoginFields {
979            username: username.into(),
980            password: password.into(),
981        }
982        .encrypt(&*TEST_ENCDEC, &id)
983        .unwrap();
984        EncryptedLogin {
985            meta: LoginMeta {
986                id,
987                ..Default::default()
988            },
989            fields: LoginFields {
990                form_action_origin: fao,
991                http_realm: realm,
992                origin: "http://not-relevant-here.com".into(),
993                ..Default::default()
994            },
995            sec_fields,
996        }
997    }
998
999    #[test]
1000    fn find_dupe_login() {
1001        ensure_initialized();
1002        let store = LoginStore::new_in_memory();
1003
1004        let to_add = LoginEntry {
1005            form_action_origin: Some("https://www.example.com".into()),
1006            origin: "http://not-relevant-here.com".into(),
1007            username: "test".into(),
1008            password: "test".into(),
1009            ..Default::default()
1010        };
1011        let first_id = store.add(to_add).expect("should insert first").id;
1012
1013        let to_add = LoginEntry {
1014            form_action_origin: Some("https://www.example1.com".into()),
1015            origin: "http://not-relevant-here.com".into(),
1016            username: "test1".into(),
1017            password: "test1".into(),
1018            ..Default::default()
1019        };
1020        let second_id = store.add(to_add).expect("should insert second").id;
1021
1022        let to_add = LoginEntry {
1023            http_realm: Some("http://some-realm.com".into()),
1024            origin: "http://not-relevant-here.com".into(),
1025            username: "test1".into(),
1026            password: "test1".into(),
1027            ..Default::default()
1028        };
1029        let no_form_origin_id = store.add(to_add).expect("should insert second").id;
1030
1031        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1032
1033        let to_find = make_enc_login("test", "test", Some("https://www.example.com".into()), None);
1034        assert_eq!(
1035            engine
1036                .find_dupe_login(&to_find)
1037                .expect("should work")
1038                .expect("should be Some()")
1039                .meta
1040                .id,
1041            first_id
1042        );
1043
1044        let to_find = make_enc_login(
1045            "test",
1046            "test",
1047            Some("https://something-else.com".into()),
1048            None,
1049        );
1050        assert!(engine
1051            .find_dupe_login(&to_find)
1052            .expect("should work")
1053            .is_none());
1054
1055        let to_find = make_enc_login(
1056            "test1",
1057            "test1",
1058            Some("https://www.example1.com".into()),
1059            None,
1060        );
1061        assert_eq!(
1062            engine
1063                .find_dupe_login(&to_find)
1064                .expect("should work")
1065                .expect("should be Some()")
1066                .meta
1067                .id,
1068            second_id
1069        );
1070
1071        let to_find = make_enc_login(
1072            "other",
1073            "other",
1074            Some("https://www.example1.com".into()),
1075            None,
1076        );
1077        assert!(engine
1078            .find_dupe_login(&to_find)
1079            .expect("should work")
1080            .is_none());
1081
1082        // no form origin.
1083        let to_find = make_enc_login("test1", "test1", None, Some("http://some-realm.com".into()));
1084        assert_eq!(
1085            engine
1086                .find_dupe_login(&to_find)
1087                .expect("should work")
1088                .expect("should be Some()")
1089                .meta
1090                .id,
1091            no_form_origin_id
1092        );
1093    }
1094
1095    #[test]
1096    fn test_roundtrip_unknown() {
1097        ensure_initialized();
1098        // A couple of helpers
1099        fn apply_incoming_payload(engine: &LoginsSyncEngine, payload: serde_json::Value) {
1100            let bso = IncomingBso::from_test_content(payload);
1101            let mut telem = sync15::telemetry::Engine::new(engine.collection_name());
1102            engine.stage_incoming(vec![bso], &mut telem).unwrap();
1103            engine
1104                .apply(ServerTimestamp::from_millis(0), &mut telem)
1105                .unwrap();
1106        }
1107
1108        fn get_outgoing_payload(engine: &LoginsSyncEngine) -> serde_json::Value {
1109            // Edit it so it's considered outgoing.
1110            engine
1111                .store
1112                .update(
1113                    "dummy_000001",
1114                    LoginEntry {
1115                        origin: "https://www.example2.com".into(),
1116                        http_realm: Some("https://www.example2.com".into()),
1117                        username: "test".into(),
1118                        password: "test".into(),
1119                        ..Default::default()
1120                    },
1121                )
1122                .unwrap();
1123            let changeset = engine.fetch_outgoing().unwrap();
1124            assert_eq!(changeset.len(), 1);
1125            serde_json::from_str::<serde_json::Value>(&changeset[0].payload).unwrap()
1126        }
1127
1128        // The test itself...
1129        let store = LoginStore::new_in_memory();
1130        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1131
1132        apply_incoming_payload(
1133            &engine,
1134            serde_json::json!({
1135                "id": "dummy_000001",
1136                "formSubmitURL": "https://www.example.com/submit",
1137                "hostname": "https://www.example.com",
1138                "username": "test",
1139                "password": "test",
1140                "unknown1": "?",
1141                "unknown2": {"sub": "object"},
1142            }),
1143        );
1144
1145        let payload = get_outgoing_payload(&engine);
1146
1147        // The outgoing payload for our item should have the unknown fields.
1148        assert_eq!(payload.get("unknown1").unwrap().as_str().unwrap(), "?");
1149        assert_eq!(
1150            payload.get("unknown2").unwrap(),
1151            &serde_json::json!({"sub": "object"})
1152        );
1153
1154        // test mirror updates - record is already in our mirror, but now it's
1155        // incoming with different unknown fields.
1156        apply_incoming_payload(
1157            &engine,
1158            serde_json::json!({
1159                "id": "dummy_000001",
1160                "formSubmitURL": "https://www.example.com/submit",
1161                "hostname": "https://www.example.com",
1162                "username": "test",
1163                "password": "test",
1164                "unknown2": 99,
1165                "unknown3": {"something": "else"},
1166            }),
1167        );
1168        let payload = get_outgoing_payload(&engine);
1169        // old unknown values were replaced.
1170        assert!(payload.get("unknown1").is_none());
1171        assert_eq!(payload.get("unknown2").unwrap().as_u64().unwrap(), 99);
1172        assert_eq!(
1173            payload
1174                .get("unknown3")
1175                .unwrap()
1176                .as_object()
1177                .unwrap()
1178                .get("something")
1179                .unwrap()
1180                .as_str()
1181                .unwrap(),
1182            "else"
1183        );
1184    }
1185
1186    fn count(engine: &LoginsSyncEngine, table_name: &str) -> u32 {
1187        ensure_initialized();
1188        let sql = format!("SELECT COUNT(*) FROM {table_name}");
1189        engine
1190            .store
1191            .lock_db()
1192            // TODO: get rid of this unwrap
1193            .unwrap()
1194            .try_query_one(&sql, [], false)
1195            .unwrap()
1196            .unwrap()
1197    }
1198
1199    fn do_test_incoming_with_local_unmirrored_tombstone(local_newer: bool) {
1200        ensure_initialized();
1201        fn apply_incoming_payload(engine: &LoginsSyncEngine, payload: serde_json::Value) {
1202            let bso = IncomingBso::from_test_content(payload);
1203            let mut telem = sync15::telemetry::Engine::new(engine.collection_name());
1204            engine.stage_incoming(vec![bso], &mut telem).unwrap();
1205            engine
1206                .apply(ServerTimestamp::from_millis(0), &mut telem)
1207                .unwrap();
1208        }
1209
1210        // The test itself...
1211        let (local_timestamp, remote_timestamp) = if local_newer { (123, 0) } else { (0, 123) };
1212
1213        let store = LoginStore::new_in_memory();
1214        let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1215
1216        // apply an incoming record - will be in the mirror.
1217        apply_incoming_payload(
1218            &engine,
1219            serde_json::json!({
1220                "id": "dummy_000001",
1221                "formSubmitURL": "https://www.example.com/submit",
1222                "hostname": "https://www.example.com",
1223                "username": "test",
1224                "password": "test",
1225                "timePasswordChanged": local_timestamp,
1226                "unknown1": "?",
1227                "unknown2": {"sub": "object"},
1228            }),
1229        );
1230
1231        // Reset the engine - this wipes the mirror.
1232        engine.reset(&EngineSyncAssociation::Disconnected).unwrap();
1233        // But the local record does still exist.
1234        assert!(engine
1235            .store
1236            .get("dummy_000001")
1237            .expect("should work")
1238            .is_some());
1239
1240        // Delete the local record.
1241        engine.store.delete("dummy_000001").unwrap();
1242        assert!(engine
1243            .store
1244            .get("dummy_000001")
1245            .expect("should work")
1246            .is_none());
1247
1248        // double-check our test preconditions - should now have 1 in LoginsL and 0 in LoginsM
1249        assert_eq!(count(&engine, "LoginsL"), 1);
1250        assert_eq!(count(&engine, "LoginsM"), 0);
1251
1252        // Now we assume we've been reconnected to sync and have an incoming change for the record.
1253        apply_incoming_payload(
1254            &engine,
1255            serde_json::json!({
1256                "id": "dummy_000001",
1257                "formSubmitURL": "https://www.example.com/submit",
1258                "hostname": "https://www.example.com",
1259                "username": "test",
1260                "password": "test2",
1261                "timePasswordChanged": remote_timestamp,
1262                "unknown1": "?",
1263                "unknown2": {"sub": "object"},
1264            }),
1265        );
1266
1267        // Desktop semantics here are that a local tombstone is treated as though it doesn't exist at all.
1268        // ie, the remote record should be taken whether it is newer or older than the tombstone.
1269        assert!(engine
1270            .store
1271            .get("dummy_000001")
1272            .expect("should work")
1273            .is_some());
1274        // and there should never be an outgoing record.
1275        // XXX - but there is! But this is exceedingly rare, we
1276        // should fix it :)
1277        // assert_eq!(engine.fetch_outgoing().unwrap().len(), 0);
1278
1279        // should now be no records in loginsL and 1 in loginsM
1280        assert_eq!(count(&engine, "LoginsL"), 0);
1281        assert_eq!(count(&engine, "LoginsM"), 1);
1282    }
1283
1284    #[test]
1285    fn test_incoming_non_mirror_tombstone_local_newer() {
1286        do_test_incoming_with_local_unmirrored_tombstone(true);
1287    }
1288
1289    #[test]
1290    fn test_incoming_non_mirror_tombstone_local_older() {
1291        do_test_incoming_with_local_unmirrored_tombstone(false);
1292    }
1293}