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