Skip to main content

bitwarden_sync/
sync_client.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::SyncResponseModel;
4use bitwarden_core::{
5    Client,
6    client::{ApiConfigurations, FromClientPart},
7};
8use bitwarden_state::Setting;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use tokio::sync::Mutex;
13
14use crate::{
15    SyncErrorHandler, SyncHandler, SyncHandlerError, registry::HandlerRegistry, state::LAST_SYNC,
16};
17
18#[allow(missing_docs)]
19#[derive(Debug, Error)]
20pub enum SyncError {
21    #[error(transparent)]
22    Api(#[from] bitwarden_core::ApiError),
23
24    #[error("Sync event handler failed: {0}")]
25    HandlerFailed(#[source] SyncHandlerError),
26
27    #[error("Account has been deleted on the server.")]
28    AccountDeleted,
29
30    #[error(transparent)]
31    Setting(#[from] bitwarden_state::SettingsError),
32
33    #[error("Server returned an unrepresentable revision date.")]
34    InvalidRevisionDate,
35}
36
37#[allow(missing_docs)]
38#[derive(Serialize, Deserialize, Debug, Clone)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct SyncRequest {
41    /// Skip the revision-date check and always sync.
42    #[serde(default)]
43    pub force: bool,
44    /// Exclude the subdomains from the response, defaults to false
45    pub exclude_subdomains: Option<bool>,
46}
47
48/// Client for performing sync operations with event support
49///
50/// This client wraps the core sync functionality and provides hooks
51/// for registering event handlers that can respond to sync operations.
52pub struct SyncClient {
53    api_configurations: Arc<ApiConfigurations>,
54    sync_handlers: HandlerRegistry<dyn SyncHandler>,
55    error_handlers: HandlerRegistry<dyn SyncErrorHandler>,
56    sync_lock: Mutex<()>,
57    last_sync: Option<Setting<DateTime<Utc>>>,
58}
59
60impl SyncClient {
61    /// Create a new SyncClient from a Bitwarden client
62    pub fn new(client: Client) -> Self {
63        Self {
64            api_configurations: client.get_part(),
65            sync_handlers: HandlerRegistry::new(),
66            error_handlers: HandlerRegistry::new(),
67            sync_lock: Mutex::new(()),
68            last_sync: client.platform().state().setting(LAST_SYNC).ok(),
69        }
70    }
71
72    /// Get the timestamp of the last successful sync or confirmed-up-to-date skip, if any.
73    pub async fn last_sync(&self) -> Option<DateTime<Utc>> {
74        match self.last_sync.as_ref()?.get().await {
75            Ok(value) => value,
76            Err(e) => {
77                tracing::warn!("Failed to read last sync timestamp: {e}");
78                None
79            }
80        }
81    }
82
83    /// Register a sync handler for sync operations
84    ///
85    /// Handlers are called in registration order. If any handler returns an error,
86    /// the sync operation is aborted immediately and subsequent handlers are not called.
87    pub fn register_sync_handler(&self, handler: Arc<dyn SyncHandler>) {
88        self.sync_handlers.register(handler);
89    }
90
91    /// Register an error handler for sync operations
92    ///
93    /// Error handlers are called when any error occurs during sync, including
94    /// API errors and handler errors. All error handlers are always called
95    /// regardless of individual failures.
96    pub fn register_error_handler(&self, handler: Arc<dyn SyncErrorHandler>) {
97        self.error_handlers.register(handler);
98    }
99
100    /// Perform a sync operation, skipping the server call when nothing has changed.
101    ///
102    /// Returns `Ok(true)` if a full sync was performed, or `Ok(false)` if the revision-date
103    /// check determined that the server has no new changes and the sync was skipped.
104    ///
105    /// ## Control flow
106    ///
107    /// Unless `request.force` is `true`, this method first fetches the account revision date
108    /// from the server and compares it to the stored `last_sync` timestamp. If the revision
109    /// is not newer, the sync is skipped: `last_sync` is bumped to now and `Ok(false)` is
110    /// returned without calling any sync or error handlers.
111    ///
112    /// When a full sync is performed:
113    /// 1. Fetches the full sync response from the Bitwarden API.
114    /// 2. Dispatches `on_sync` with the response to all registered handlers in order; stops on the
115    ///    first handler error.
116    /// 3. Dispatches `on_sync_complete` to all handlers for post-processing.
117    /// 4. On success, bumps `last_sync` to now and returns `Ok(true)`.
118    ///
119    /// ## Errors
120    ///
121    /// Any error (revision-date fetch, API call, or handler failure) is forwarded to all
122    /// registered error handlers before being returned to the caller. `last_sync` is never
123    /// bumped on an error path.
124    pub async fn sync(&self, request: SyncRequest) -> Result<bool, SyncError> {
125        // Wait for any in-progress sync to complete before starting a new one
126        let _guard = self.sync_lock.lock().await;
127
128        // Capture the sync start time before any server interaction. Using the start time
129        // (not the finish time) as last_sync guarantees that any server-side change committed
130        // during the sync window has a revision date strictly greater than last_sync, so the
131        // next needs_sync check will pick it up. Matches the Node CLI pattern:
132        // `const now = new Date()` before `needsSyncing()`.
133        let sync_start = Utc::now();
134
135        let needs_sync = if request.force {
136            true
137        } else {
138            match self.needs_sync().await {
139                Ok(needed) => needed,
140                Err(e) => {
141                    self.run_error_handlers(&e).await;
142                    return Err(e);
143                }
144            }
145        };
146
147        if !needs_sync {
148            // Persistent clock-skew note: if the local clock is permanently ahead of the
149            // server, every revision check will say "no sync needed" and we keep bumping
150            // lastSync to a future-skewed `now` — server-side changes are never picked up
151            // until the clock is corrected. Matches Node CLI behaviour.
152            self.update_last_sync(sync_start).await;
153            return Ok(false);
154        }
155
156        let result = async {
157            let response = self.perform_sync(&request).await?;
158            self.run_handlers(&response).await?;
159            Ok(response)
160        }
161        .await;
162
163        match result {
164            Ok(_) => {
165                self.update_last_sync(sync_start).await;
166                Ok(true)
167            }
168            Err(error) => {
169                self.run_error_handlers(&error).await;
170                Err(error)
171            }
172        }
173    }
174
175    async fn needs_sync(&self) -> Result<bool, SyncError> {
176        let Some(last_sync_setting) = self.last_sync.as_ref() else {
177            return Ok(true); // No state backend — always sync
178        };
179        let Some(last_sync) = last_sync_setting.get().await? else {
180            return Ok(true); // First sync — skip revision check
181        };
182
183        let revision_ms = self
184            .api_configurations
185            .api_client
186            .accounts_api()
187            .get_account_revision_date()
188            .await?;
189
190        if revision_ms < 0 {
191            return Err(SyncError::AccountDeleted);
192        }
193
194        Ok(DateTime::<Utc>::from_timestamp_millis(revision_ms)
195            .ok_or(SyncError::InvalidRevisionDate)?
196            > last_sync)
197    }
198
199    async fn update_last_sync(&self, now: DateTime<Utc>) {
200        if let Some(setting) = self.last_sync.as_ref()
201            && let Err(e) = setting.update(now).await
202        {
203            tracing::warn!("Failed to update last sync timestamp: {e}");
204        }
205    }
206
207    /// Run sync handlers for a completed sync operation
208    ///
209    /// Executes two phases sequentially:
210    /// 1. Calls [`SyncHandler::on_sync`] on all handlers with the response
211    /// 2. Calls [`SyncHandler::on_sync_complete`] on all handlers
212    ///
213    /// Stops on first error and returns it immediately.
214    async fn run_handlers(&self, response: &SyncResponseModel) -> Result<(), SyncError> {
215        let handlers = self.sync_handlers.handlers();
216
217        for handler in &handlers {
218            handler
219                .on_sync(response)
220                .await
221                .map_err(SyncError::HandlerFailed)?;
222        }
223
224        for handler in &handlers {
225            handler.on_sync_complete().await;
226        }
227
228        Ok(())
229    }
230
231    /// Run all error handlers for a sync error
232    ///
233    /// All error handlers are called sequentially in registration order.
234    async fn run_error_handlers(&self, error: &SyncError) {
235        for handler in &self.error_handlers.handlers() {
236            handler.on_error(error).await;
237        }
238    }
239
240    /// Performs the actual sync operation with the Bitwarden API
241    async fn perform_sync(&self, input: &SyncRequest) -> Result<SyncResponseModel, SyncError> {
242        let sync = self
243            .api_configurations
244            .api_client
245            .sync_api()
246            .get(input.exclude_subdomains)
247            .await?;
248
249        Ok(sync)
250    }
251}
252
253/// Extension trait to add sync() method to Client
254///
255/// This trait provides a convenient way to create a SyncClient from
256/// a Bitwarden Client instance.
257pub trait SyncClientExt {
258    /// Create a new SyncClient for this client
259    fn sync(&self) -> SyncClient;
260}
261
262impl SyncClientExt for Client {
263    fn sync(&self) -> SyncClient {
264        SyncClient::new(self.clone())
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use std::sync::{Arc, Mutex};
271
272    use chrono::{Duration, Utc};
273
274    use super::*;
275
276    struct TestHandler {
277        name: String,
278        execution_log: Arc<Mutex<Vec<String>>>,
279        should_fail: bool,
280    }
281
282    #[async_trait::async_trait]
283    impl SyncHandler for TestHandler {
284        async fn on_sync(&self, _response: &SyncResponseModel) -> Result<(), SyncHandlerError> {
285            self.execution_log.lock().unwrap().push(self.name.clone());
286            if self.should_fail {
287                Err("Handler failed".into())
288            } else {
289                Ok(())
290            }
291        }
292    }
293
294    struct TestErrorHandler {
295        name: String,
296        error_log: Arc<Mutex<Vec<String>>>,
297    }
298
299    #[async_trait::async_trait]
300    impl SyncErrorHandler for TestErrorHandler {
301        async fn on_error(&self, _error: &SyncError) {
302            self.error_log.lock().unwrap().push(self.name.clone());
303        }
304    }
305
306    /// Helper to create a SyncClient with a mocked API client.
307    fn test_client(api_client: bitwarden_api_api::apis::ApiClient) -> SyncClient {
308        let dummy_config = bitwarden_api_api::Configuration::new(String::new());
309        SyncClient {
310            api_configurations: Arc::new(ApiConfigurations {
311                api_client,
312                identity_client: bitwarden_api_identity::apis::ApiClient::new_mocked(|_| {}),
313                api_config: dummy_config.clone(),
314                identity_config: dummy_config,
315                device_type: bitwarden_core::client::DeviceType::SDK,
316            }),
317            sync_handlers: HandlerRegistry::new(),
318            error_handlers: HandlerRegistry::new(),
319            sync_lock: tokio::sync::Mutex::new(()),
320            last_sync: None,
321        }
322    }
323
324    /// Helper to create a SyncClient with a state-backed last_sync setting.
325    ///
326    /// If `stored_last_sync` is `Some`, it is written into the in-memory setting so
327    /// that subsequent calls to `needs_sync` see it.
328    async fn test_client_with_last_sync(
329        api_client: bitwarden_api_api::apis::ApiClient,
330        stored_last_sync: Option<DateTime<Utc>>,
331    ) -> SyncClient {
332        let setting = bitwarden_test::MemorySetting::create();
333        if let Some(dt) = stored_last_sync {
334            setting.update(dt).await.expect("pre-populate last_sync");
335        }
336        let mut client = test_client(api_client);
337        client.last_sync = Some(setting);
338        client
339    }
340
341    #[tokio::test]
342    async fn test_handlers_execute_in_registration_order() {
343        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|_| {}));
344        let log = Arc::new(Mutex::new(Vec::new()));
345
346        client.register_sync_handler(Arc::new(TestHandler {
347            name: "first".to_string(),
348            execution_log: log.clone(),
349            should_fail: false,
350        }));
351        client.register_sync_handler(Arc::new(TestHandler {
352            name: "second".to_string(),
353            execution_log: log.clone(),
354            should_fail: false,
355        }));
356        client.register_sync_handler(Arc::new(TestHandler {
357            name: "third".to_string(),
358            execution_log: log.clone(),
359            should_fail: false,
360        }));
361
362        let response = SyncResponseModel::default();
363        client.run_handlers(&response).await.unwrap();
364
365        assert_eq!(
366            *log.lock().unwrap(),
367            vec!["first", "second", "third"],
368            "Handlers should execute in registration order"
369        );
370    }
371
372    #[tokio::test]
373    async fn test_handler_error_stops_subsequent_handlers() {
374        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|_| {}));
375        let log = Arc::new(Mutex::new(Vec::new()));
376
377        client.register_sync_handler(Arc::new(TestHandler {
378            name: "first".to_string(),
379            execution_log: log.clone(),
380            should_fail: false,
381        }));
382        client.register_sync_handler(Arc::new(TestHandler {
383            name: "second".to_string(),
384            execution_log: log.clone(),
385            should_fail: true,
386        }));
387        client.register_sync_handler(Arc::new(TestHandler {
388            name: "third".to_string(),
389            execution_log: log.clone(),
390            should_fail: false,
391        }));
392
393        let response = SyncResponseModel::default();
394        let result = client.run_handlers(&response).await;
395
396        assert!(result.is_err(), "Should return error when handler fails");
397        assert_eq!(
398            *log.lock().unwrap(),
399            vec!["first", "second"],
400            "Third handler should not execute after second handler fails"
401        );
402    }
403
404    #[tokio::test]
405    async fn test_sync_success_calls_handlers_and_returns_response() {
406        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
407            mock.sync_api
408                .expect_get()
409                .returning(|_| Ok(SyncResponseModel::default()));
410        }));
411        let sync_log = Arc::new(Mutex::new(Vec::new()));
412        let error_log = Arc::new(Mutex::new(Vec::new()));
413
414        client.register_sync_handler(Arc::new(TestHandler {
415            name: "handler".to_string(),
416            execution_log: sync_log.clone(),
417            should_fail: false,
418        }));
419        client.register_error_handler(Arc::new(TestErrorHandler {
420            name: "error_handler".to_string(),
421            error_log: error_log.clone(),
422        }));
423
424        let result = client
425            .sync(SyncRequest {
426                force: false,
427                exclude_subdomains: None,
428            })
429            .await;
430
431        assert!(result.is_ok(), "Sync should succeed");
432        assert_eq!(
433            *sync_log.lock().unwrap(),
434            vec!["handler"],
435            "Sync handler should be called on success"
436        );
437        assert!(
438            error_log.lock().unwrap().is_empty(),
439            "Error handlers should not be called on success"
440        );
441    }
442
443    #[tokio::test]
444    async fn test_sync_error_notifies_error_handlers() {
445        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
446            mock.sync_api
447                .expect_get()
448                .returning(|_| Err(std::io::Error::other("test error").into()));
449        }));
450        let error_log = Arc::new(Mutex::new(Vec::new()));
451
452        client.register_error_handler(Arc::new(TestErrorHandler {
453            name: "first".to_string(),
454            error_log: error_log.clone(),
455        }));
456        client.register_error_handler(Arc::new(TestErrorHandler {
457            name: "second".to_string(),
458            error_log: error_log.clone(),
459        }));
460
461        // sync() will fail due to the mocked error, which should trigger all error handlers
462        let result = client
463            .sync(SyncRequest {
464                force: false,
465                exclude_subdomains: None,
466            })
467            .await;
468
469        assert!(result.is_err());
470        assert_eq!(
471            *error_log.lock().unwrap(),
472            vec!["first", "second"],
473            "All error handlers should be called on sync failure"
474        );
475    }
476
477    #[tokio::test]
478    async fn test_first_sync_skips_revision_check() {
479        // Setting exists but has no stored value — revision check must not be called.
480        let client = test_client_with_last_sync(
481            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
482                mock.sync_api
483                    .expect_get()
484                    .returning(|_| Ok(SyncResponseModel::default()));
485                // accounts_api has no expectation — mock panics on unexpected calls
486            }),
487            None,
488        )
489        .await;
490
491        let result = client
492            .sync(SyncRequest {
493                force: false,
494                exclude_subdomains: None,
495            })
496            .await;
497
498        assert!(result.is_ok_and(|v| v));
499    }
500
501    #[tokio::test]
502    async fn test_revision_check_skips_sync_when_up_to_date() {
503        let stored_last_sync = Utc::now();
504        // Server revision is 60 seconds older than our stored last_sync.
505        let server_revision_ms = (stored_last_sync - Duration::seconds(60)).timestamp_millis();
506
507        let sync_log = Arc::new(Mutex::new(Vec::<String>::new()));
508        let sync_log_clone = sync_log.clone();
509
510        let client = test_client_with_last_sync(
511            bitwarden_api_api::apis::ApiClient::new_mocked(move |mock| {
512                mock.accounts_api
513                    .expect_get_account_revision_date()
514                    .returning(move || Ok(server_revision_ms));
515                // sync_api has no expectation — must not be called
516            }),
517            Some(stored_last_sync),
518        )
519        .await;
520
521        client.register_sync_handler(Arc::new(TestHandler {
522            name: "should_not_run".to_string(),
523            execution_log: sync_log_clone,
524            should_fail: false,
525        }));
526
527        let result = client
528            .sync(SyncRequest {
529                force: false,
530                exclude_subdomains: None,
531            })
532            .await;
533
534        assert!(result.is_ok_and(|v| !v), "Expected Ok(false) skip result");
535        assert!(
536            sync_log.lock().unwrap().is_empty(),
537            "Sync handler must not be called on skip"
538        );
539    }
540
541    #[tokio::test]
542    async fn test_force_bypasses_revision_check() {
543        let stored_last_sync = Utc::now();
544
545        let client = test_client_with_last_sync(
546            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
547                mock.sync_api
548                    .expect_get()
549                    .returning(|_| Ok(SyncResponseModel::default()));
550                // accounts_api has no expectation
551            }),
552            Some(stored_last_sync),
553        )
554        .await;
555
556        let result = client
557            .sync(SyncRequest {
558                force: true,
559                exclude_subdomains: None,
560            })
561            .await;
562
563        assert!(result.is_ok_and(|v| v));
564    }
565
566    #[tokio::test]
567    async fn test_account_deleted_error() {
568        let stored_last_sync = Utc::now();
569        let error_log = Arc::new(Mutex::new(Vec::<String>::new()));
570        let error_log_clone = error_log.clone();
571
572        let client = test_client_with_last_sync(
573            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
574                mock.accounts_api
575                    .expect_get_account_revision_date()
576                    .returning(|| Ok(-1i64));
577            }),
578            Some(stored_last_sync),
579        )
580        .await;
581
582        client.register_error_handler(Arc::new(TestErrorHandler {
583            name: "error_handler".to_string(),
584            error_log: error_log_clone,
585        }));
586
587        let result = client
588            .sync(SyncRequest {
589                force: false,
590                exclude_subdomains: None,
591            })
592            .await;
593
594        assert!(
595            matches!(result, Err(SyncError::AccountDeleted)),
596            "Expected AccountDeleted error"
597        );
598        assert_eq!(
599            *error_log.lock().unwrap(),
600            vec!["error_handler"],
601            "Error handler must be called for AccountDeleted"
602        );
603    }
604
605    #[tokio::test]
606    async fn test_revision_fetch_failure_does_not_bump_last_sync() {
607        let stored_last_sync =
608            DateTime::<Utc>::from_timestamp_millis(1_000_000).expect("valid timestamp");
609        let error_log = Arc::new(Mutex::new(Vec::<String>::new()));
610        let error_log_clone = error_log.clone();
611
612        // Build Setting manually so we can inspect it after sync.
613        let setting = bitwarden_test::MemorySetting::create();
614        setting
615            .update(stored_last_sync)
616            .await
617            .expect("pre-populate last_sync");
618
619        let mut client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
620            mock.accounts_api
621                .expect_get_account_revision_date()
622                .returning(|| Err(std::io::Error::other("network error").into()));
623        }));
624        client.last_sync = Some(setting.clone());
625
626        client.register_error_handler(Arc::new(TestErrorHandler {
627            name: "error_handler".to_string(),
628            error_log: error_log_clone,
629        }));
630
631        let result = client
632            .sync(SyncRequest {
633                force: false,
634                exclude_subdomains: None,
635            })
636            .await;
637
638        assert!(
639            result.is_err(),
640            "Expected error from revision fetch failure"
641        );
642        assert_eq!(
643            setting.get().await.unwrap(),
644            Some(stored_last_sync),
645            "last_sync must not be bumped on error"
646        );
647        assert!(
648            !error_log.lock().unwrap().is_empty(),
649            "Error handler must be called"
650        );
651    }
652}