Skip to main content

max / synckit-client

Version bump to 0.2.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-12 02:03 UTC
Commit: 12299746b2d2930fd4b5ea92d5a834f621b669a3
Parent: f92a9cb
6 files changed, +666 insertions, -39 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-client"
3 - version = "0.1.0"
3 + version = "0.2.1"
4 4 edition = "2021"
5 5 description = "SyncKit client SDK with end-to-end encryption"
6 6
@@ -18,7 +18,7 @@ base64 = "0.22"
18 18
19 19 # HTTP
20 20 reqwest = { version = "0.12", features = ["json", "native-tls"] }
21 - tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
21 + tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
22 22
23 23 # Serialization
24 24 serde = { version = "1", features = ["derive"] }
M src/client.rs +374 -30
@@ -2,8 +2,11 @@
2 2
3 3 use reqwest::Client;
4 4 use std::sync::Mutex;
5 + use std::time::Duration;
5 6 use uuid::Uuid;
6 7
8 + use base64::Engine;
9 +
7 10 use crate::{
8 11 crypto,
9 12 error::{Result, SyncKitError},
@@ -11,6 +14,16 @@ use crate::{
11 14 types::*,
12 15 };
13 16
17 + /// Maximum number of retry attempts for transient failures.
18 + const MAX_RETRIES: u32 = 3;
19 +
20 + /// Base delay for exponential backoff (1s, 2s, 4s).
21 + const BASE_DELAY: Duration = Duration::from_secs(1);
22 +
23 + /// Seconds before actual expiry to consider the token expired.
24 + /// Avoids sending a request with a token that expires mid-flight.
25 + const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
26 +
14 27 /// Configuration for the SyncKit client.
15 28 #[derive(Debug, Clone)]
16 29 pub struct SyncKitConfig {
@@ -45,9 +58,15 @@ pub struct SyncKitClient {
45 58 impl SyncKitClient {
46 59 /// Create a new client with the given configuration.
47 60 pub fn new(config: SyncKitConfig) -> Self {
61 + let http = Client::builder()
62 + .timeout(std::time::Duration::from_secs(30))
63 + .connect_timeout(std::time::Duration::from_secs(10))
64 + .build()
65 + .expect("failed to build HTTP client");
66 +
48 67 Self {
49 68 config,
50 - http: Client::new(),
69 + http,
51 70 session: Mutex::new(None),
52 71 master_key: Mutex::new(None),
53 72 }
@@ -123,6 +142,19 @@ impl SyncKitClient {
123 142 tracing::info!("Session restored for user {user_id}, app {app_id}");
124 143 }
125 144
145 + /// Check whether the current session token has expired (or will expire
146 + /// within a 30-second buffer). Returns `true` if there is no session or
147 + /// if the token's `exp` claim is in the past. Returns `false` if the
148 + /// token cannot be decoded (assumes not expired — the server will reject
149 + /// it with a 401 if it actually is).
150 + pub fn is_token_expired(&self) -> bool {
151 + let guard = self.session.lock().unwrap();
152 + let Some(session) = guard.as_ref() else {
153 + return true;
154 + };
155 + token_is_expired(&session.token)
156 + }
157 +
126 158 // ── OAuth ──
127 159
128 160 /// Build the authorization URL for the OAuth2 PKCE flow.
@@ -272,18 +304,20 @@ impl SyncKitClient {
272 304 ) -> Result<()> {
273 305 let (app_id, user_id) = self.require_session_ids()?;
274 306
275 - // Get the current master key (from memory or re-derive from old password)
276 - let master_key = {
307 + // Get the current master key (from memory or re-derive from old password).
308 + // Check the in-memory cache first, dropping the lock before any .await.
309 + let cached = {
277 310 let guard = self.master_key.lock().unwrap();
278 - if let Some(ref key) = *guard {
279 - **key
280 - } else {
281 - // Fall back to decrypting from server
282 - let envelope_json = self.get_server_key().await?;
283 - let wrapping_key =
284 - crypto::derive_wrapping_key(old_password, app_id, user_id)?;
285 - crypto::unwrap_master_key(&envelope_json, &wrapping_key)?
286 - }
311 + guard.as_ref().map(|key| **key)
312 + };
313 +
314 + let master_key = if let Some(key) = cached {
315 + key
316 + } else {
317 + let envelope_json = self.get_server_key().await?;
318 + let wrapping_key =
319 + crypto::derive_wrapping_key(old_password, app_id, user_id)?;
320 + crypto::unwrap_master_key(&envelope_json, &wrapping_key)?
287 321 };
288 322
289 323 // Re-wrap with new password
@@ -344,6 +378,8 @@ impl SyncKitClient {
344 378
345 379 /// Push changes to the server. Encrypts `data` fields automatically.
346 380 /// Returns the server cursor after the push.
381 + ///
382 + /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
347 383 pub async fn push(
348 384 &self,
349 385 device_id: Uuid,
@@ -352,23 +388,29 @@ impl SyncKitClient {
352 388 let url = format!("{}/api/sync/push", self.config.server_url);
353 389 let token = self.require_token()?;
354 390
391 + // Encrypt once, before the retry loop
355 392 let wire_changes = changes
356 393 .into_iter()
357 394 .map(|c| self.encrypt_change(c))
358 395 .collect::<Result<Vec<_>>>()?;
359 396
397 + let body = serde_json::to_vec(&WirePushRequest {
398 + device_id,
399 + changes: wire_changes,
400 + })?;
401 +
360 402 let resp = self
361 - .http
362 - .post(&url)
363 - .bearer_auth(&token)
364 - .json(&WirePushRequest {
365 - device_id,
366 - changes: wire_changes,
403 + .retry_request(|| {
404 + let req = self
405 + .http
406 + .post(&url)
407 + .bearer_auth(&token)
408 + .header("content-type", "application/json")
409 + .body(body.clone());
410 + async move { check_response(req.send().await?).await }
367 411 })
368 - .send()
369 412 .await?;
370 413
371 - let resp = check_response(resp).await?;
372 414 let push_resp: PushResponse = resp.json().await?;
373 415 Ok(push_resp.cursor)
374 416 }
@@ -376,6 +418,8 @@ impl SyncKitClient {
376 418 /// Pull changes from the server since the given cursor.
377 419 /// Decrypts `data` fields automatically.
378 420 /// Returns (changes, new_cursor, has_more).
421 + ///
422 + /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
379 423 pub async fn pull(
380 424 &self,
381 425 device_id: Uuid,
@@ -384,15 +428,20 @@ impl SyncKitClient {
384 428 let url = format!("{}/api/sync/pull", self.config.server_url);
385 429 let token = self.require_token()?;
386 430
431 + let body = serde_json::to_vec(&PullRequest { device_id, cursor })?;
432 +
387 433 let resp = self
388 - .http
389 - .post(&url)
390 - .bearer_auth(&token)
391 - .json(&PullRequest { device_id, cursor })
392 - .send()
434 + .retry_request(|| {
435 + let req = self
436 + .http
437 + .post(&url)
438 + .bearer_auth(&token)
439 + .header("content-type", "application/json")
440 + .body(body.clone());
441 + async move { check_response(req.send().await?).await }
442 + })
393 443 .await?;
394 444
395 - let resp = check_response(resp).await?;
396 445 let pull_resp: PullResponse = resp.json().await?;
397 446
398 447 let changes = pull_resp
@@ -420,14 +469,122 @@ impl SyncKitClient {
420 469 Ok(resp.json().await?)
421 470 }
422 471
472 + // ── Blobs ──
473 +
474 + /// Request a presigned upload URL for a blob.
475 + /// Returns (upload_url, already_exists). If `already_exists` is true,
476 + /// the blob is already on the server and no upload is needed.
477 + pub async fn blob_upload_url(
478 + &self,
479 + hash: &str,
480 + size_bytes: i64,
481 + ) -> Result<BlobUploadUrlResponse> {
482 + let url = format!("{}/api/sync/blobs/upload", self.config.server_url);
483 + let token = self.require_token()?;
484 +
485 + let resp = self
486 + .http
487 + .post(&url)
488 + .bearer_auth(&token)
489 + .json(&BlobUploadUrlRequest {
490 + hash: hash.to_string(),
491 + size_bytes,
492 + })
493 + .send()
494 + .await?;
495 +
496 + let resp = check_response(resp).await?;
497 + Ok(resp.json().await?)
498 + }
499 +
500 + /// Upload blob data directly to S3 via a presigned PUT URL.
501 + pub async fn blob_upload(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
502 + let resp = self
503 + .http
504 + .put(presigned_url)
505 + .header("content-type", "application/octet-stream")
506 + .body(data)
507 + .send()
508 + .await?;
509 +
510 + if !resp.status().is_success() {
511 + let status = resp.status().as_u16();
512 + let message = resp.text().await.unwrap_or_default();
513 + return Err(SyncKitError::Server { status, message });
514 + }
515 +
516 + Ok(())
517 + }
518 +
519 + /// Confirm that a blob upload completed successfully.
520 + /// The server verifies the object exists in S3 and records it.
521 + pub async fn blob_confirm(&self, hash: &str, size_bytes: i64) -> Result<()> {
522 + let url = format!("{}/api/sync/blobs/confirm", self.config.server_url);
523 + let token = self.require_token()?;
524 +
525 + let resp = self
526 + .http
527 + .post(&url)
528 + .bearer_auth(&token)
529 + .json(&BlobConfirmRequest {
530 + hash: hash.to_string(),
531 + size_bytes,
532 + })
533 + .send()
534 + .await?;
535 +
536 + check_response(resp).await?;
537 + Ok(())
538 + }
539 +
540 + /// Get a presigned download URL for a blob by hash.
541 + pub async fn blob_download_url(&self, hash: &str) -> Result<String> {
542 + let url = format!("{}/api/sync/blobs/download", self.config.server_url);
543 + let token = self.require_token()?;
544 +
545 + let resp = self
546 + .http
547 + .post(&url)
548 + .bearer_auth(&token)
549 + .json(&BlobDownloadUrlRequest {
550 + hash: hash.to_string(),
551 + })
552 + .send()
553 + .await?;
554 +
555 + let resp = check_response(resp).await?;
556 + let body: BlobDownloadUrlResponse = resp.json().await?;
557 + Ok(body.download_url)
558 + }
559 +
560 + /// Download blob data from S3 via a presigned GET URL.
561 + pub async fn blob_download(&self, presigned_url: &str) -> Result<Vec<u8>> {
562 + let resp = self
563 + .http
564 + .get(presigned_url)
565 + .send()
566 + .await?;
567 +
568 + if !resp.status().is_success() {
569 + let status = resp.status().as_u16();
570 + let message = resp.text().await.unwrap_or_default();
571 + return Err(SyncKitError::Server { status, message });
572 + }
573 +
574 + Ok(resp.bytes().await?.to_vec())
575 + }
576 +
423 577 // ── Internal helpers ──
424 578
425 579 fn require_token(&self) -> Result<String> {
426 580 let guard = self.session.lock().unwrap();
427 - guard
428 - .as_ref()
429 - .map(|s| s.token.clone())
430 - .ok_or(SyncKitError::NotAuthenticated)
581 + let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
582 +
583 + if token_is_expired(&session.token) {
584 + return Err(SyncKitError::TokenExpired);
585 + }
586 +
587 + Ok(session.token.clone())
431 588 }
432 589
433 590 fn require_session_ids(&self) -> Result<(Uuid, Uuid)> {
@@ -484,6 +641,46 @@ impl SyncKitClient {
484 641 Ok(key_resp.encrypted_key)
485 642 }
486 643
644 + /// Retry an async HTTP operation with exponential backoff.
645 + ///
646 + /// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`]
647 + /// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail.
648 + /// Client errors (4xx except 429) are considered permanent and returned immediately.
649 + async fn retry_request<F, Fut>(&self, mut operation: F) -> Result<reqwest::Response>
650 + where
651 + F: FnMut() -> Fut,
652 + Fut: std::future::Future<Output = Result<reqwest::Response>>,
653 + {
654 + let mut last_err = None;
655 +
656 + for attempt in 0..=MAX_RETRIES {
657 + match operation().await {
658 + Ok(resp) => return Ok(resp),
659 + Err(err) => {
660 + if !is_transient(&err) {
661 + return Err(err);
662 + }
663 +
664 + if attempt < MAX_RETRIES {
665 + let delay = BASE_DELAY * 2u32.pow(attempt);
666 + tracing::debug!(
667 + attempt = attempt + 1,
668 + max_retries = MAX_RETRIES,
669 + delay_ms = delay.as_millis() as u64,
670 + error = %err,
671 + "Transient error, retrying after backoff",
672 + );
673 + tokio::time::sleep(delay).await;
674 + }
675 +
676 + last_err = Some(err);
677 + }
678 + }
679 + }
680 +
681 + Err(last_err.expect("loop ran at least once"))
682 + }
683 +
487 684 /// Encrypt the data field of a change entry for the wire.
488 685 fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> {
489 686 let encrypted_data = match entry.data {
@@ -523,6 +720,31 @@ impl SyncKitClient {
523 720 }
524 721 }
525 722
723 + /// Extract the `exp` claim from a JWT without verifying the signature.
724 + ///
725 + /// JWTs are `header.payload.signature` where the payload is base64url-encoded JSON.
726 + /// We decode the payload segment and read the `exp` field. Returns `None` if
727 + /// the token is malformed or `exp` is missing.
728 + fn jwt_exp(token: &str) -> Option<i64> {
729 + let payload = token.split('.').nth(1)?;
730 + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
731 + .decode(payload)
732 + .ok()?;
733 + let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
734 + claims["exp"].as_i64()
735 + }
736 +
737 + /// Returns `true` if the token's `exp` claim is within [`TOKEN_EXPIRY_BUFFER_SECS`]
738 + /// of the current time (or already past). Returns `false` if the token cannot
739 + /// be decoded — in that case, let the server decide.
740 + fn token_is_expired(token: &str) -> bool {
741 + let Some(exp) = jwt_exp(token) else {
742 + return false;
743 + };
744 + let now = chrono::Utc::now().timestamp();
745 + now >= exp - TOKEN_EXPIRY_BUFFER_SECS
746 + }
747 +
526 748 /// Check an HTTP response for errors, returning the response on success.
527 749 async fn check_response(resp: reqwest::Response) -> Result<reqwest::Response> {
528 750 let status = resp.status().as_u16();
@@ -532,3 +754,1051 @@ async fn check_response(resp: reqwest::Response) -> Result<reqwest::Response> {
532 754 }
533 755 Ok(resp)
534 756 }
757 +
758 + /// Returns true if the error is transient and worth retrying.
759 + ///
760 + /// Transient errors:
761 + /// - Network-level failures (connection refused, timeout, DNS, etc.)
762 + /// - Server errors (5xx)
763 + /// - Rate limiting (429)
764 + ///
765 + /// Permanent errors (not retried):
766 + /// - Client errors (4xx except 429) — bad request, auth failure, not found, etc.
767 + /// - Serialization errors, encryption errors, missing session, etc.
768 + fn is_transient(err: &SyncKitError) -> bool {
769 + match err {
770 + SyncKitError::Http(e) => {
771 + // All reqwest transport errors are transient (timeout, connect, DNS, etc.)
772 + // except for builder errors which indicate programming mistakes.
773 + !e.is_builder()
774 + }
775 + SyncKitError::Server { status, .. } => {
776 + // 5xx = server error (transient), 429 = rate limited (transient)
777 + *status >= 500 || *status == 429
778 + }
779 + // Everything else (auth, crypto, serialization) is permanent
780 + _ => false,
781 + }
782 + }
783 +
784 + #[cfg(test)]
785 + mod tests {
786 + use super::*;
787 + use base64::Engine;
788 + use chrono::Utc;
789 +
790 + fn test_config() -> SyncKitConfig {
791 + SyncKitConfig {
792 + server_url: "https://example.com".to_string(),
793 + api_key: "test-api-key-123".to_string(),
794 + }
795 + }
796 +
797 + fn test_ids() -> (Uuid, Uuid) {
798 + (
799 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
800 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
801 + )
802 + }
803 +
804 + // ── SyncKitClient::new() construction ──
805 +
806 + #[test]
807 + fn new_client_starts_unauthenticated() {
808 + let client = SyncKitClient::new(test_config());
809 + assert!(client.session_info().is_none());
810 + }
811 +
812 + #[test]
813 + fn new_client_has_no_master_key() {
814 + let client = SyncKitClient::new(test_config());
815 + assert!(!client.has_master_key());
816 + }
817 +
818 + #[test]
819 + fn config_returns_provided_values() {
820 + let client = SyncKitClient::new(test_config());
821 + assert_eq!(client.config().server_url, "https://example.com");
822 + assert_eq!(client.config().api_key, "test-api-key-123");
823 + }
824 +
825 + // ── SyncKitConfig ──
826 +
827 + #[test]
828 + fn config_clone() {
829 + let config = test_config();
830 + let cloned = config.clone();
831 + assert_eq!(cloned.server_url, config.server_url);
832 + assert_eq!(cloned.api_key, config.api_key);
833 + }
834 +
835 + #[test]
836 + fn config_debug() {
837 + let config = test_config();
838 + let debug = format!("{:?}", config);
839 + assert!(debug.contains("SyncKitConfig"));
840 + assert!(debug.contains("example.com"));
841 + }
842 +
843 + // ── restore_session ──
844 +
845 + #[test]
846 + fn restore_session_makes_client_authenticated() {
847 + let client = SyncKitClient::new(test_config());
848 + let (app_id, user_id) = test_ids();
849 +
850 + client.restore_session("fake-token", user_id, app_id);
851 +
852 + let info = client.session_info().expect("session should exist");
853 + assert_eq!(info.token, "fake-token");
854 + assert_eq!(info.user_id, user_id);
855 + assert_eq!(info.app_id, app_id);
856 + }
857 +
858 + #[test]
859 + fn restore_session_overwrites_previous_session() {
860 + let client = SyncKitClient::new(test_config());
861 + let (app_id, user_id) = test_ids();
862 +
863 + client.restore_session("first-token", user_id, app_id);
864 + client.restore_session("second-token", user_id, app_id);
865 +
866 + let info = client.session_info().unwrap();
867 + assert_eq!(info.token, "second-token");
868 + }
869 +
870 + // ── require_token ──
871 +
872 + #[test]
873 + fn require_token_fails_without_session() {
874 + let client = SyncKitClient::new(test_config());
875 + let err = client.require_token().unwrap_err();
876 + assert!(matches!(err, SyncKitError::NotAuthenticated));
877 + }
878 +
Lines truncated
@@ -32,6 +32,9 @@ pub enum SyncKitError {
32 32 #[error("Not authenticated — call authenticate first")]
33 33 NotAuthenticated,
34 34
35 + #[error("Token expired — re-authenticate to continue syncing")]
36 + TokenExpired,
37 +
35 38 #[cfg(feature = "keychain")]
36 39 #[error("Keychain error: {0}")]
37 40 Keychain(String),
@@ -92,3 +92,213 @@ pub fn load_key(_app_id: Uuid, _user_id: Uuid) -> Result<Option<[u8; 32]>> {
92 92 pub fn delete_key(_app_id: Uuid, _user_id: Uuid) -> Result<()> {
93 93 Ok(())
94 94 }
95 +
96 + // ── Tests ──
97 + // The public functions (store_key, load_key, delete_key) are thin wrappers
98 + // around the `keyring` crate with base64 encoding. Direct keychain access
99 + // varies by OS and CI environment, so these tests focus on:
100 + // - Pure helper functions (service_name, user_key)
101 + // - Base64 round-trip correctness (the encoding used by store/load)
102 + // - Length validation logic (the guard in load_key)
103 + // - Error variant construction
104 + // - No-op stub behavior (when keychain feature is disabled)
105 +
106 + #[cfg(test)]
107 + mod keystore_tests {
108 + use super::*;
109 + use base64::{engine::general_purpose::STANDARD as B64, Engine};
110 +
111 + fn test_ids() -> (Uuid, Uuid) {
112 + (
113 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
114 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
115 + )
116 + }
117 +
118 + // ── service_name ──
119 +
120 + #[test]
121 + fn service_name_format() {
122 + let (app_id, _) = test_ids();
123 + let name = service_name(app_id);
124 + assert_eq!(name, "synckit:550e8400-e29b-41d4-a716-446655440000");
125 + }
126 +
127 + #[test]
128 + fn service_name_starts_with_prefix() {
129 + let (app_id, _) = test_ids();
130 + let name = service_name(app_id);
131 + assert!(name.starts_with("synckit:"));
132 + }
133 +
134 + #[test]
135 + fn service_name_contains_app_id() {
136 + let (app_id, _) = test_ids();
137 + let name = service_name(app_id);
138 + assert!(name.contains(&app_id.to_string()));
139 + }
140 +
141 + #[test]
142 + fn service_name_different_ids_produce_different_names() {
143 + let (app_id1, app_id2) = test_ids();
144 + assert_ne!(service_name(app_id1), service_name(app_id2));
145 + }
146 +
147 + // ── user_key ──
148 +
149 + #[test]
150 + fn user_key_is_uuid_string() {
151 + let (_, user_id) = test_ids();
152 + let key = user_key(user_id);
153 + assert_eq!(key, "6ba7b810-9dad-11d1-80b4-00c04fd430c8");
154 + }
155 +
156 + #[test]
157 + fn user_key_round_trips_through_uuid_parse() {
158 + let (_, user_id) = test_ids();
159 + let key = user_key(user_id);
160 + let parsed = Uuid::parse_str(&key).expect("user_key should produce a valid UUID string");
161 + assert_eq!(parsed, user_id);
162 + }
163 +
164 + // ── Base64 round-trip (mirrors store_key encode / load_key decode) ──
165 +
166 + #[test]
167 + fn base64_round_trip_32_byte_key() {
168 + // Reproduces the encoding path in store_key and decoding path in load_key
169 + let master_key: [u8; 32] = [
170 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
171 + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
172 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
173 + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
174 + ];
175 +
176 + // Encode (as store_key does)
177 + let encoded = B64.encode(master_key);
178 +
179 + // Decode (as load_key does)
180 + let bytes = B64.decode(&encoded).expect("decode should succeed");
181 + assert_eq!(bytes.len(), 32);
182 +
183 + let mut recovered = [0u8; 32];
184 + recovered.copy_from_slice(&bytes);
185 + assert_eq!(recovered, master_key);
186 + }
187 +
188 + #[test]
189 + fn base64_round_trip_all_zeros() {
190 + let master_key = [0u8; 32];
191 + let encoded = B64.encode(master_key);
192 + let bytes = B64.decode(&encoded).unwrap();
193 + assert_eq!(bytes.len(), 32);
194 + assert_eq!(bytes, master_key);
195 + }
196 +
197 + #[test]
198 + fn base64_round_trip_all_ones() {
199 + let master_key = [0xffu8; 32];
200 + let encoded = B64.encode(master_key);
201 + let bytes = B64.decode(&encoded).unwrap();
202 + assert_eq!(bytes.len(), 32);
203 + assert_eq!(bytes, master_key);
204 + }
205 +
206 + #[test]
207 + fn base64_encoded_length_is_44_chars() {
208 + // 32 bytes -> ceil(32/3)*4 = 44 base64 characters (with padding)
209 + let key = [0u8; 32];
210 + let encoded = B64.encode(key);
211 + assert_eq!(encoded.len(), 44);
212 + }
213 +
214 + // ── Length validation (mirrors the guard in load_key) ──
215 +
216 + #[test]
217 + fn length_validation_rejects_short_key() {
218 + // Simulate what load_key does when it decodes a stored value
219 + let short_key = [0u8; 16];
220 + let encoded = B64.encode(short_key);
221 + let bytes = B64.decode(&encoded).unwrap();
222 +
223 + // This is the same check from load_key
224 + assert_ne!(bytes.len(), 32, "16-byte key should fail the length check");
225 + }
226 +
227 + #[test]
228 + fn length_validation_rejects_long_key() {
229 + let long_key = [0u8; 64];
230 + let encoded = B64.encode(long_key);
231 + let bytes = B64.decode(&encoded).unwrap();
232 +
233 + assert_ne!(bytes.len(), 32, "64-byte key should fail the length check");
234 + }
235 +
236 + #[test]
237 + fn length_validation_accepts_exact_32() {
238 + let key = [0u8; 32];
239 + let encoded = B64.encode(key);
240 + let bytes = B64.decode(&encoded).unwrap();
241 +
242 + assert_eq!(bytes.len(), 32, "32-byte key should pass the length check");
243 + }
244 +
245 + #[test]
246 + fn length_validation_rejects_empty() {
247 + let empty: [u8; 0] = [];
248 + let encoded = B64.encode(empty);
249 + let bytes = B64.decode(&encoded).unwrap();
250 +
251 + assert_ne!(bytes.len(), 32, "empty key should fail the length check");
252 + }
253 +
254 + // ── Error variant construction ──
255 +
256 + #[cfg(feature = "keychain")]
257 + #[test]
258 + fn keychain_error_contains_message() {
259 + let err = SyncKitError::Keychain("test failure".into());
260 + let msg = format!("{err}");
261 + assert!(msg.contains("test failure"));
262 + assert!(msg.contains("Keychain"));
263 + }
264 +
265 + #[test]
266 + fn base64_decode_error_propagates() {
267 + // Invalid base64 should produce a Base64 error variant
268 + let result = B64.decode("not!valid!base64!!!");
269 + assert!(result.is_err());
270 +
271 + // Verify SyncKitError::Base64 can be constructed from it
272 + let sync_err: SyncKitError = result.unwrap_err().into();
273 + let msg = format!("{sync_err}");
274 + assert!(msg.contains("Base64"));
275 + }
276 +
277 + // ── SERVICE_PREFIX constant ──
278 +
279 + #[test]
280 + fn service_prefix_is_synckit() {
281 + assert_eq!(SERVICE_PREFIX, "synckit");
282 + }
283 +
284 + // ── No-op stub behavior ──
285 + // These tests verify the public API contract regardless of feature flags.
286 + // When keychain is enabled, they exercise the real keyring path (which may
287 + // succeed or fail depending on OS keychain availability in CI).
288 + // The important contract: the functions exist, accept the right types,
289 + // and return the right types.
290 +
291 + #[test]
292 + fn public_api_types_compile() {
293 + // Compile-time check that the public API signatures are correct.
294 + // This catches accidental signature changes.
295 + let (app_id, user_id) = test_ids();
296 + let key = [0u8; 32];
297 +
298 + // These may fail at runtime due to keychain unavailability,
299 + // but they must compile with the correct types.
300 + let _: Result<()> = store_key(app_id, user_id, &key);
301 + let _: Result<Option<[u8; 32]>> = load_key(app_id, user_id);
302 + let _: Result<()> = delete_key(app_id, user_id);
303 + }
304 + }
M src/lib.rs +3 -3
@@ -6,7 +6,7 @@
6 6 //! # Quick start
7 7 //!
8 8 //! ```no_run
9 - //! use synckit_client::{SyncKitClient, SyncKitConfig, ChangeEntry};
9 + //! use synckit_client::{SyncKitClient, SyncKitConfig, ChangeEntry, ChangeOp};
10 10 //! use chrono::Utc;
11 11 //!
12 12 //! # async fn example() -> synckit_client::Result<()> {
@@ -28,7 +28,7 @@
28 28 //! let cursor = client.push(device.id, vec![
29 29 //! ChangeEntry {
30 30 //! table: "tasks".into(),
31 - //! op: "INSERT".into(),
31 + //! op: ChangeOp::Insert,
32 32 //! row_id: uuid::Uuid::new_v4().to_string(),
33 33 //! timestamp: Utc::now(),
34 34 //! data: Some(serde_json::json!({"title": "Buy milk"})),
@@ -50,4 +50,4 @@ pub mod types;
50 50 // Re-exports for convenience
51 51 pub use client::{SessionInfo, SyncKitClient, SyncKitConfig};
52 52 pub use error::{Result, SyncKitError};
53 - pub use types::{ChangeEntry, Device, SyncStatus};
53 + pub use types::{ChangeEntry, ChangeOp, Device, SyncStatus};
M src/types.rs +74 -4
@@ -2,8 +2,48 @@
2 2
3 3 use chrono::{DateTime, Utc};
4 4 use serde::{Deserialize, Serialize};
5 + use std::fmt;
5 6 use uuid::Uuid;
6 7
8 + // ── Change operations ──
9 +
10 + /// The operation type for a sync change entry.
11 + ///
12 + /// Serializes to/from uppercase strings (`"INSERT"`, `"UPDATE"`, `"DELETE"`)
13 + /// matching the server wire protocol. Invalid values are rejected at
14 + /// deserialization time.
15 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16 + #[serde(rename_all = "UPPERCASE")]
17 + pub enum ChangeOp {
18 + Insert,
19 + Update,
20 + Delete,
21 + }
22 +
23 + impl fmt::Display for ChangeOp {
24 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 + match self {
26 + ChangeOp::Insert => write!(f, "INSERT"),
27 + ChangeOp::Update => write!(f, "UPDATE"),
28 + ChangeOp::Delete => write!(f, "DELETE"),
29 + }
30 + }
31 + }
32 +
33 + impl ChangeOp {
34 + /// Parse from a string, returning `None` for unrecognized values.
35 + ///
36 + /// Useful when reading raw op strings from a local database.
37 + pub fn from_str_opt(s: &str) -> Option<Self> {
38 + match s {
39 + "INSERT" => Some(ChangeOp::Insert),
40 + "UPDATE" => Some(ChangeOp::Update),
41 + "DELETE" => Some(ChangeOp::Delete),
42 + _ => None,
43 + }
44 + }
45 + }
46 +
7 47 // ── Auth ──
8 48
9 49 #[derive(Serialize)]
@@ -46,7 +86,7 @@ pub struct Device {
46 86 #[derive(Debug, Clone, Serialize, Deserialize)]
47 87 pub struct ChangeEntry {
48 88 pub table: String,
49 - pub op: String,
89 + pub op: ChangeOp,
50 90 pub row_id: String,
51 91 pub timestamp: DateTime<Utc>,
52 92 #[serde(skip_serializing_if = "Option::is_none")]
@@ -60,10 +100,10 @@ pub(crate) struct WirePushRequest {
60 100 pub changes: Vec<WireChangeEntry>,
61 101 }
62 102
63 - #[derive(Serialize)]
103 + #[derive(Debug, Serialize)]
64 104 pub(crate) struct WireChangeEntry {
65 105 pub table: String,
66 - pub op: String,
106 + pub op: ChangeOp,
67 107 pub row_id: String,
68 108 pub timestamp: DateTime<Utc>,
69 109 pub data: Option<serde_json::Value>,
@@ -94,7 +134,7 @@ pub(crate) struct PullChangeEntry {
94 134 #[allow(dead_code)]
95 135 pub device_id: Uuid,
96 136 pub table: String,
97 - pub op: String,
137 + pub op: ChangeOp,
98 138 pub row_id: String,
99 139 pub timestamp: DateTime<Utc>,
100 140 pub data: Option<serde_json::Value>,
@@ -141,3 +181,33 @@ pub struct SyncStatus {
141 181 pub total_changes: i64,
142 182 pub latest_cursor: Option<i64>,
143 183 }
184 +
185 + // ── Blobs ──
186 +
187 + #[derive(Serialize)]
188 + pub struct BlobUploadUrlRequest {
189 + pub hash: String,
190 + pub size_bytes: i64,
191 + }
192 +
193 + #[derive(Deserialize)]
194 + pub struct BlobUploadUrlResponse {
195 + pub upload_url: String,
196 + pub already_exists: bool,
197 + }
198 +
199 + #[derive(Serialize)]
200 + pub struct BlobConfirmRequest {
201 + pub hash: String,
202 + pub size_bytes: i64,
203 + }
204 +
205 + #[derive(Serialize)]
206 + pub(crate) struct BlobDownloadUrlRequest {
207 + pub hash: String,
208 + }
209 +
210 + #[derive(Deserialize)]
211 + pub(crate) struct BlobDownloadUrlResponse {
212 + pub download_url: String,
213 + }