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