1use 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
27pub struct LoginsSyncEngine {
29 pub store: Arc<LoginStore>,
30 pub scope: SqlInterruptScope,
31 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 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 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 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 Error::InvalidLogin(InvalidLogin::IllegalOrigin { reason: _ }) => {
154 warn!("logins-deserialize-error: {e}");
155 }
156 _ => {
158 report_error!(
159 "logins-deserialize-error",
160 "Failed to deserialize record {:?}: {e}",
161 id
162 );
163 }
164 };
165 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 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 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 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.collect::<Result<()>>()?;
239 Ok(())
240 },
241 )?;
242 Ok(sync_data)
243 }
244
245 fn fetch_outgoing(&self) -> Result<Vec<OutgoingBso>> {
246 const TOMBSTONE_SORTINDEX: i32 = 5_000_000;
249 const DEFAULT_SORTINDEX: i32 = 1;
250 let db = self.store.lock_db()?;
251 let mut stmt = db.prepare_cached(&format!(
252 "SELECT L.*, M.enc_unknown_fields
253 FROM loginsL L LEFT JOIN loginsM M ON L.guid = M.guid
254 WHERE sync_status IS NOT {synced}
255 -- Never sync Desktop's FxA session-credentials pseudo-login.
256 AND L.origin IS NOT :fxa_origin",
257 synced = SyncStatus::Synced as u8
258 ))?;
259 let bsos = stmt.query_and_then(
260 named_params! { ":fxa_origin": FXA_CREDENTIALS_ORIGIN },
261 |row| {
262 self.scope.err_if_interrupted()?;
263 Ok(if row.get::<_, bool>("is_deleted")? {
264 let envelope = OutgoingEnvelope {
265 id: row.get::<_, String>("guid")?.into(),
266 sortindex: Some(TOMBSTONE_SORTINDEX),
267 ..Default::default()
268 };
269 OutgoingBso::new_tombstone(envelope)
270 } else {
271 let unknown = row.get::<_, Option<String>>("enc_unknown_fields")?;
272 let mut bso =
273 EncryptedLogin::from_row(row)?.into_bso(db.encdec.as_ref(), unknown)?;
274 bso.envelope.sortindex = Some(DEFAULT_SORTINDEX);
275 bso
276 })
277 },
278 )?;
279 bsos.collect::<Result<_>>()
280 }
281
282 fn do_apply_incoming(
283 &self,
284 inbound: Vec<IncomingBso>,
285 timestamp: ServerTimestamp,
286 telem: &mut telemetry::Engine,
287 ) -> Result<Vec<OutgoingBso>> {
288 let mut incoming_telemetry = telemetry::EngineIncoming::new();
289 let data = self.fetch_login_data(inbound, &mut incoming_telemetry)?;
290 let plan = {
291 let result = self.reconcile(data, timestamp, &mut incoming_telemetry);
292 telem.incoming(incoming_telemetry);
293 result
294 }?;
295 self.execute_plan(plan)?;
296 self.fetch_outgoing()
297 }
298
299 pub fn set_last_sync(&self, db: &LoginDb, last_sync: ServerTimestamp) -> Result<()> {
301 debug!("Updating last sync to {}", last_sync);
302 let last_sync_millis = last_sync.as_millis();
303 db.put_meta(schema::LAST_SYNC_META_KEY, &last_sync_millis)
304 }
305
306 pub fn get_last_sync(&self, db: &LoginDb) -> Result<Option<ServerTimestamp>> {
310 Ok(db
311 .get_meta::<i64>(schema::LAST_SYNC_META_KEY)?
312 .map(ServerTimestamp))
313 }
314
315 fn mark_as_synchronized(&self, guids: &[&str], ts: ServerTimestamp) -> Result<()> {
316 let db = self.store.lock_db()?;
317 let tx = db.unchecked_transaction()?;
318 sql_support::each_chunk(guids, |chunk, _| -> Result<()> {
319 db.execute(
320 &format!(
321 "DELETE FROM loginsM WHERE guid IN ({vars})",
322 vars = sql_support::repeat_sql_vars(chunk.len())
323 ),
324 rusqlite::params_from_iter(chunk),
325 )?;
326 self.scope.err_if_interrupted()?;
327
328 db.execute(
329 &format!(
330 "INSERT OR IGNORE INTO loginsM (
331 {common_cols}, is_overridden, server_modified
332 )
333 SELECT {common_cols}, 0, {modified_ms_i64}
334 FROM loginsL
335 WHERE is_deleted = 0 AND guid IN ({vars})",
336 common_cols = schema::COMMON_COLS,
337 modified_ms_i64 = ts.as_millis(),
338 vars = sql_support::repeat_sql_vars(chunk.len())
339 ),
340 rusqlite::params_from_iter(chunk),
341 )?;
342 self.scope.err_if_interrupted()?;
343
344 db.execute(
345 &format!(
346 "DELETE FROM loginsL WHERE guid IN ({vars})",
347 vars = sql_support::repeat_sql_vars(chunk.len())
348 ),
349 rusqlite::params_from_iter(chunk),
350 )?;
351 self.scope.err_if_interrupted()?;
352 Ok(())
353 })?;
354 self.set_last_sync(&db, ts)?;
355 tx.commit()?;
356 Ok(())
357 }
358
359 pub fn do_reset(&self, assoc: &EngineSyncAssociation) -> Result<()> {
363 info!("Executing reset on password engine!");
364 let db = self.store.lock_db()?;
365 let tx = db.unchecked_transaction()?;
366 db.execute_all(&[
367 &CLONE_ENTIRE_MIRROR_SQL,
368 "DELETE FROM loginsM",
369 &format!("UPDATE loginsL SET sync_status = {}", SyncStatus::New as u8),
370 ])?;
371 self.set_last_sync(&db, ServerTimestamp(0))?;
372 match assoc {
373 EngineSyncAssociation::Disconnected => {
374 db.delete_meta(schema::GLOBAL_SYNCID_META_KEY)?;
375 db.delete_meta(schema::COLLECTION_SYNCID_META_KEY)?;
376 }
377 EngineSyncAssociation::Connected(ids) => {
378 db.put_meta(schema::GLOBAL_SYNCID_META_KEY, &ids.global)?;
379 db.put_meta(schema::COLLECTION_SYNCID_META_KEY, &ids.coll)?;
380 }
381 };
382 tx.commit()?;
383 Ok(())
384 }
385
386 pub(crate) fn find_dupe_login(&self, l: &EncryptedLogin) -> Result<Option<EncryptedLogin>> {
391 let form_submit_host_port = l
392 .fields
393 .form_action_origin
394 .as_ref()
395 .and_then(|s| util::url_host_port(s));
396 let encdec = self.encdec()?;
397 let enc_fields = l.decrypt_fields(encdec.as_ref())?;
398 let args = named_params! {
399 ":origin": l.fields.origin,
400 ":http_realm": l.fields.http_realm,
401 ":form_submit": form_submit_host_port,
402 };
403 let mut query = format!(
404 "SELECT {common}
405 FROM loginsL
406 WHERE origin IS :origin
407 AND httpRealm IS :http_realm",
408 common = schema::COMMON_COLS,
409 );
410 if form_submit_host_port.is_some() {
411 query += " AND (formActionOrigin = '' OR (instr(formActionOrigin, :form_submit) > 0))";
413 } else {
414 query += " AND formActionOrigin IS :form_submit"
415 }
416 let db = self.store.lock_db()?;
417 let mut stmt = db.prepare_cached(&query)?;
418 for login in stmt
419 .query_and_then(args, EncryptedLogin::from_row)?
420 .collect::<Result<Vec<EncryptedLogin>>>()?
421 {
422 let this_enc_fields = login.decrypt_fields(encdec.as_ref())?;
423 if enc_fields.username == this_enc_fields.username {
424 return Ok(Some(login));
425 }
426 }
427 Ok(None)
428 }
429}
430
431impl SyncEngine for LoginsSyncEngine {
432 fn collection_name(&self) -> std::borrow::Cow<'static, str> {
433 "passwords".into()
434 }
435
436 fn stage_incoming(
437 &self,
438 mut inbound: Vec<IncomingBso>,
439 _telem: &mut telemetry::Engine,
440 ) -> anyhow::Result<()> {
441 self.staged.lock().unwrap().append(&mut inbound);
444 Ok(())
445 }
446
447 fn apply(
448 &self,
449 timestamp: ServerTimestamp,
450 telem: &mut telemetry::Engine,
451 ) -> anyhow::Result<Vec<OutgoingBso>> {
452 let inbound = self.staged.lock().unwrap().drain(..).collect();
453 Ok(self.do_apply_incoming(inbound, timestamp, telem)?)
454 }
455
456 fn set_uploaded(&self, new_timestamp: ServerTimestamp, ids: Vec<Guid>) -> anyhow::Result<()> {
457 Ok(self.mark_as_synchronized(
458 &ids.iter().map(Guid::as_str).collect::<Vec<_>>(),
459 new_timestamp,
460 )?)
461 }
462
463 fn get_collection_request(
464 &self,
465 server_timestamp: ServerTimestamp,
466 ) -> anyhow::Result<Option<CollectionRequest>> {
467 let db = self.store.lock_db()?;
468 let since = self.get_last_sync(&db)?.unwrap_or_default();
469 Ok(if since == server_timestamp {
470 None
471 } else {
472 Some(
473 CollectionRequest::new("passwords".into())
474 .full()
475 .newer_than(since),
476 )
477 })
478 }
479
480 fn get_sync_assoc(&self) -> anyhow::Result<EngineSyncAssociation> {
481 let db = self.store.lock_db()?;
482 let global = db.get_meta(schema::GLOBAL_SYNCID_META_KEY)?;
483 let coll = db.get_meta(schema::COLLECTION_SYNCID_META_KEY)?;
484 Ok(if let (Some(global), Some(coll)) = (global, coll) {
485 EngineSyncAssociation::Connected(CollSyncIds { global, coll })
486 } else {
487 EngineSyncAssociation::Disconnected
488 })
489 }
490
491 fn reset(&self, assoc: &EngineSyncAssociation) -> anyhow::Result<()> {
492 self.do_reset(assoc)?;
493 Ok(())
494 }
495
496 fn wipe(&self) -> anyhow::Result<()> {
497 self.store.wipe_local().map_err(Into::into)
498 }
499}
500
501#[cfg(not(feature = "keydb"))]
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::db::test_utils::insert_login;
506 use crate::encryption::test_utils::TEST_ENCDEC;
507 use crate::login::test_utils::enc_login;
508 use crate::{LoginEntry, LoginFields, LoginMeta, SecureLoginFields};
509 use nss_as::ensure_initialized;
510 use std::collections::HashMap;
511 use std::sync::Arc;
512
513 fn run_fetch_login_data(
515 engine: &mut LoginsSyncEngine,
516 records: Vec<IncomingBso>,
517 ) -> (Vec<SyncLoginData>, telemetry::EngineIncoming) {
518 let mut telem = sync15::telemetry::EngineIncoming::new();
519 (engine.fetch_login_data(records, &mut telem).unwrap(), telem)
520 }
521
522 fn run_fetch_outgoing(store: LoginStore) -> Vec<OutgoingBso> {
523 let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
524 engine.fetch_outgoing().unwrap()
525 }
526
527 #[test]
528 fn test_fetch_login_data() {
529 ensure_initialized();
530 let store = LoginStore::new_in_memory();
532 insert_login(
533 &store.lock_db().unwrap(),
534 "updated_remotely",
535 None,
536 Some("password"),
537 );
538 insert_login(
539 &store.lock_db().unwrap(),
540 "deleted_remotely",
541 None,
542 Some("password"),
543 );
544 insert_login(
545 &store.lock_db().unwrap(),
546 "three_way_merge",
547 Some("new-local-password"),
548 Some("password"),
549 );
550
551 let mut engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
552
553 let (res, _) = run_fetch_login_data(
554 &mut engine,
555 vec![
556 IncomingBso::new_test_tombstone(Guid::new("deleted_remotely")),
557 enc_login("added_remotely", "password")
558 .into_bso(&*TEST_ENCDEC, None)
559 .unwrap()
560 .to_test_incoming(),
561 enc_login("updated_remotely", "new-password")
562 .into_bso(&*TEST_ENCDEC, None)
563 .unwrap()
564 .to_test_incoming(),
565 enc_login("three_way_merge", "new-remote-password")
566 .into_bso(&*TEST_ENCDEC, None)
567 .unwrap()
568 .to_test_incoming(),
569 ],
570 );
571 #[derive(Debug, PartialEq)]
573 struct SyncPasswords {
574 local: Option<String>,
575 mirror: Option<String>,
576 inbound: Option<String>,
577 }
578 let extracted_passwords: HashMap<String, SyncPasswords> = res
579 .into_iter()
580 .map(|sync_login_data| {
581 let mut guids_seen = HashSet::new();
582 let passwords = SyncPasswords {
583 local: sync_login_data.local.map(|local_login| {
584 guids_seen.insert(local_login.guid_str().to_string());
585 let LocalLogin::Alive { login, .. } = local_login else {
586 unreachable!("this test is not expecting a tombstone");
587 };
588 login.decrypt_fields(&*TEST_ENCDEC).unwrap().password
589 }),
590 mirror: sync_login_data.mirror.map(|mirror_login| {
591 guids_seen.insert(mirror_login.login.meta.id.clone());
592 mirror_login
593 .login
594 .decrypt_fields(&*TEST_ENCDEC)
595 .unwrap()
596 .password
597 }),
598 inbound: sync_login_data.inbound.map(|incoming| {
599 guids_seen.insert(incoming.login.meta.id.clone());
600 incoming
601 .login
602 .decrypt_fields(&*TEST_ENCDEC)
603 .unwrap()
604 .password
605 }),
606 };
607 (guids_seen.into_iter().next().unwrap(), passwords)
608 })
609 .collect();
610
611 assert_eq!(extracted_passwords.len(), 4);
612 assert_eq!(
613 extracted_passwords.get("added_remotely").unwrap(),
614 &SyncPasswords {
615 local: None,
616 mirror: None,
617 inbound: Some("password".into()),
618 }
619 );
620 assert_eq!(
621 extracted_passwords.get("updated_remotely").unwrap(),
622 &SyncPasswords {
623 local: None,
624 mirror: Some("password".into()),
625 inbound: Some("new-password".into()),
626 }
627 );
628 assert_eq!(
629 extracted_passwords.get("deleted_remotely").unwrap(),
630 &SyncPasswords {
631 local: None,
632 mirror: Some("password".into()),
633 inbound: None,
634 }
635 );
636 assert_eq!(
637 extracted_passwords.get("three_way_merge").unwrap(),
638 &SyncPasswords {
639 local: Some("new-local-password".into()),
640 mirror: Some("password".into()),
641 inbound: Some("new-remote-password".into()),
642 }
643 );
644 }
645
646 #[test]
647 fn test_sync_local_delete() {
648 ensure_initialized();
649 let store = LoginStore::new_in_memory();
650 insert_login(
651 &store.lock_db().unwrap(),
652 "local-deleted",
653 Some("password"),
654 None,
655 );
656 store.lock_db().unwrap().delete("local-deleted").unwrap();
657 let changeset = run_fetch_outgoing(store);
658 let changes: HashMap<String, serde_json::Value> = changeset
659 .into_iter()
660 .map(|b| {
661 (
662 b.envelope.id.to_string(),
663 serde_json::from_str(&b.payload).unwrap(),
664 )
665 })
666 .collect();
667 assert_eq!(changes.len(), 1);
668 assert!(changes["local-deleted"].get("deleted").is_some());
669
670 }
672
673 #[test]
674 fn test_sync_local_readd() {
675 ensure_initialized();
676 let store = LoginStore::new_in_memory();
677 insert_login(
678 &store.lock_db().unwrap(),
679 "local-readded",
680 Some("password"),
681 None,
682 );
683 store.lock_db().unwrap().delete("local-readded").unwrap();
684 insert_login(
685 &store.lock_db().unwrap(),
686 "local-readded",
687 Some("password"),
688 None,
689 );
690 let changeset = run_fetch_outgoing(store);
691 let changes: HashMap<String, serde_json::Value> = changeset
692 .into_iter()
693 .map(|b| {
694 (
695 b.envelope.id.to_string(),
696 serde_json::from_str(&b.payload).unwrap(),
697 )
698 })
699 .collect();
700 assert_eq!(changes.len(), 1);
701 assert_eq!(
702 changes["local-readded"].get("password").unwrap(),
703 "password"
704 );
705 }
706
707 #[test]
708 fn test_sync_local_readd_of_remote_deletion() {
709 ensure_initialized();
710 let other_store = LoginStore::new_in_memory();
711 let mut engine = LoginsSyncEngine::new(Arc::new(other_store)).unwrap();
712 let (_res, _telem) = run_fetch_login_data(
713 &mut engine,
714 vec![IncomingBso::new_test_tombstone(Guid::new("remote-readded"))],
715 );
716
717 let store = LoginStore::new_in_memory();
718 insert_login(
719 &store.lock_db().unwrap(),
720 "remote-readded",
721 Some("password"),
722 None,
723 );
724 let changeset = run_fetch_outgoing(store);
725 let changes: HashMap<String, serde_json::Value> = changeset
726 .into_iter()
727 .map(|b| {
728 (
729 b.envelope.id.to_string(),
730 serde_json::from_str(&b.payload).unwrap(),
731 )
732 })
733 .collect();
734 assert_eq!(changes.len(), 1);
735 assert_eq!(
736 changes["remote-readded"].get("password").unwrap(),
737 "password"
738 );
739 }
740
741 #[test]
742 fn test_sync_local_readd_redelete_of_remote_login() {
743 ensure_initialized();
744 let other_store = LoginStore::new_in_memory();
745 let mut engine = LoginsSyncEngine::new(Arc::new(other_store)).unwrap();
746 let (_res, _telem) = run_fetch_login_data(
747 &mut engine,
748 vec![IncomingBso::from_test_content(serde_json::json!({
749 "id": "remote-readded-redeleted",
750 "formSubmitURL": "https://www.example.com/submit",
751 "hostname": "https://www.example.com",
752 "username": "test",
753 "password": "test",
754 }))],
755 );
756
757 let store = LoginStore::new_in_memory();
758 store
759 .lock_db()
760 .unwrap()
761 .delete("remote-readded-redeleted")
762 .unwrap();
763 insert_login(
764 &store.lock_db().unwrap(),
765 "remote-readded-redeleted",
766 Some("password"),
767 None,
768 );
769 store
770 .lock_db()
771 .unwrap()
772 .delete("remote-readded-redeleted")
773 .unwrap();
774 let changeset = run_fetch_outgoing(store);
775 let changes: HashMap<String, serde_json::Value> = changeset
776 .into_iter()
777 .map(|b| {
778 (
779 b.envelope.id.to_string(),
780 serde_json::from_str(&b.payload).unwrap(),
781 )
782 })
783 .collect();
784 assert_eq!(changes.len(), 1);
785 assert!(changes["remote-readded-redeleted"].get("deleted").is_some());
786 }
787
788 #[test]
789 fn test_fetch_outgoing() {
790 ensure_initialized();
791 let store = LoginStore::new_in_memory();
792 insert_login(
793 &store.lock_db().unwrap(),
794 "changed",
795 Some("new-password"),
796 Some("password"),
797 );
798 insert_login(
799 &store.lock_db().unwrap(),
800 "unchanged",
801 None,
802 Some("password"),
803 );
804 insert_login(&store.lock_db().unwrap(), "added", Some("password"), None);
805 insert_login(&store.lock_db().unwrap(), "deleted", None, Some("password"));
806 store.lock_db().unwrap().delete("deleted").unwrap();
807
808 let changeset = run_fetch_outgoing(store);
809 let changes: HashMap<String, serde_json::Value> = changeset
810 .into_iter()
811 .map(|b| {
812 (
813 b.envelope.id.to_string(),
814 serde_json::from_str(&b.payload).unwrap(),
815 )
816 })
817 .collect();
818 assert_eq!(changes.len(), 3);
819 assert_eq!(changes["added"].get("password").unwrap(), "password");
820 assert_eq!(changes["changed"].get("password").unwrap(), "new-password");
821 assert!(changes["deleted"].get("deleted").is_some());
822 assert!(changes["added"].get("deleted").is_none());
823 assert!(changes["changed"].get("deleted").is_none());
824 }
825
826 #[test]
827 fn test_fetch_outgoing_excludes_fxa_credentials() {
828 ensure_initialized();
829 let store = LoginStore::new_in_memory();
830
831 insert_login(&store.lock_db().unwrap(), "normal", Some("password"), None);
833
834 store
836 .add(LoginEntry {
837 origin: FXA_CREDENTIALS_ORIGIN.to_string(),
838 http_realm: Some("Firefox Accounts credentials".to_string()),
839 username: "uid".to_string(),
840 password: "sync-token".to_string(),
841 ..Default::default()
842 })
843 .unwrap();
844
845 let changeset = run_fetch_outgoing(store);
846 let changes: HashMap<String, serde_json::Value> = changeset
847 .into_iter()
848 .map(|b| {
849 (
850 b.envelope.id.to_string(),
851 serde_json::from_str(&b.payload).unwrap(),
852 )
853 })
854 .collect();
855
856 assert!(changes.contains_key("normal"));
859 assert!(changes
860 .values()
861 .all(|payload| payload["hostname"] != FXA_CREDENTIALS_ORIGIN));
862 }
863
864 #[test]
865 fn test_bad_record() {
866 ensure_initialized();
867 let store = LoginStore::new_in_memory();
868 let test_ids = ["dummy_000001", "dummy_000002", "dummy_000003"];
869 for id in test_ids {
870 insert_login(
871 &store.lock_db().unwrap(),
872 id,
873 Some("password"),
874 Some("password"),
875 );
876 }
877 let mut engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
878 engine
879 .mark_as_synchronized(&test_ids, ServerTimestamp::from_millis(100))
880 .unwrap();
881 let (res, telem) = run_fetch_login_data(
882 &mut engine,
883 vec![
884 IncomingBso::new_test_tombstone(Guid::new("dummy_000001")),
885 IncomingBso::from_test_content(serde_json::json!({
887 "id": "dummy_000002",
888 "garbage": "data",
889 "etc": "not a login"
890 })),
891 IncomingBso::from_test_content(serde_json::json!({
893 "id": "dummy_000003",
894 "formSubmitURL": "https://www.example.com/submit",
895 "hostname": "https://www.example.com",
896 "username": "test",
897 "password": "test",
898 })),
899 ],
900 );
901 assert_eq!(telem.get_failed(), 1);
902 assert_eq!(res.len(), 2);
903 assert_eq!(res[0].guid, "dummy_000001");
904 assert_eq!(res[1].guid, "dummy_000003");
905 assert_eq!(engine.fetch_outgoing().unwrap().len(), 0);
906 }
907
908 fn make_enc_login(
909 username: &str,
910 password: &str,
911 fao: Option<String>,
912 realm: Option<String>,
913 ) -> EncryptedLogin {
914 ensure_initialized();
915 let id = Guid::random().to_string();
916 let sec_fields = SecureLoginFields {
917 username: username.into(),
918 password: password.into(),
919 }
920 .encrypt(&*TEST_ENCDEC, &id)
921 .unwrap();
922 EncryptedLogin {
923 meta: LoginMeta {
924 id,
925 ..Default::default()
926 },
927 fields: LoginFields {
928 form_action_origin: fao,
929 http_realm: realm,
930 origin: "http://not-relevant-here.com".into(),
931 ..Default::default()
932 },
933 sec_fields,
934 }
935 }
936
937 #[test]
938 fn find_dupe_login() {
939 ensure_initialized();
940 let store = LoginStore::new_in_memory();
941
942 let to_add = LoginEntry {
943 form_action_origin: Some("https://www.example.com".into()),
944 origin: "http://not-relevant-here.com".into(),
945 username: "test".into(),
946 password: "test".into(),
947 ..Default::default()
948 };
949 let first_id = store.add(to_add).expect("should insert first").id;
950
951 let to_add = LoginEntry {
952 form_action_origin: Some("https://www.example1.com".into()),
953 origin: "http://not-relevant-here.com".into(),
954 username: "test1".into(),
955 password: "test1".into(),
956 ..Default::default()
957 };
958 let second_id = store.add(to_add).expect("should insert second").id;
959
960 let to_add = LoginEntry {
961 http_realm: Some("http://some-realm.com".into()),
962 origin: "http://not-relevant-here.com".into(),
963 username: "test1".into(),
964 password: "test1".into(),
965 ..Default::default()
966 };
967 let no_form_origin_id = store.add(to_add).expect("should insert second").id;
968
969 let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
970
971 let to_find = make_enc_login("test", "test", Some("https://www.example.com".into()), None);
972 assert_eq!(
973 engine
974 .find_dupe_login(&to_find)
975 .expect("should work")
976 .expect("should be Some()")
977 .meta
978 .id,
979 first_id
980 );
981
982 let to_find = make_enc_login(
983 "test",
984 "test",
985 Some("https://something-else.com".into()),
986 None,
987 );
988 assert!(engine
989 .find_dupe_login(&to_find)
990 .expect("should work")
991 .is_none());
992
993 let to_find = make_enc_login(
994 "test1",
995 "test1",
996 Some("https://www.example1.com".into()),
997 None,
998 );
999 assert_eq!(
1000 engine
1001 .find_dupe_login(&to_find)
1002 .expect("should work")
1003 .expect("should be Some()")
1004 .meta
1005 .id,
1006 second_id
1007 );
1008
1009 let to_find = make_enc_login(
1010 "other",
1011 "other",
1012 Some("https://www.example1.com".into()),
1013 None,
1014 );
1015 assert!(engine
1016 .find_dupe_login(&to_find)
1017 .expect("should work")
1018 .is_none());
1019
1020 let to_find = make_enc_login("test1", "test1", None, Some("http://some-realm.com".into()));
1022 assert_eq!(
1023 engine
1024 .find_dupe_login(&to_find)
1025 .expect("should work")
1026 .expect("should be Some()")
1027 .meta
1028 .id,
1029 no_form_origin_id
1030 );
1031 }
1032
1033 #[test]
1034 fn test_roundtrip_unknown() {
1035 ensure_initialized();
1036 fn apply_incoming_payload(engine: &LoginsSyncEngine, payload: serde_json::Value) {
1038 let bso = IncomingBso::from_test_content(payload);
1039 let mut telem = sync15::telemetry::Engine::new(engine.collection_name());
1040 engine.stage_incoming(vec![bso], &mut telem).unwrap();
1041 engine
1042 .apply(ServerTimestamp::from_millis(0), &mut telem)
1043 .unwrap();
1044 }
1045
1046 fn get_outgoing_payload(engine: &LoginsSyncEngine) -> serde_json::Value {
1047 engine
1049 .store
1050 .update(
1051 "dummy_000001",
1052 LoginEntry {
1053 origin: "https://www.example2.com".into(),
1054 http_realm: Some("https://www.example2.com".into()),
1055 username: "test".into(),
1056 password: "test".into(),
1057 ..Default::default()
1058 },
1059 )
1060 .unwrap();
1061 let changeset = engine.fetch_outgoing().unwrap();
1062 assert_eq!(changeset.len(), 1);
1063 serde_json::from_str::<serde_json::Value>(&changeset[0].payload).unwrap()
1064 }
1065
1066 let store = LoginStore::new_in_memory();
1068 let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1069
1070 apply_incoming_payload(
1071 &engine,
1072 serde_json::json!({
1073 "id": "dummy_000001",
1074 "formSubmitURL": "https://www.example.com/submit",
1075 "hostname": "https://www.example.com",
1076 "username": "test",
1077 "password": "test",
1078 "unknown1": "?",
1079 "unknown2": {"sub": "object"},
1080 }),
1081 );
1082
1083 let payload = get_outgoing_payload(&engine);
1084
1085 assert_eq!(payload.get("unknown1").unwrap().as_str().unwrap(), "?");
1087 assert_eq!(
1088 payload.get("unknown2").unwrap(),
1089 &serde_json::json!({"sub": "object"})
1090 );
1091
1092 apply_incoming_payload(
1095 &engine,
1096 serde_json::json!({
1097 "id": "dummy_000001",
1098 "formSubmitURL": "https://www.example.com/submit",
1099 "hostname": "https://www.example.com",
1100 "username": "test",
1101 "password": "test",
1102 "unknown2": 99,
1103 "unknown3": {"something": "else"},
1104 }),
1105 );
1106 let payload = get_outgoing_payload(&engine);
1107 assert!(payload.get("unknown1").is_none());
1109 assert_eq!(payload.get("unknown2").unwrap().as_u64().unwrap(), 99);
1110 assert_eq!(
1111 payload
1112 .get("unknown3")
1113 .unwrap()
1114 .as_object()
1115 .unwrap()
1116 .get("something")
1117 .unwrap()
1118 .as_str()
1119 .unwrap(),
1120 "else"
1121 );
1122 }
1123
1124 fn count(engine: &LoginsSyncEngine, table_name: &str) -> u32 {
1125 ensure_initialized();
1126 let sql = format!("SELECT COUNT(*) FROM {table_name}");
1127 engine
1128 .store
1129 .lock_db()
1130 .unwrap()
1132 .try_query_one(&sql, [], false)
1133 .unwrap()
1134 .unwrap()
1135 }
1136
1137 fn do_test_incoming_with_local_unmirrored_tombstone(local_newer: bool) {
1138 ensure_initialized();
1139 fn apply_incoming_payload(engine: &LoginsSyncEngine, payload: serde_json::Value) {
1140 let bso = IncomingBso::from_test_content(payload);
1141 let mut telem = sync15::telemetry::Engine::new(engine.collection_name());
1142 engine.stage_incoming(vec![bso], &mut telem).unwrap();
1143 engine
1144 .apply(ServerTimestamp::from_millis(0), &mut telem)
1145 .unwrap();
1146 }
1147
1148 let (local_timestamp, remote_timestamp) = if local_newer { (123, 0) } else { (0, 123) };
1150
1151 let store = LoginStore::new_in_memory();
1152 let engine = LoginsSyncEngine::new(Arc::new(store)).unwrap();
1153
1154 apply_incoming_payload(
1156 &engine,
1157 serde_json::json!({
1158 "id": "dummy_000001",
1159 "formSubmitURL": "https://www.example.com/submit",
1160 "hostname": "https://www.example.com",
1161 "username": "test",
1162 "password": "test",
1163 "timePasswordChanged": local_timestamp,
1164 "unknown1": "?",
1165 "unknown2": {"sub": "object"},
1166 }),
1167 );
1168
1169 engine.reset(&EngineSyncAssociation::Disconnected).unwrap();
1171 assert!(engine
1173 .store
1174 .get("dummy_000001")
1175 .expect("should work")
1176 .is_some());
1177
1178 engine.store.delete("dummy_000001").unwrap();
1180 assert!(engine
1181 .store
1182 .get("dummy_000001")
1183 .expect("should work")
1184 .is_none());
1185
1186 assert_eq!(count(&engine, "LoginsL"), 1);
1188 assert_eq!(count(&engine, "LoginsM"), 0);
1189
1190 apply_incoming_payload(
1192 &engine,
1193 serde_json::json!({
1194 "id": "dummy_000001",
1195 "formSubmitURL": "https://www.example.com/submit",
1196 "hostname": "https://www.example.com",
1197 "username": "test",
1198 "password": "test2",
1199 "timePasswordChanged": remote_timestamp,
1200 "unknown1": "?",
1201 "unknown2": {"sub": "object"},
1202 }),
1203 );
1204
1205 assert!(engine
1208 .store
1209 .get("dummy_000001")
1210 .expect("should work")
1211 .is_some());
1212 assert_eq!(count(&engine, "LoginsL"), 0);
1219 assert_eq!(count(&engine, "LoginsM"), 1);
1220 }
1221
1222 #[test]
1223 fn test_incoming_non_mirror_tombstone_local_newer() {
1224 do_test_incoming_with_local_unmirrored_tombstone(true);
1225 }
1226
1227 #[test]
1228 fn test_incoming_non_mirror_tombstone_local_older() {
1229 do_test_incoming_with_local_unmirrored_tombstone(false);
1230 }
1231}