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 repository 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 repo: Arc<dyn bitwarden_state::repository::Repository<bitwarden_state::SettingItem>> =
333            Arc::new(bitwarden_test::MemoryRepository::<
334                bitwarden_state::SettingItem,
335            >::default());
336        let setting = bitwarden_state::Setting::new(repo, crate::state::LAST_SYNC);
337        if let Some(dt) = stored_last_sync {
338            setting.update(dt).await.expect("pre-populate last_sync");
339        }
340        let mut client = test_client(api_client);
341        client.last_sync = Some(setting);
342        client
343    }
344
345    #[tokio::test]
346    async fn test_handlers_execute_in_registration_order() {
347        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|_| {}));
348        let log = Arc::new(Mutex::new(Vec::new()));
349
350        client.register_sync_handler(Arc::new(TestHandler {
351            name: "first".to_string(),
352            execution_log: log.clone(),
353            should_fail: false,
354        }));
355        client.register_sync_handler(Arc::new(TestHandler {
356            name: "second".to_string(),
357            execution_log: log.clone(),
358            should_fail: false,
359        }));
360        client.register_sync_handler(Arc::new(TestHandler {
361            name: "third".to_string(),
362            execution_log: log.clone(),
363            should_fail: false,
364        }));
365
366        let response = SyncResponseModel::default();
367        client.run_handlers(&response).await.unwrap();
368
369        assert_eq!(
370            *log.lock().unwrap(),
371            vec!["first", "second", "third"],
372            "Handlers should execute in registration order"
373        );
374    }
375
376    #[tokio::test]
377    async fn test_handler_error_stops_subsequent_handlers() {
378        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|_| {}));
379        let log = Arc::new(Mutex::new(Vec::new()));
380
381        client.register_sync_handler(Arc::new(TestHandler {
382            name: "first".to_string(),
383            execution_log: log.clone(),
384            should_fail: false,
385        }));
386        client.register_sync_handler(Arc::new(TestHandler {
387            name: "second".to_string(),
388            execution_log: log.clone(),
389            should_fail: true,
390        }));
391        client.register_sync_handler(Arc::new(TestHandler {
392            name: "third".to_string(),
393            execution_log: log.clone(),
394            should_fail: false,
395        }));
396
397        let response = SyncResponseModel::default();
398        let result = client.run_handlers(&response).await;
399
400        assert!(result.is_err(), "Should return error when handler fails");
401        assert_eq!(
402            *log.lock().unwrap(),
403            vec!["first", "second"],
404            "Third handler should not execute after second handler fails"
405        );
406    }
407
408    #[tokio::test]
409    async fn test_sync_success_calls_handlers_and_returns_response() {
410        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
411            mock.sync_api
412                .expect_get()
413                .returning(|_| Ok(SyncResponseModel::default()));
414        }));
415        let sync_log = Arc::new(Mutex::new(Vec::new()));
416        let error_log = Arc::new(Mutex::new(Vec::new()));
417
418        client.register_sync_handler(Arc::new(TestHandler {
419            name: "handler".to_string(),
420            execution_log: sync_log.clone(),
421            should_fail: false,
422        }));
423        client.register_error_handler(Arc::new(TestErrorHandler {
424            name: "error_handler".to_string(),
425            error_log: error_log.clone(),
426        }));
427
428        let result = client
429            .sync(SyncRequest {
430                force: false,
431                exclude_subdomains: None,
432            })
433            .await;
434
435        assert!(result.is_ok(), "Sync should succeed");
436        assert_eq!(
437            *sync_log.lock().unwrap(),
438            vec!["handler"],
439            "Sync handler should be called on success"
440        );
441        assert!(
442            error_log.lock().unwrap().is_empty(),
443            "Error handlers should not be called on success"
444        );
445    }
446
447    #[tokio::test]
448    async fn test_sync_error_notifies_error_handlers() {
449        let client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
450            mock.sync_api
451                .expect_get()
452                .returning(|_| Err(std::io::Error::other("test error").into()));
453        }));
454        let error_log = Arc::new(Mutex::new(Vec::new()));
455
456        client.register_error_handler(Arc::new(TestErrorHandler {
457            name: "first".to_string(),
458            error_log: error_log.clone(),
459        }));
460        client.register_error_handler(Arc::new(TestErrorHandler {
461            name: "second".to_string(),
462            error_log: error_log.clone(),
463        }));
464
465        // sync() will fail due to the mocked error, which should trigger all error handlers
466        let result = client
467            .sync(SyncRequest {
468                force: false,
469                exclude_subdomains: None,
470            })
471            .await;
472
473        assert!(result.is_err());
474        assert_eq!(
475            *error_log.lock().unwrap(),
476            vec!["first", "second"],
477            "All error handlers should be called on sync failure"
478        );
479    }
480
481    #[tokio::test]
482    async fn test_first_sync_skips_revision_check() {
483        // Setting exists but has no stored value — revision check must not be called.
484        let client = test_client_with_last_sync(
485            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
486                mock.sync_api
487                    .expect_get()
488                    .returning(|_| Ok(SyncResponseModel::default()));
489                // accounts_api has no expectation — mock panics on unexpected calls
490            }),
491            None,
492        )
493        .await;
494
495        let result = client
496            .sync(SyncRequest {
497                force: false,
498                exclude_subdomains: None,
499            })
500            .await;
501
502        assert!(result.is_ok_and(|v| v));
503    }
504
505    #[tokio::test]
506    async fn test_revision_check_skips_sync_when_up_to_date() {
507        let stored_last_sync = Utc::now();
508        // Server revision is 60 seconds older than our stored last_sync.
509        let server_revision_ms = (stored_last_sync - Duration::seconds(60)).timestamp_millis();
510
511        let sync_log = Arc::new(Mutex::new(Vec::<String>::new()));
512        let sync_log_clone = sync_log.clone();
513
514        let client = test_client_with_last_sync(
515            bitwarden_api_api::apis::ApiClient::new_mocked(move |mock| {
516                mock.accounts_api
517                    .expect_get_account_revision_date()
518                    .returning(move || Ok(server_revision_ms));
519                // sync_api has no expectation — must not be called
520            }),
521            Some(stored_last_sync),
522        )
523        .await;
524
525        client.register_sync_handler(Arc::new(TestHandler {
526            name: "should_not_run".to_string(),
527            execution_log: sync_log_clone,
528            should_fail: false,
529        }));
530
531        let result = client
532            .sync(SyncRequest {
533                force: false,
534                exclude_subdomains: None,
535            })
536            .await;
537
538        assert!(result.is_ok_and(|v| !v), "Expected Ok(false) skip result");
539        assert!(
540            sync_log.lock().unwrap().is_empty(),
541            "Sync handler must not be called on skip"
542        );
543    }
544
545    #[tokio::test]
546    async fn test_force_bypasses_revision_check() {
547        let stored_last_sync = Utc::now();
548
549        let client = test_client_with_last_sync(
550            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
551                mock.sync_api
552                    .expect_get()
553                    .returning(|_| Ok(SyncResponseModel::default()));
554                // accounts_api has no expectation
555            }),
556            Some(stored_last_sync),
557        )
558        .await;
559
560        let result = client
561            .sync(SyncRequest {
562                force: true,
563                exclude_subdomains: None,
564            })
565            .await;
566
567        assert!(result.is_ok_and(|v| v));
568    }
569
570    #[tokio::test]
571    async fn test_account_deleted_error() {
572        let stored_last_sync = Utc::now();
573        let error_log = Arc::new(Mutex::new(Vec::<String>::new()));
574        let error_log_clone = error_log.clone();
575
576        let client = test_client_with_last_sync(
577            bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
578                mock.accounts_api
579                    .expect_get_account_revision_date()
580                    .returning(|| Ok(-1i64));
581            }),
582            Some(stored_last_sync),
583        )
584        .await;
585
586        client.register_error_handler(Arc::new(TestErrorHandler {
587            name: "error_handler".to_string(),
588            error_log: error_log_clone,
589        }));
590
591        let result = client
592            .sync(SyncRequest {
593                force: false,
594                exclude_subdomains: None,
595            })
596            .await;
597
598        assert!(
599            matches!(result, Err(SyncError::AccountDeleted)),
600            "Expected AccountDeleted error"
601        );
602        assert_eq!(
603            *error_log.lock().unwrap(),
604            vec!["error_handler"],
605            "Error handler must be called for AccountDeleted"
606        );
607    }
608
609    #[tokio::test]
610    async fn test_revision_fetch_failure_does_not_bump_last_sync() {
611        let stored_last_sync =
612            DateTime::<Utc>::from_timestamp_millis(1_000_000).expect("valid timestamp");
613        let error_log = Arc::new(Mutex::new(Vec::<String>::new()));
614        let error_log_clone = error_log.clone();
615
616        // Build Setting manually so we can inspect it after sync.
617        let repo: Arc<dyn bitwarden_state::repository::Repository<bitwarden_state::SettingItem>> =
618            Arc::new(bitwarden_test::MemoryRepository::<
619                bitwarden_state::SettingItem,
620            >::default());
621        let setting = bitwarden_state::Setting::new(repo, crate::state::LAST_SYNC);
622        setting
623            .update(stored_last_sync)
624            .await
625            .expect("pre-populate last_sync");
626
627        let mut client = test_client(bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
628            mock.accounts_api
629                .expect_get_account_revision_date()
630                .returning(|| Err(std::io::Error::other("network error").into()));
631        }));
632        client.last_sync = Some(setting.clone());
633
634        client.register_error_handler(Arc::new(TestErrorHandler {
635            name: "error_handler".to_string(),
636            error_log: error_log_clone,
637        }));
638
639        let result = client
640            .sync(SyncRequest {
641                force: false,
642                exclude_subdomains: None,
643            })
644            .await;
645
646        assert!(
647            result.is_err(),
648            "Expected error from revision fetch failure"
649        );
650        assert_eq!(
651            setting.get().await.unwrap(),
652            Some(stored_last_sync),
653            "last_sync must not be bumped on error"
654        );
655        assert!(
656            !error_log.lock().unwrap().is_empty(),
657            "Error handler must be called"
658        );
659    }
660}