//! HTTP transport and high-level API with transparent end-to-end encryption. //! //! This module provides [`SyncKitClient`], the primary interface to the MNW //! SyncKit server. All encryption and decryption happens transparently inside //! the client, callers work with plaintext [`ChangeEntry`] values and never //! handle ciphertext directly. //! //! ## Method groups //! //! - **Authentication**: [`authenticate`](SyncKitClient::authenticate) (email/password), //! [`authenticate_with_code`](SyncKitClient::authenticate_with_code) (OAuth2 PKCE), //! [`restore_session`](SyncKitClient::restore_session), [`clear_session`](SyncKitClient::clear_session). //! - **Encryption setup**: [`setup_encryption_new`](SyncKitClient::setup_encryption_new) (first device), //! [`setup_encryption_existing`](SyncKitClient::setup_encryption_existing) (subsequent devices), //! [`try_load_key_from_keychain`](SyncKitClient::try_load_key_from_keychain), //! [`change_password`](SyncKitClient::change_password). //! - **Device management**: [`register_device`](SyncKitClient::register_device), //! [`list_devices`](SyncKitClient::list_devices). //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull), //! [`status`](SyncKitClient::status). //! - **Blob storage**: [`blob_upload_streaming`](SyncKitClient::blob_upload_streaming) //! (file-backed, bounded memory, the path for large blobs), //! [`blob_upload_url`](SyncKitClient::blob_upload_url) + //! [`blob_upload`](SyncKitClient::blob_upload) (in-memory), //! [`blob_confirm`](SyncKitClient::blob_confirm), //! [`blob_download_url`](SyncKitClient::blob_download_url), //! [`blob_download`](SyncKitClient::blob_download). //! //! ## Internal state //! //! The client holds two `RwLock`-wrapped fields: the authenticated session //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both //! start as `None` and are populated by the authentication and encryption //! setup methods respectively. //! //! ## Thread safety //! //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public //! methods take `&self`, acquiring the internal locks only briefly to read //! or update state. The locks are never held across `.await` points. //! //! ## Retry strategy //! //! All HTTP operations retry transient failures (network errors, 5xx, //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors //! (4xx except 429) are permanent and returned immediately. //! //! ## Token handling //! //! The client decodes the JWT `exp` claim (without signature verification) //! and applies a 30-second expiry buffer. If the token is about to expire, //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller //! can re-authenticate before the request fails on the server. mod auth; mod blob; mod groups; pub use blob::BlobUploadOutcome; mod encryption; pub(crate) mod helpers; mod ota; mod rotation; mod subscribe; pub mod subscription; mod sync; pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease}; pub use subscribe::SyncNotifyStream; use parking_lot::RwLock; use reqwest::Client; use std::sync::Arc; use std::time::Duration; #[cfg(test)] use uuid::Uuid; use crate::{ crypto, error::{Result, SyncKitError}, ids::{AppId, GroupId, UserId}, }; /// Maximum number of retry attempts for transient failures. const MAX_RETRIES: u32 = 3; /// Base delay for exponential backoff (1s, 2s, 4s). const BASE_DELAY: Duration = Duration::from_secs(1); /// Seconds before actual expiry to consider the token expired. /// Avoids sending a request with a token that expires mid-flight. const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30; /// Inactivity (read) timeout for the streaming HTTP client. Bounds the gap /// between successive body reads without capping the total transfer, so a stalled /// (slow-loris) blob/OTA/SSE connection is torn down while a legitimately long, /// steady transfer is not. Kept above the server's 30s SSE keepalive interval so /// an idle notification stream is never mistaken for a stall. const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(90); /// Configuration for the SyncKit client. #[derive(Debug, Clone)] pub struct SyncKitConfig { /// Base URL of the MNW server (e.g. "https://makenot.work"). pub server_url: String, /// App API key (obtained from MNW dashboard). pub api_key: String, } /// Pre-built endpoint URLs, computed once at client construction. struct Endpoints { auth: String, oauth_token: String, devices: String, push: String, pull: String, subscribe: String, status: String, keys: String, groups_base: String, blobs_upload: String, blobs_confirm: String, blobs_download: String, blobs_multipart_start: String, blobs_multipart_parts: String, blobs_multipart_complete: String, blobs_multipart_abort: String, subscription: String, subscription_checkout: String, subscription_quote: String, subscription_storage_cap: String, app_pricing: String, account: String, /// Base for OTA paths (`{server}/api/v1/sync/ota`). The app-scoped and public /// OTA URLs carry runtime ids (app, release, slug/target/arch/version) so they /// cannot be pre-built like the static endpoints above; the `ota_*` builder /// methods construct them from this base, keeping *all* path construction in /// this one type instead of re-deriving it with `format!` in `ota.rs`. ota_base: String, } impl Endpoints { fn new(base: &str) -> Self { let base = base.trim_end_matches('/'); Self { auth: format!("{base}/api/v1/sync/auth"), oauth_token: format!("{base}/oauth/token"), devices: format!("{base}/api/v1/sync/devices"), push: format!("{base}/api/v1/sync/push"), pull: format!("{base}/api/v1/sync/pull"), subscribe: format!("{base}/api/v1/sync/subscribe"), status: format!("{base}/api/v1/sync/status"), keys: format!("{base}/api/v1/sync/keys"), blobs_upload: format!("{base}/api/v1/sync/blobs/upload"), blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"), blobs_download: format!("{base}/api/v1/sync/blobs/download"), blobs_multipart_start: format!("{base}/api/v1/sync/blobs/multipart/start"), blobs_multipart_parts: format!("{base}/api/v1/sync/blobs/multipart/parts"), blobs_multipart_complete: format!("{base}/api/v1/sync/blobs/multipart/complete"), blobs_multipart_abort: format!("{base}/api/v1/sync/blobs/multipart/abort"), subscription: format!("{base}/api/v1/sync/subscription"), subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"), subscription_quote: format!("{base}/api/v1/sync/subscription/quote"), subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"), app_pricing: format!("{base}/api/v1/sync/app/pricing"), account: format!("{base}/api/v1/sync/account"), ota_base: format!("{base}/api/v1/sync/ota"), groups_base: format!("{base}/api/v1/sync/groups"), } } /// `GET`/`POST` the group collection: list the caller's groups, or create one. fn groups(&self) -> &str { &self.groups_base } /// `GET` here for the caller's own sealed GCK grant for a group. fn group_grant(&self, group_id: GroupId) -> String { format!("{}/{group_id}/grant", self.groups_base) } /// `POST` here to add a member to a group (admin only). fn group_members(&self, group_id: GroupId) -> String { format!("{}/{group_id}/members", self.groups_base) } /// `DELETE` here to remove a member from a group (admin only). fn group_member(&self, group_id: GroupId, member: UserId) -> String { format!("{}/{group_id}/members/{member}", self.groups_base) } /// `POST` encrypted changes to a group's shared changelog. fn group_push(&self, group_id: GroupId) -> String { format!("{}/{group_id}/push", self.groups_base) } /// `POST` to pull a group's changes since a cursor. fn group_pull(&self, group_id: GroupId) -> String { format!("{}/{group_id}/pull", self.groups_base) } /// `POST` here to create a release, or list releases, for an app. fn ota_releases(&self, app_id: AppId) -> String { format!("{}/apps/{app_id}/releases", self.ota_base) } /// `POST` here to register an artifact under a release. fn ota_artifacts(&self, app_id: AppId, release_id: uuid::Uuid) -> String { format!( "{}/apps/{app_id}/releases/{release_id}/artifacts", self.ota_base ) } /// `POST` here to confirm an uploaded artifact under a release. fn ota_confirm(&self, app_id: AppId, release_id: uuid::Uuid) -> String { format!( "{}/apps/{app_id}/releases/{release_id}/artifacts/confirm", self.ota_base ) } /// The public, unauthenticated Tauri updater check URL. fn ota_updater(&self, slug: &str, target: &str, arch: &str, current_version: &str) -> String { format!("{}/{slug}/{target}/{arch}/{current_version}", self.ota_base) } } /// A bearer token whose bytes are scrubbed from memory when the last reference /// is dropped. /// /// The SDK holds the session's JWT for the lifetime of the session; wrapping it /// so its final drop zeroizes the heap keeps the credential from lingering after /// logout or session replacement. [`Deref`](std::ops::Deref) to `str` and /// [`Display`](std::fmt::Display) expose the raw value only where the HTTP layer /// needs it (the `Authorization: Bearer` header); [`Debug`](std::fmt::Debug) is /// redacted so the token cannot leak into a log line. pub struct SecretToken(String); impl SecretToken { pub(crate) fn new(token: String) -> Self { SecretToken(token) } /// The raw token string, for building the `Authorization` header. pub fn as_str(&self) -> &str { &self.0 } } impl std::ops::Deref for SecretToken { type Target = str; fn deref(&self) -> &str { &self.0 } } impl std::fmt::Display for SecretToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl std::fmt::Debug for SecretToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("SecretToken()") } } impl Drop for SecretToken { fn drop(&mut self) { use zeroize::Zeroize; self.0.zeroize(); } } /// Session state obtained after authentication. struct Session { token: Arc, /// Cached `exp` claim from the JWT, extracted once at session creation. token_exp: Option, user_id: UserId, app_id: AppId, } /// Public session info returned by `session_info()`. pub struct SessionInfo { /// The JWT bearer token for API requests (shared ref-counted to avoid /// cloning; zeroized when the last reference drops). pub token: Arc, /// The authenticated user's UUID. pub user_id: UserId, /// The SyncKit app UUID this session belongs to. pub app_id: AppId, } /// Info about a pending key rotation, cached from `GET /keys`. pub(crate) struct PendingKeyState { pub key: crypto::ZeroizeOnDrop, pub key_id: i32, } /// The SyncKit client. Handles authentication, encryption, and HTTP transport. pub struct SyncKitClient { config: SyncKitConfig, /// HTTP client for small JSON control-plane calls. Carries a whole-request /// timeout, those calls should never run long. http: Client, /// HTTP client for large-body and long-lived transfers (blob up/download, /// OTA artifacts, the SSE notification stream). Deliberately has NO /// whole-request timeout, a multi-gigabyte blob or an open push stream must /// not be torn down by a fixed clock, only a connect timeout. http_stream: Client, endpoints: Endpoints, session: RwLock>, master_key: RwLock>, /// The key_id associated with the current master_key. Default 1 (pre-rotation). master_key_id: RwLock, /// Pending rotation key, if a rotation is in progress. pending_key: RwLock>, /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version, /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key) /// from the sealed grant; a version mismatch (rotation) or a decrypt failure /// forces a re-fetch. Holds one entry per group, the current generation. gck_cache: RwLock>, } impl SyncKitClient { /// Create a new client with the given configuration. pub fn new(config: SyncKitConfig) -> Self { // reqwest is built with `rustls-no-provider`; a real consumer app installs the // process-wide crypto provider (audiofiles installs ring) before it ever builds // a client. Unit tests have no such app, so install ring here once, or the // client build below would panic. Never compiled into a consumer build. #[cfg(test)] { static PROVIDER: std::sync::Once = std::sync::Once::new(); PROVIDER.call_once(|| { let _ = rustls::crypto::ring::default_provider().install_default(); }); } let https_only = requires_https(&config.server_url); let http = Client::builder() .timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(10)) .pool_max_idle_per_host(5) .pool_idle_timeout(Duration::from_secs(90)) .https_only(https_only) .build() .expect("failed to build HTTP client"); // No whole-request timeout: this client carries multi-gigabyte blobs and // the long-lived SSE stream, which a fixed 30s cap would break. It DOES // carry a read (inactivity) timeout: without one, a server that dribbles // one byte an hour holds a blob/OTA/SSE connection open forever (slow- // loris). The cap bounds the gap *between* reads, not the total transfer, // so a legitimate slow-but-steady transfer is unaffected. 90s sits well // above the server's 30s SSE keepalive, so an idle notification stream // (which receives a keepalive comment every 30s) is never torn down. let http_stream = Client::builder() .connect_timeout(Duration::from_secs(10)) .read_timeout(STREAM_READ_TIMEOUT) .pool_max_idle_per_host(5) .pool_idle_timeout(Duration::from_secs(90)) .https_only(https_only) .build() .expect("failed to build streaming HTTP client"); let endpoints = Endpoints::new(&config.server_url); Self { config, http, http_stream, endpoints, session: RwLock::new(None), master_key: RwLock::new(None), master_key_id: RwLock::new(1), pending_key: RwLock::new(None), gck_cache: RwLock::new(std::collections::HashMap::new()), } } /// Create a new client with a custom HTTP client (for testing with custom timeouts). /// /// Test-only: gated behind the `testing` feature so consumer builds cannot /// substitute an unvalidated HTTP client. #[doc(hidden)] #[cfg(any(test, feature = "testing"))] pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self { let endpoints = Endpoints::new(&config.server_url); Self { config, http: http.clone(), http_stream: http, endpoints, session: RwLock::new(None), master_key: RwLock::new(None), master_key_id: RwLock::new(1), pending_key: RwLock::new(None), gck_cache: RwLock::new(std::collections::HashMap::new()), } } /// Returns the client configuration. pub fn config(&self) -> &SyncKitConfig { &self.config } /// Returns whether the master encryption key is loaded and ready. pub fn has_master_key(&self) -> bool { self.master_key.read().is_some() } /// Returns the current session info, if authenticated. pub fn session_info(&self) -> Option { let guard = self.session.read(); guard.as_ref().map(|s| SessionInfo { token: Arc::clone(&s.token), user_id: s.user_id, app_id: s.app_id, }) } /// Set a raw 256-bit master key directly (for testing without Argon2 overhead). /// /// Test-only: gated behind the `testing` feature so a consumer build has no /// chosen-key injection point that bypasses key derivation. #[doc(hidden)] #[cfg(any(test, feature = "testing"))] pub fn set_master_key_raw(&self, key: [u8; 32]) { *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key)); } // ── Internal helpers ── /// Extract the bearer token from the current session. /// /// Returns `NotAuthenticated` if no session exists. Also checks token /// expiry and returns `TokenExpired` if the JWT `exp` claim is within /// 30 seconds of the current time. pub(crate) fn require_token(&self) -> Result> { let guard = self.session.read(); let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?; if let Some(exp) = session.token_exp { let now = chrono::Utc::now().timestamp(); if now >= exp - TOKEN_EXPIRY_BUFFER_SECS { return Err(SyncKitError::TokenExpired); } } Ok(Arc::clone(&session.token)) } /// Extract `(app_id, user_id)` from the current session. /// /// Returns `NotAuthenticated` if no session exists. pub(crate) fn require_session_ids(&self) -> Result<(AppId, UserId)> { let guard = self.session.read(); guard .as_ref() .map(|s| (s.app_id, s.user_id)) .ok_or(SyncKitError::NotAuthenticated) } /// Return a copy of the 256-bit master encryption key, wrapped in /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`. /// /// Returns `NoMasterKey` if encryption has not been set up yet. pub(crate) fn require_master_key(&self) -> Result { let guard = self.master_key.read(); guard .as_ref() .map(|k| crypto::ZeroizeOnDrop(**k)) .ok_or(SyncKitError::NoMasterKey) } } /// Whether the HTTP client for `server_url` must enforce TLS (`https_only`). /// /// Production traffic carries the bearer sync token, so plaintext `http` to any /// non-loopback host is refused (defense-in-depth on top of normal cert /// validation). Loopback hosts stay exempt so local development and the /// mock-server test suite can use `http://127.0.0.1`. fn requires_https(server_url: &str) -> bool { let lower = server_url.trim().to_ascii_lowercase(); let Some(rest) = lower.strip_prefix("http://") else { // https (or any non-plaintext scheme): enforcing https_only is a no-op. return true; }; // Extract the host *exactly*. A prefix match (`starts_with("127.")`, // `starts_with("localhost")`) trusts `http://127.0.0.1.attacker.com` and // `http://localhost.evil.com`, hostnames that resolve to an attacker's IP, // and would send the bearer token to them in cleartext. Parse the authority, // strip the port, and require an exact `localhost` or an IP literal that is // genuinely loopback. `0.0.0.0` is the unspecified/bind-all address, not a // loopback you connect *to*, so `is_loopback()` correctly rejects it. let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); // Strip userinfo: everything up to the last `@` is credentials, not the host. // `http://127.0.0.1:80@evil.com` connects to evil.com, so a naive port-split // on the raw authority would read `127.0.0.1` and wrongly exempt it (the same // userinfo bypass class as the server-side SSRF finding). let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h); let host = if let Some(after) = authority.strip_prefix('[') { // IPv6 literal `[::1]:port`: the host is inside the brackets, and the only // thing allowed after `]` is a port. Anything else (`[::1].evil.com`) is // malformed, fail closed. match after.split_once(']') { Some((h, rest)) if rest.is_empty() || rest.starts_with(':') => h, _ => return true, } } else { // `host:port` or bare `host`, cut at the port separator. authority.split(':').next().unwrap_or(authority) }; let is_loopback = host == "localhost" || host .parse::() .is_ok_and(|ip| ip.is_loopback()); !is_loopback } /// Returns the app name on success, or an error if the key is invalid or the server /// is unreachable. This is intended for setup UIs that need to verify a key before /// saving it. #[tracing::instrument(skip(api_key))] pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result { let url = format!("{server_url}/api/v1/sync/validate-app"); let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .https_only(requires_https(server_url)) .build()?; let resp = http .post(&url) .header("content-type", "application/json") .body(serde_json::to_vec( &serde_json::json!({"api_key": api_key}), )?) .send() .await?; let status = resp.status().as_u16(); if status == 401 { return Err(SyncKitError::Server { status: 401, message: "Invalid API key".to_string(), retry_after_secs: None, }); } let resp = helpers::check_response(resp).await?; #[derive(serde::Deserialize)] struct ValidateResponse { app_name: String, } let body: ValidateResponse = crate::client::helpers::read_json_capped( resp, crate::client::helpers::MAX_CONTROL_BODY_BYTES, ) .await?; Ok(body.app_name) } #[cfg(test)] mod tests { use super::*; use base64::Engine; #[test] fn requires_https_enforces_tls_off_loopback() { // https or non-loopback http must enforce TLS. assert!(requires_https("https://makenot.work")); assert!(requires_https("http://makenot.work")); assert!(requires_https("http://192.168.1.5:7766")); // 0.0.0.0 is the unspecified address, not loopback, must enforce TLS. assert!(requires_https("http://0.0.0.0:8080")); // loopback is exempt (local dev + mock-server tests use http://127.0.0.1). assert!(!requires_https("http://127.0.0.1:8080")); assert!(!requires_https("http://localhost:3000")); assert!(!requires_https("http://[::1]:9000")); assert!(!requires_https("http://127.0.0.1")); assert!(!requires_https("http://127.5.6.7:80/path")); // 127/8 is all loopback } #[test] fn requires_https_rejects_loopback_prefix_spoofs() { // The exact-host fix: a hostname that merely starts with a loopback // string but resolves elsewhere must NOT be exempted from TLS, the old // prefix match sent the bearer token to these in cleartext. assert!(requires_https("http://127.0.0.1.attacker.com")); assert!(requires_https("http://127.0.0.1.attacker.com:8080/pull")); assert!(requires_https("http://localhost.evil.com")); assert!(requires_https("http://localhostx")); assert!(requires_https("http://[::1].evil.com")); // junk after the bracket // userinfo bypass: the real host is evil.com, not the loopback userinfo. assert!(requires_https("http://127.0.0.1:80@evil.com")); assert!(requires_https("http://localhost@evil.com/pull")); } fn test_config() -> SyncKitConfig { SyncKitConfig { server_url: "https://example.com".to_string(), api_key: "test-api-key-123".to_string(), } } fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) { ( crate::ids::AppId::new( Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), ), crate::ids::UserId::new( Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), ), ) } // ── SyncKitClient::new() construction ── #[test] fn new_client_starts_unauthenticated() { let client = SyncKitClient::new(test_config()); assert!(client.session_info().is_none()); } #[test] fn new_client_has_no_master_key() { let client = SyncKitClient::new(test_config()); assert!(!client.has_master_key()); } #[test] fn config_returns_provided_values() { let client = SyncKitClient::new(test_config()); assert_eq!(client.config().server_url, "https://example.com"); assert_eq!(client.config().api_key, "test-api-key-123"); } // ── SyncKitConfig ── #[test] fn config_clone() { let config = test_config(); let cloned = config.clone(); assert_eq!(cloned.server_url, config.server_url); assert_eq!(cloned.api_key, config.api_key); } #[test] fn config_debug() { let config = test_config(); let debug = format!("{config:?}"); assert!(debug.contains("SyncKitConfig")); assert!(debug.contains("example.com")); } // ── require_token ── #[test] fn require_token_fails_without_session() { let client = SyncKitClient::new(test_config()); let err = client.require_token().unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[test] fn require_token_succeeds_with_session() { let client = SyncKitClient::new(test_config()); let (app_id, user_id) = test_ids(); client.restore_session("my-token", user_id, app_id); let token = client.require_token().unwrap(); assert_eq!(token.as_str(), "my-token"); } #[test] fn secret_token_exposes_value_but_redacts_debug() { let t = SecretToken::new("super-secret-jwt".to_string()); // The raw value is reachable exactly where the HTTP layer needs it. assert_eq!(t.as_str(), "super-secret-jwt"); assert_eq!(&*t, "super-secret-jwt"); // Deref to str assert_eq!(t.to_string(), "super-secret-jwt"); // Display, for bearer_auth // ...but Debug must never spill it into a log line. let debug = format!("{t:?}"); assert!( !debug.contains("super-secret-jwt"), "Debug leaked the token: {debug}" ); assert!(debug.contains("redacted")); } // ── require_session_ids ── #[test] fn require_session_ids_fails_without_session() { let client = SyncKitClient::new(test_config()); let err = client.require_session_ids().unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[test] fn require_session_ids_returns_correct_ids() { let client = SyncKitClient::new(test_config()); let (app_id, user_id) = test_ids(); client.restore_session("token", user_id, app_id); let (returned_app, returned_user) = client.require_session_ids().unwrap(); assert_eq!(returned_app, app_id); assert_eq!(returned_user, user_id); } // ── require_master_key ── #[test] fn require_master_key_fails_without_key() { let client = SyncKitClient::new(test_config()); let err = client.require_master_key().unwrap_err(); assert!(matches!(err, SyncKitError::NoMasterKey)); } #[test] fn require_master_key_succeeds_after_set() { let client = SyncKitClient::new(test_config()); let test_key = [42u8; 32]; *client.master_key.write() = Some(crypto::ZeroizeOnDrop(test_key)); let key = client.require_master_key().unwrap(); assert_eq!(*key, test_key); } // ── has_master_key ── #[test] fn has_master_key_false_initially() { let client = SyncKitClient::new(test_config()); assert!(!client.has_master_key()); } #[test] fn has_master_key_true_after_set() { let client = SyncKitClient::new(test_config()); *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32])); assert!(client.has_master_key()); } // ── set_master_key_raw ── #[test] fn set_master_key_raw_makes_key_available() { let client = SyncKitClient::new(test_config()); assert!(!client.has_master_key()); let key = [99u8; 32]; client.set_master_key_raw(key); assert!(client.has_master_key()); assert_eq!(*client.require_master_key().unwrap(), key); } #[test] fn set_master_key_raw_overwrites_previous() { let client = SyncKitClient::new(test_config()); let key1 = [1u8; 32]; let key2 = [2u8; 32]; client.set_master_key_raw(key1); assert_eq!(*client.require_master_key().unwrap(), key1); client.set_master_key_raw(key2); assert_eq!(*client.require_master_key().unwrap(), key2); } // ── with_http_client constructor ── #[test] fn with_http_client_starts_unauthenticated() { let http = Client::builder() .timeout(Duration::from_millis(100)) .build() .unwrap(); let client = SyncKitClient::with_http_client(test_config(), http); assert!(client.session_info().is_none()); assert!(!client.has_master_key()); } // ── Send + Sync assertions ── #[test] fn client_is_send_and_sync() { fn assert_send_sync() {} assert_send_sync::(); } // ── Config edge case ── #[test] fn config_with_trailing_slash_url() { let config = SyncKitConfig { server_url: "https://example.com/".to_string(), api_key: "key".to_string(), }; let client = SyncKitClient::new(config); assert_eq!(client.config().server_url, "https://example.com/"); // Endpoints should not have double slashes assert_eq!( client.endpoints.auth, "https://example.com/api/v1/sync/auth" ); } // ── SyncKitError Display ── #[test] fn error_display_not_authenticated() { let err = SyncKitError::NotAuthenticated; assert!(err.to_string().contains("Not authenticated")); } #[test] fn error_display_no_master_key() { let err = SyncKitError::NoMasterKey; assert!(err.to_string().contains("Encryption not initialized")); } #[test] fn error_display_server() { let err = SyncKitError::Server { status: 500, message: "boom".to_string(), retry_after_secs: None, }; let msg = err.to_string(); assert!(msg.contains("500")); assert!(msg.contains("boom")); } #[test] fn error_display_decryption_failed() { let err = SyncKitError::DecryptionFailed; assert!(err.to_string().contains("Wrong password")); } #[test] fn error_display_invalid_envelope() { let err = SyncKitError::InvalidEnvelope("bad version".to_string()); let msg = err.to_string(); assert!(msg.contains("Invalid key envelope")); assert!(msg.contains("bad version")); } #[test] fn error_display_crypto() { let err = SyncKitError::Crypto("aead failed".to_string()); let msg = err.to_string(); assert!(msg.contains("Encryption error")); assert!(msg.contains("aead failed")); } #[test] fn error_display_token_expired() { let err = SyncKitError::TokenExpired; assert!(err.to_string().contains("Token expired")); } // ── SyncKitError conversions ── #[test] fn error_from_serde_json() { let err: SyncKitError = serde_json::from_str::("{{bad}}") .unwrap_err() .into(); assert!(matches!(err, SyncKitError::Json(_))); assert!(err.to_string().contains("JSON")); } #[test] fn error_from_base64() { let err: SyncKitError = base64::engine::general_purpose::STANDARD .decode("!!!bad!!!") .unwrap_err() .into(); assert!(matches!(err, SyncKitError::Base64(_))); assert!(err.to_string().contains("Base64")); } #[test] fn error_internal_contains_message() { let err = SyncKitError::Internal("test internal error".to_string()); assert!(err.to_string().contains("test internal error")); } }