logins/sync/
bridge.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use crate::sync::engine::LoginsSyncEngine;
6use crate::LoginStore;
7use anyhow::Result;
8use std::sync::Arc;
9use sync15::bso::{IncomingBso, OutgoingBso};
10use sync15::engine::{BridgedEngine, BridgedEngineAdaptor};
11use sync15::ServerTimestamp;
12use sync_guid::Guid as SyncGuid;
13
14impl LoginStore {
15    /// Returns a bridged sync engine for Desktop for this store.
16    ///
17    /// Unlike Tabs, constructing a `LoginsSyncEngine` locks the DB and can
18    /// fail, so this is fallible (and exposed as `[Throws]` in the UDL). The
19    /// internal error is surfaced via `anyhow`, which UniFFI maps onto
20    /// `LoginsApiError` through `From<anyhow::Error>`.
21    pub fn bridged_engine(self: Arc<Self>) -> Result<Arc<LoginsBridgedEngine>> {
22        let engine = LoginsSyncEngine::new(self)?;
23        let bridged_engine = LoginsBridgedEngineAdaptor { engine };
24        Ok(Arc::new(LoginsBridgedEngine::new(Box::new(bridged_engine))))
25    }
26}
27
28/// `LoginsSyncEngine` only implements the internal `sync15::SyncEngine` trait,
29/// which is what the mobile (Android/iOS) sync manager drives. Desktop's Sync
30/// framework instead speaks the `mozIBridgedSyncEngine` interface, whose Rust
31/// shape is `sync15::BridgedEngine`. This adaptor wraps our `SyncEngine` and,
32/// via the blanket `impl<A: BridgedEngineAdaptor> BridgedEngine for A`, gives
33/// us a `BridgedEngine` for free. The adaptor exists only because these two
34/// sync-engine traits still live side by side; it can go away if they're ever
35/// unified.
36struct LoginsBridgedEngineAdaptor {
37    engine: LoginsSyncEngine,
38}
39
40/// see sync15/src/engine/bridged_engine.rs for required functions for the trait
41impl BridgedEngineAdaptor for LoginsBridgedEngineAdaptor {
42    fn last_sync(&self) -> Result<i64> {
43        // `get_last_sync` takes the `&LoginDb` to avoid deadlocking when called
44        // mid-sync (while the lock is already held). The bridge methods are
45        // always called outside a sync transaction, so we can lock here.
46        let db = self.engine.store.lock_db()?;
47        Ok(self
48            .engine
49            .get_last_sync(&db)?
50            .unwrap_or_default()
51            .as_millis())
52    }
53
54    fn set_last_sync(&self, last_sync_millis: i64) -> Result<()> {
55        let db = self.engine.store.lock_db()?;
56        self.engine
57            .set_last_sync(&db, ServerTimestamp::from_millis(last_sync_millis))?;
58        Ok(())
59    }
60
61    fn engine(&self) -> &dyn sync15::engine::SyncEngine {
62        &self.engine
63    }
64}
65
66// This is what UniFFI exposes; it does nothing other than delegate back to the
67// `BridgedEngine` trait object (and handle the JSON (de)serialization of BSOs
68// that crosses the FFI boundary).
69/// see services/interfaces/mozIBridgedSyncEngine.idl for contract
70pub struct LoginsBridgedEngine {
71    bridge_impl: Box<dyn BridgedEngine>,
72}
73
74impl LoginsBridgedEngine {
75    pub fn new(bridge_impl: Box<dyn BridgedEngine>) -> Self {
76        Self { bridge_impl }
77    }
78
79    pub fn last_sync(&self) -> Result<i64> {
80        self.bridge_impl.last_sync()
81    }
82
83    pub fn set_last_sync(&self, last_sync: i64) -> Result<()> {
84        self.bridge_impl.set_last_sync(last_sync)
85    }
86
87    pub fn sync_id(&self) -> Result<Option<String>> {
88        self.bridge_impl.sync_id()
89    }
90
91    pub fn reset_sync_id(&self) -> Result<String> {
92        self.bridge_impl.reset_sync_id()
93    }
94
95    pub fn ensure_current_sync_id(&self, sync_id: &str) -> Result<String> {
96        self.bridge_impl.ensure_current_sync_id(sync_id)
97    }
98
99    pub fn sync_started(&self) -> Result<()> {
100        self.bridge_impl.sync_started()
101    }
102
103    // Decode the JSON-encoded IncomingBso's that UniFFI passes to us
104    fn convert_incoming_bsos(&self, incoming: Vec<String>) -> Result<Vec<IncomingBso>> {
105        let mut bsos = Vec::with_capacity(incoming.len());
106        for inc in incoming {
107            bsos.push(serde_json::from_str::<IncomingBso>(&inc)?);
108        }
109        Ok(bsos)
110    }
111
112    // Encode OutgoingBso's into JSON for UniFFI
113    fn convert_outgoing_bsos(&self, outgoing: Vec<OutgoingBso>) -> Result<Vec<String>> {
114        let mut bsos = Vec::with_capacity(outgoing.len());
115        for e in outgoing {
116            bsos.push(serde_json::to_string(&e)?);
117        }
118        Ok(bsos)
119    }
120
121    pub fn store_incoming(&self, incoming: Vec<String>) -> Result<()> {
122        self.bridge_impl
123            .store_incoming(self.convert_incoming_bsos(incoming)?)
124    }
125
126    pub fn apply(&self) -> Result<Vec<String>> {
127        let apply_results = self.bridge_impl.apply()?;
128        self.convert_outgoing_bsos(apply_results.records)
129    }
130
131    pub fn set_uploaded(&self, server_modified_millis: i64, guids: Vec<String>) -> Result<()> {
132        // UniFFI hands us plain strings; the bridge works in terms of `Guid`.
133        let guids: Vec<SyncGuid> = guids.into_iter().map(SyncGuid::from).collect();
134        self.bridge_impl
135            .set_uploaded(server_modified_millis, &guids)
136    }
137
138    pub fn sync_finished(&self) -> Result<()> {
139        self.bridge_impl.sync_finished()
140    }
141
142    pub fn reset(&self) -> Result<()> {
143        self.bridge_impl.reset()
144    }
145
146    pub fn wipe(&self) -> Result<()> {
147        self.bridge_impl.wipe()
148    }
149}
150
151#[cfg(not(feature = "keydb"))]
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::db::test_utils::insert_login;
156    use nss_as::ensure_initialized;
157    use std::collections::HashMap;
158
159    // Exercises the sync-metadata plumbing (last_sync / sync_id / reset) that
160    // Desktop's Sync framework drives through the bridge, mirroring the Tabs
161    // `test_sync_meta` test.
162    #[test]
163    fn test_sync_meta() {
164        ensure_initialized();
165        error_support::init_for_tests();
166
167        let store = Arc::new(LoginStore::new_in_memory());
168        let bridge = store.bridged_engine().expect("should create bridge");
169
170        // Fresh DB: never synced.
171        assert_eq!(bridge.last_sync().unwrap(), 0);
172        bridge.set_last_sync(3).unwrap();
173        assert_eq!(bridge.last_sync().unwrap(), 3);
174
175        assert!(bridge.sync_id().unwrap().is_none());
176
177        bridge.ensure_current_sync_id("some_guid").unwrap();
178        assert_eq!(bridge.sync_id().unwrap(), Some("some_guid".to_string()));
179        // changing the sync ID should reset the timestamp
180        assert_eq!(bridge.last_sync().unwrap(), 0);
181        bridge.set_last_sync(3).unwrap();
182
183        bridge.reset_sync_id().unwrap();
184        // should now be a random guid.
185        assert_ne!(bridge.sync_id().unwrap(), Some("some_guid".to_string()));
186        // should have reset the last sync timestamp.
187        assert_eq!(bridge.last_sync().unwrap(), 0);
188        bridge.set_last_sync(3).unwrap();
189
190        // `reset` clears the guid and the timestamp
191        bridge.reset().unwrap();
192        assert_eq!(bridge.last_sync().unwrap(), 0);
193        assert!(bridge.sync_id().unwrap().is_none());
194    }
195
196    // A roundtrip through the bridge's data path: stage an incoming remote
197    // login, apply it, and confirm the local-only login comes back out for
198    // upload. Unlike `test_sync_meta`, this exercises the JSON (de)serialization
199    // of BSOs and the staged-incoming `Mutex`. Mirrors the Tabs
200    // `test_sync_via_bridge` test.
201    #[test]
202    fn test_sync_via_bridge() {
203        ensure_initialized();
204        error_support::init_for_tests();
205
206        let store = Arc::new(LoginStore::new_in_memory());
207
208        // A local-only login: nothing on the server knows about it yet, so it
209        // should be uploaded.
210        insert_login(
211            &store.lock_db().unwrap(),
212            "local-only-aaaa",
213            Some("local-password"),
214            None,
215        );
216
217        let bridge = store
218            .clone()
219            .bridged_engine()
220            .expect("should create bridge");
221
222        bridge.sync_started().unwrap();
223
224        // An incoming remote login that isn't known locally. We build the
225        // envelope as raw JSON, exactly as the JS bridge hands it to us.
226        let incoming = vec![serde_json::json!({
227            "id": "remote-only-bbbb",
228            "modified": 0,
229            "payload": serde_json::json!({
230                "id": "remote-only-bbbb",
231                "hostname": "https://remote.example.com",
232                "formSubmitURL": "https://remote.example.com",
233                "username": "remote-user",
234                "password": "remote-password",
235            })
236            .to_string(),
237        })
238        .to_string()];
239        bridge
240            .store_incoming(incoming)
241            .expect("should store incoming");
242
243        // Applying stores the remote record locally and returns the local-only
244        // login for upload.
245        let outgoing = bridge.apply().expect("should apply");
246        let changes: HashMap<String, serde_json::Value> = outgoing
247            .into_iter()
248            .map(|s| {
249                let bso: serde_json::Value = serde_json::from_str(&s).unwrap();
250                let payload: serde_json::Value =
251                    serde_json::from_str(bso["payload"].as_str().unwrap()).unwrap();
252                (payload["id"].as_str().unwrap().to_string(), payload)
253            })
254            .collect();
255
256        // Only the local login is outgoing; the just-applied remote one is not
257        // re-uploaded.
258        assert_eq!(changes.len(), 1);
259        assert_eq!(changes["local-only-aaaa"]["password"], "local-password");
260
261        // The incoming remote login was actually persisted.
262        let stored = store
263            .get("remote-only-bbbb")
264            .unwrap()
265            .expect("remote login should have been stored");
266        assert_eq!(stored.password, "remote-password");
267
268        // Acknowledging the upload advances last_sync.
269        bridge
270            .set_uploaded(1234, vec!["local-only-aaaa".to_string()])
271            .unwrap();
272        bridge.sync_finished().unwrap();
273        assert_eq!(bridge.last_sync().unwrap(), 1234);
274    }
275}