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