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            .map_err(|e| SyncError::Api(e.into()))?;
190
191        if revision_ms < 0 {
192            return Err(SyncError::AccountDeleted);
193        }
194
195        Ok(DateTime::<Utc>::from_timestamp_millis(revision_ms)
196            .ok_or(SyncError::InvalidRevisionDate)?
197            > last_sync)
198    }
199
200    async fn update_last_sync(&self, now: DateTime<Utc>) {
201        if let Some(setting) = self.last_sync.as_ref()
202            && let Err(e) = setting.update(now).await
203        {
204            tracing::warn!("Failed to update last sync timestamp: {e}");
205        }
206    }
207
208    /// Run sync handlers for a completed sync operation
209    ///
210    /// Executes two phases sequentially:
211    /// 1. Calls [`SyncHandler::on_sync`] on all handlers with the response
212    /// 2. Calls [`SyncHandler::on_sync_complete`] on all handlers
213    ///
214    /// Stops on first error and returns it immediately.
215    async fn run_handlers(&self, response: &SyncResponseModel) -> Result<(), SyncError> {
216        let handlers = self.sync_handlers.handlers();
217
218        for handler in &handlers {
219            handler
220                .on_sync(response)
221                .await
222                .map_err(SyncError::HandlerFailed)?;
223        }
224
225        for handler in &handlers {
226            handler.on_sync_complete().await;
227        }
228
229        Ok(())
230    }
231
232    /// Run all error handlers for a sync error
233    ///
234    /// All error handlers are called sequentially in registration order.
235    async fn run_error_handlers(&self, error: &SyncError) {
236        for handler in &self.error_handlers.handlers() {
237            handler.on_error(error).await;
238        }
239    }
240
241    /// Performs the actual sync operation with the Bitwarden API
242    async fn perform_sync(&self, input: &SyncRequest) -> Result<SyncResponseModel, SyncError> {
243        let sync = self
244            .api_configurations
245            .api_client
246            .sync_api()
247            .get(input.exclude_subdomains)
248            .await
249            .map_err(|e| SyncError::Api(e.into()))?;
250
251        Ok(sync)
252    }
253}
254
255/// Extension trait to add sync() method to Client
256///
257/// This trait provides a convenient way to create a SyncClient from
258/// a Bitwarden Client instance.
259pub trait SyncClientExt {
260    /// Create a new SyncClient for this client
261    fn sync(&self) -> SyncClient;
262}
263
264impl SyncClientExt for Client {
265    fn sync(&self) -> SyncClient {
266        SyncClient::new(self.clone())
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use std::sync::{Arc, Mutex};
273
274    use chrono::{Duration, Utc};
275
276    use super::*;
277
278    struct TestHandler {
279        name: String,
280        execution_log: Arc<Mutex<Vec<String>>>,
281        should_fail: bool,
282    }
283
284    #[async_trait::async_trait]
285    impl SyncHandler for TestHandler {
286        async fn on_sync(&self, _response: &SyncResponseModel) -> Result<(), SyncHandlerError> {
287            self.execution_log.lock().unwrap().push(self.name.clone());
288            if self.should_fail {
289                Err("Handler failed".into())
290            } else {
291                Ok(())
292            }
293        }
294    }
295
296    struct TestErrorHandler {
297        name: String,
298        error_log: Arc<Mutex<Vec<String>>>,
299    }
300
301    #[async_trait::async_trait]
302    impl SyncErrorHandler for TestErrorHandler {
303        async fn on_error(&self, _error: &SyncError) {
304            self.error_log.lock().unwrap().push(self.name.clone());
305        }
306    }
307
308    /// Helper to create a SyncClient with a mocked API client.
309    fn test_client(api_client: bitwarden_api_api::apis::ApiClient) -> SyncClient {
310        let dummy_config = bitwarden_api_api::Configuration::new(String::new());
311        SyncClient {
312            api_configurations: Arc::new(ApiConfigurations {
313                api_client,
314                identity_client: bitwarden_api_identity::apis::ApiClient::new_mocked(|_| {}),
315                api_config: dummy_config.clone(),
316                identity_config: dummy_config,
317                device_type: bitwarden_core::client::DeviceType::SDK,
318            }),
319            sync_handlers: HandlerRegistry::new(),
320            error_handlers: HandlerRegistry::new(),
321            sync_lock: tokio::sync::Mutex::new(()),
322            last_sync: None,
323        }
324    }
325
326    /// Helper to create a SyncClient with a state-backed last_sync setting.
327    ///
328    /// If `stored_last_sync` is `Some`, it is written into the in-memory repository so
329    /// that subsequent calls to `needs_sync` see it.
330    async fn test_client_with_last_sync(
331        api_client: bitwarden_api_api::apis::ApiClient,
332        stored_last_sync: Option<DateTime<Utc>>,
333    ) -> SyncClient {
334        let repo: Arc<dyn bitwarden_state::repository::Repository<bitwarden_state::SettingItem>> =
335            Arc::new(bitwarden_test::MemoryRepository::<
336                bitwarden_state::SettingItem,
337            >::default());
338        let setting = bitwarden_state::Setting::new(repo, crate::state::LAST_SYNC);
339        if let Some(dt) = stored_last_sync {
340            setting.update(dt).await.expect("pre-populate last_sync");
341        }
342        let mut client = test_client(api_client);
343        client.last_sync = Some(setting);
344        client
345    }
346
347    #[tokio::test]
348    async fn test_handlers_execute_in_registration_order() {
349        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|_| {}));
350        let log = Arc::new(Mutex::new(Vec::new()));
351
352        client.register_sync_handler(Arc::new(TestHandler {
353            name: "first".to_string(),
354            execution_log: log.clone(),
355            should_fail: false,
356        }));
357        client.register_sync_handler(Arc::new(TestHandler {
358            name: "second".to_string(),
359            execution_log: log.clone(),
360            should_fail: false,
361        }));
362        client.register_sync_handler(Arc::new(TestHandler {
363            name: "third".to_string(),
364            execution_log: log.clone(),
365            should_fail: false,
366        }));
367
368        let response = SyncResponseModel::default();
369        client.run_handlers(&response).await.unwrap();
370
371        assert_eq!(
372            *log.lock().unwrap(),
373            vec!["first", "second", "third"],
374            "Handlers should execute in registration order"
375        );
376    }
377
378    #[tokio::test]
379    async fn test_handler_error_stops_subsequent_handlers() {
380        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|_| {}));
381        let log = Arc::new(Mutex::new(Vec::new()));
382
383        client.register_sync_handler(Arc::new(TestHandler {
384            name: "first".to_string(),
385            execution_log: log.clone(),
386            should_fail: false,
387        }));
388        client.register_sync_handler(Arc::new(TestHandler {
389            name: "second".to_string(),
390            execution_log: log.clone(),
391            should_fail: true,
392        }));
393        client.register_sync_handler(Arc::new(TestHandler {
394            name: "third".to_string(),
395            execution_log: log.clone(),
396            should_fail: false,
397        }));
398
399        let response = SyncResponseModel::default();
400        let result = client.run_handlers(&response).await;
401
402        assert!(result.is_err(), "Should return error when handler fails");
403        assert_eq!(
404            *log.lock().unwrap(),
405            vec!["first", "second"],
406            "Third handler should not execute after second handler fails"
407        );
408    }
409
410    #[tokio::test]
411    async fn test_sync_success_calls_handlers_and_returns_response() {
412        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
413            mock.sync_api
414                .expect_get()
415                .returning(|_| Ok(SyncResponseModel::default()));
416        }));
417        let sync_log = Arc::new(Mutex::new(Vec::new()));
418        let error_log = Arc::new(Mutex::new(Vec::new()));
419
420        client.register_sync_handler(Arc::new(TestHandler {
421            name: "handler".to_string(),
422            execution_log: sync_log.clone(),
423            should_fail: false,
424        }));
425        client.register_error_handler(Arc::new(TestErrorHandler {
426            name: "error_handler".to_string(),
427            error_log: error_log.clone(),
428        }));
429
430        let result = client
431            .sync(SyncRequest {
432                force: false,
433                exclude_subdomains: None,
434            })
435            .await;
436
437        assert!(result.is_ok(), "Sync should succeed");
438        assert_eq!(
439            *sync_log.lock().unwrap(),
440            vec!["handler"],
441            "Sync handler should be called on success"
442        );
443        assert!(
444            error_log.lock().unwrap().is_empty(),
445            "Error handlers should not be called on success"
446        );
447    }
448
449    #[tokio::test]
450    async fn test_sync_error_notifies_error_handlers() {
451        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
452            mock.sync_api
453                .expect_get()
454                .returning(|_| Err(std::io::Error::other("test error").into()));
455        }));
456        let error_log = Arc::new(Mutex::new(Vec::new()));
457
458        client.register_error_handler(Arc::new(TestErrorHandler {
459            name: "first".to_string(),
460            error_log: error_log.clone(),
461        }));
462        client.register_error_handler(Arc::new(TestErrorHandler {
463            name: "second".to_string(),
464            error_log: error_log.clone(),
465        }));
466
467        // sync() will fail due to the mocked error, which should trigger all error handlers
468        let result = client
469            .sync(SyncRequest {
470                force: false,
471                exclude_subdomains: None,
472            })
473            .await;
474
475        assert!(result.is_err());
476        assert_eq!(
477            *error_log.lock().unwrap(),
478            vec!["first", "second"],
479            "All error handlers should be called on sync failure"
480        );
481    }
482
483    #[tokio::test]
484    async fn test_first_sync_skips_revision_check() {
485        // Setting exists but has no stored value — revision check must not be called.
486        let client = test_client_with_last_sync(
487            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
488                mock.sync_api
489                    .expect_get()
490                    .returning(|_| Ok(SyncResponseModel::default()));
491                // accounts_api has no expectation — mock panics on unexpected calls
492            }),
493            None,
494        )
495        .await;
496
497        let result = client
498            .sync(SyncRequest {
499                force: false,
500                exclude_subdomains: None,
501            })
502            .await;
503
504        assert!(result.is_ok_and(|v| v));
505    }
506
507    #[tokio::test]
508    async fn test_revision_check_skips_sync_when_up_to_date() {
509        let stored_last_sync = Utc::now();
510        // Server revision is 60 seconds older than our stored last_sync.
511        let server_revision_ms = (stored_last_sync - Duration::seconds(60)).timestamp_millis();
512
513        let sync_log = Arc::new(Mutex::new(Vec::<String>::new()));
514        let sync_log_clone = sync_log.clone();
515
516        let client = test_client_with_last_sync(
517            bitwarden_api_api::apis::ApiClient::new_mocked(move |mock| {
518                mock.accounts_api
519                    .expect_get_account_revision_date()
520                    .returning(move || Ok(server_revision_ms));
521                // sync_api has no expectation — must not be called
522            }),
523            Some(stored_last_sync),
524        )
525        .await;
526
527        client.register_sync_handler(Arc::new(TestHandler {
528            name: "should_not_run".to_string(),
529            execution_log: sync_log_clone,
530            should_fail: false,
531        }));
532
533        let result = client
534            .sync(SyncRequest {
535                force: false,
536                exclude_subdomains: None,
537            })
538            .await;
539
540        assert!(result.is_ok_and(|v| !v), "Expected Ok(false) skip result");
541        assert!(
542            sync_log.lock().unwrap().is_empty(),
543            "Sync handler must not be called on skip"
544        );
545    }
546
547    #[tokio::test]
548    async fn test_force_bypasses_revision_check() {
549        let stored_last_sync = Utc::now();
550
551        let client = test_client_with_last_sync(
552            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
553                mock.sync_api
554                    .expect_get()
555                    .returning(|_| Ok(SyncResponseModel::default()));
556                // accounts_api has no expectation
557            }),
558            Some(stored_last_sync),
559        )
560        .await;
561
562        let result = client
563            .sync(SyncRequest {
564                force: true,
565                exclude_subdomains: None,
566            })
567            .await;
568
569        assert!(result.is_ok_and(|v| v));
570    }
571
572    #[tokio::test]
573    async fn test_account_deleted_error() {
574        let stored_last_sync = Utc::now();
575        let error_log = Arc::new(Mutex::new(Vec::<String>::new()));
576        let error_log_clone = error_log.clone();
577
578        let client = test_client_with_last_sync(
579            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
580                mock.accounts_api
581                    .expect_get_account_revision_date()
582                    .returning(|| Ok(-1i64));
583            }),
584            Some(stored_last_sync),
585        )
586        .await;
587
588        client.register_error_handler(Arc::new(TestErrorHandler {
589            name: "error_handler".to_string(),
590            error_log: error_log_clone,
591        }));
592
593        let result = client
594            .sync(SyncRequest {
595                force: false,
596                exclude_subdomains: None,
597            })
598            .await;
599
600        assert!(
601            matches!(result, Err(SyncError::AccountDeleted)),
602            "Expected AccountDeleted error"
603        );
604        assert_eq!(
605            *error_log.lock().unwrap(),
606            vec!["error_handler"],
607            "Error handler must be called for AccountDeleted"
608        );
609    }
610
611    #[tokio::test]
612    async fn test_revision_fetch_failure_does_not_bump_last_sync() {
613        let stored_last_sync =
614            DateTime::<Utc>::from_timestamp_millis(1_000_000).expect("valid timestamp");
615        let error_log = Arc::new(Mutex::new(Vec::<String>::new()));
616        let error_log_clone = error_log.clone();
617
618        // Build Setting manually so we can inspect it after sync.
619        let repo: Arc<dyn bitwarden_state::repository::Repository<bitwarden_state::SettingItem>> =
620            Arc::new(bitwarden_test::MemoryRepository::<
621                bitwarden_state::SettingItem,
622            >::default());
623        let setting = bitwarden_state::Setting::new(repo, crate::state::LAST_SYNC);
624        setting
625            .update(stored_last_sync)
626            .await
627            .expect("pre-populate last_sync");
628
629        let mut client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
630            mock.accounts_api
631                .expect_get_account_revision_date()
632                .returning(|| Err(std::io::Error::other("network error").into()));
633        }));
634        client.last_sync = Some(setting.clone());
635
636        client.register_error_handler(Arc::new(TestErrorHandler {
637            name: "error_handler".to_string(),
638            error_log: error_log_clone,
639        }));
640
641        let result = client
642            .sync(SyncRequest {
643                force: false,
644                exclude_subdomains: None,
645            })
646            .await;
647
648        assert!(
649            result.is_err(),
650            "Expected error from revision fetch failure"
651        );
652        assert_eq!(
653            setting.get().await.unwrap(),
654            Some(stored_last_sync),
655            "last_sync must not be bumped on error"
656        );
657        assert!(
658            !error_log.lock().unwrap().is_empty(),
659            "Error handler must be called"
660        );
661    }
662}