max / synckit-client
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
19 files changed,
+1721 insertions,
-236 deletions
| @@ -1467,6 +1467,7 @@ | |||
| 1467 | 1467 | "serde_json", | |
| 1468 | 1468 | "thiserror", | |
| 1469 | 1469 | "tokio", | |
| 1470 | + | "tokio-stream", | |
| 1470 | 1471 | "tracing", | |
| 1471 | 1472 | "unicode-normalization", | |
| 1472 | 1473 | "urlencoding", | |
| @@ -1611,6 +1612,17 @@ | |||
| 1611 | 1612 | "tokio", | |
| 1612 | 1613 | ] | |
| 1613 | 1614 | ||
| 1615 | + | [[package]] | |
| 1616 | + | name = "tokio-stream" | |
| 1617 | + | version = "0.1.18" | |
| 1618 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1619 | + | checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" | |
| 1620 | + | dependencies = [ | |
| 1621 | + | "futures-core", | |
| 1622 | + | "pin-project-lite", | |
| 1623 | + | "tokio", | |
| 1624 | + | ] | |
| 1625 | + | ||
| 1614 | 1626 | [[package]] | |
| 1615 | 1627 | name = "tokio-util" | |
| 1616 | 1628 | version = "0.7.18" |
| @@ -21,6 +21,7 @@ | |||
| 21 | 21 | reqwest = { version = "0.12", features = ["json", "native-tls"] } | |
| 22 | 22 | bytes = "1" | |
| 23 | 23 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } | |
| 24 | + | tokio-stream = "0.1" | |
| 24 | 25 | ||
| 25 | 26 | # Serialization | |
| 26 | 27 | serde = { version = "1", features = ["derive"] } |
| @@ -94,100 +94,6 @@ | |||
| 94 | 94 | | 2026-03-18 (Run 9) | 4,327 | 6 | 298 | ~69 | | |
| 95 | 95 | | 2026-03-28 (Run 12) | 4,327 | 6 | 297 | ~69 | | |
| 96 | 96 | ||
| 97 | - | ## Changes Since Last Audit | |
| 97 | + | --- | |
| 98 | 98 | ||
| 99 | - | ### Seventh audit (2026-03-28, Run 12 cross-project) | |
| 100 | - | - **Test count:** 297 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. 0 failures. | |
| 101 | - | - **Grade:** A (maintained). v0.3.0. | |
| 102 | - | - **No code changes since Run 9.** | |
| 103 | - | - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`. | |
| 104 | - | - **Mandatory surprise:** None new. Previous surprise (fresh random Argon2 salt per wrap) still valid and impressive. | |
| 105 | - | - **No new findings.** All previous items remain resolved. | |
| 106 | - | ||
| 107 | - | ### Rust Patterns Audit (2026-03-21) | |
| 108 | - | - `SessionInfo.token` changed from `String` to `Arc<String>` -- `Arc::clone` instead of String clone | |
| 109 | - | - Auth request structs already use `&'a str` -- confirmed optimal, no change needed | |
| 110 | - | ||
| 111 | - | ### Sixth audit (2026-03-18, Run 9 cross-project) | |
| 112 | - | - **Test count:** 298 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. | |
| 113 | - | - **Grade:** A (maintained). v0.3.0. | |
| 114 | - | - **No new findings.** All previous items remain resolved. | |
| 115 | - | - **Crypto audit notes:** XChaCha20-Poly1305, Argon2id with OWASP minimums, ZeroizeOnDrop keys, NFC normalization. 100+ crypto-specific tests. | |
| 116 | - | - **No sensitive data in logs:** Confirmed — tracing calls log events (e.g., "Master key generated") without leaking key material or passwords. | |
| 117 | - | - **Mandatory surprise:** Argon2 salt uniqueness — every wrap generates fresh random salt (crypto.rs:130-131). Verified by `two_wraps_use_different_salts` test. Correct design, uncommon rigor. | |
| 118 | - | ||
| 119 | - | ### Concurrency Upgrade (2026-03-13) | |
| 120 | - | - **Concurrency:** B+ -> A- | |
| 121 | - | - Replaced std::sync::RwLock with parking_lot::RwLock. Removed 16 poison-handling .map_err() sites. All 234 tests pass. | |
| 122 | - | ||
| 123 | - | ### Second audit (2026-03-13, pre-launch skeptical lens) | |
| 124 | - | - **Grade:** B+ (maintained). S4 fixes resolved 4/6 first-audit issues. New critical finding: blob data not encrypted. | |
| 125 | - | - **Test count:** 13 -> 109 (+96 tests, mostly from S4 remediation) | |
| 126 | - | - **S4 fixes:** await_holding_lock, HTTP timeouts, retry with backoff, token expiry detection, client.rs tests (66), keystore.rs tests (18), ChangeOp enum | |
| 127 | - | - **New findings:** Blob encryption gap (CRITICAL), no key rotation, Mutex unwraps, master key copies not zeroized, public types that should be pub(crate) | |
| 128 | - | - **Deterministic Argon2 salt:** Persists from first audit (tracked in MNW todo as "consider random salt") | |
| 129 | - | ||
| 130 | - | ### Post-audit remediation (2026-03-13) | |
| 131 | - | - **Grade:** B+ -> A-. 5 of 6 new findings from second audit resolved. Only key rotation deferred. | |
| 132 | - | - **Test count:** 109 -> 118 (+9 tests: 7 blob encrypt/decrypt, 2 salt tests) | |
| 133 | - | - **Blob encryption:** encrypt_bytes/decrypt_bytes in crypto.rs. blob_upload/blob_download encrypt/decrypt transparently using master key. 40-byte overhead (24 nonce + 16 tag). | |
| 134 | - | - **Random Argon2 salt:** wrap_master_key generates random 32-byte salt per operation. unwrap_master_key reads salt from envelope. Eliminates deterministic salt precomputation risk. | |
| 135 | - | - **Previous S4 fixes verified:** Mutex unwraps, ZeroizeOnDrop, pub(crate) restrictions -- all still in place. | |
| 136 | - | - **Key rotation:** Deferred post-beta. Requires server-side re-encryption of all sync_log entries. | |
| 137 | - | - Documentation upgraded to A: Device/SyncStatus/ChangeEntry/BlobUploadUrlResponse field docs added. All 12 error variants documented with when-they-occur. Keystore platform behavior documented (macOS/Linux/Windows backends). Client helpers documented (require_token, require_session_ids, etc). SessionInfo field docs. client.rs module doc expanded to 50 lines. architecture.md created (217 lines), README created (78 lines). | |
| 138 | - | ||
| 139 | - | ### Observability Upgrade (2026-03-13) | |
| 140 | - | - Added Observability dimension to scorecard (grade A) | |
| 141 | - | - Added 16 `#[instrument]` annotations to all pub async methods in client.rs with appropriate skip params | |
| 142 | - | - Sensitive params skipped: password, old_password, new_password, email, code, code_verifier, presigned_url, data | |
| 143 | - | - `use tracing::instrument;` import added to client.rs | |
| 144 | - | - `cargo check` passes clean | |
| 145 | - | ||
| 146 | - | ### Performance Upgrade (2026-03-13) | |
| 147 | - | - **Performance:** B -> A- | |
| 148 | - | - Cached JWT `exp` claim in Session struct — `require_token()` and `is_token_expired()` no longer re-parse the JWT on every call | |
| 149 | - | - Retry request bodies use `bytes::Bytes` instead of `Vec<u8>` — clone in retry closures is O(1) refcount bump, not O(n) copy (10 sites) | |
| 150 | - | - Batch encrypt/decrypt in `push()`/`pull()` extracts master key once before the loop instead of per-entry lock acquisition | |
| 151 | - | ||
| 152 | - | ### Resilience Upgrade (2026-03-13) | |
| 153 | - | - **Resilience:** B- -> A- | |
| 154 | - | - Added 9 integration tests for encryption setup flows: `setup_encryption_new` (happy path, no-auth, server retry), `setup_encryption_existing` (happy path, wrong password, no-auth, server retry, missing key 404), cross-device encryption roundtrip | |
| 155 | - | - Cross-device test proves full two-device flow: device 1 creates encryption → pushes data → device 2 recovers key → pulls and decrypts successfully | |
| 156 | - | - **Test count:** 234 -> 243 (+9 integration tests). 170 unit + 72 integration + 1 doctest. | |
| 157 | - | ||
| 158 | - | ### Adversarial Test Audit (2026-03-13) | |
| 159 | - | - **Grade:** A- -> A-. Testing grade upgraded from B- to A. | |
| 160 | - | - **Test count:** 150 -> 234 (+84 tests: 52 unit, 32 integration). Test density ~94 tests/KLOC. | |
| 161 | - | - **CRITICAL fix: change_password bypass** -- Old password verification skipped when master key was cached in memory. Attacker with session access (stolen device, malware) could change encryption password without knowing the old one. Fixed: always verify old password against server envelope regardless of cache state. Added 8 tests covering cache hit/miss, wrong old password, concurrent password changes. | |
| 162 | - | - **HIGH fix: Unicode password normalization** -- NFC vs NFD normalization inconsistency across operating systems could derive different keys from "same" password (e.g., é as single codepoint vs e+combining-acute). Added `unicode-normalization` crate, NFC normalization before all key derivation (wrap_master_key, unwrap_master_key, change_password). 4 tests covering NFC/NFD/mixed inputs. | |
| 163 | - | - **Empty password rejection** -- wrap_master_key, unwrap_master_key, change_password now return error on empty password. 3 tests. | |
| 164 | - | - **Password length limits** -- 1024-byte max after UTF-8 encoding. Prevents resource exhaustion on Argon2 (linear memory cost with input length). 2 tests. | |
| 165 | - | - **Comprehensive crypto tests** -- Tamper detection (flip bits in nonce/ciphertext/tag), envelope validation (version mismatch, truncated fields), key rotation simulation (decrypt with wrong key), concurrent encryption (nonce uniqueness under load), large payload handling (1MB encrypt/decrypt). 28 new crypto unit tests. | |
| 166 | - | - **Integration tests** -- Error mapping for all 4xx/5xx codes (400/401/403/404/409/413/429/500/502/503), retry behavior (transient vs permanent), auth enforcement (missing token, invalid token, expired token), blob roundtrips (upload -> download, tamper detection, decrypt failure), malformed response handling (invalid JSON, missing fields). 32 new integration tests. | |
| 167 | - | - **Concurrency tests** -- Parallel encrypt operations (nonce uniqueness), concurrent password changes (last-write-wins, cache invalidation), device registration race (409 conflict), push/pull interleaving (optimistic locking). 9 tests across unit and integration. | |
| 168 | - | - **Resolved findings:** All 2 critical vulnerabilities from adversarial audit fixed. No new security issues discovered. | |
| 169 | - | ||
| 170 | - | ### Third audit (2026-03-16, Run 6 cross-project) | |
| 171 | - | - **Test count:** 297 (unchanged) | |
| 172 | - | - **Grade:** A (maintained). | |
| 173 | - | - **Source LOC:** 4,327 src + 2,749 test | |
| 174 | - | - **New finding (MEDIUM):** Wrapping key in `crypto.rs:99` (`derive_wrapping_key`) is computed on the stack but not wrapped in `ZeroizeOnDrop`. Intermediate key material sits in memory after function returns. Other keys properly use ZeroizeOnDrop. | |
| 175 | - | - **New finding (LOW):** Unused `sha2` dependency in Cargo.toml. | |
| 176 | - | - **Mandatory surprise:** Wrapping key not zeroized — Genuine issue (MEDIUM). | |
| 177 | - | - **Previous items verified:** All previous remediated items confirmed intact. Key rotation still deferred (post-beta). | |
| 178 | - | ||
| 179 | - | ### Testing Push (2026-03-13) | |
| 180 | - | - **Grade:** A- -> A. Testing A -> A+. Code Quality, Type Safety, Concurrency, Resilience all upgraded to A. | |
| 181 | - | - **Test count:** 243 -> 297 (+54 tests). 197 unit + 99 integration + 1 doctest. | |
| 182 | - | - **types.rs:** 13 unit tests added. Serde roundtrip, Display/serde consistency, from_str_opt edge cases, Copy/Hash trait verification, skip_serializing_if, extra unknown fields tolerance, ISO timestamp deserialization. | |
| 183 | - | - **error.rs:** 10 unit tests added. Send+Sync compile-time assert, Display for all 8 variants, Debug no-panic, source() chain verification (Json, Base64 have source; leaf variants do not), empty/very-long server messages. | |
| 184 | - | - **client.rs:** 4 unit tests added. SyncKitClient Send+Sync compile-time assert, with_http_client constructor, unicode table name encrypt/decrypt roundtrip, empty row_id roundtrip. | |
| 185 | - | - **Integration tests:** 27 new. Retry count verification (exhaustion at 4 requests, 404 not retried, 3rd-attempt success). Malformed responses (HTML body, empty body, missing has_more, wrong cursor type, missing app_id, missing already_exists, 413 error, extra fields ignored). Session edge cases (double authenticate, clear then re-auth, expired token on restore). Encryption setup overwrite. Blob edge cases (confirm retry, download retry, 1MB upload overhead). Device edge cases (empty name, unicode name, empty list). Concurrency stress (50 concurrent session_info reads, 50 has_master_key reads, 20 status checks, 4x100-entry pushes). Timeout tests (slow server timeout, retry after timeout). | |
| 186 | - | - **New constructor:** `with_http_client(config, client)` enables custom timeout testing without modifying production defaults. | |
| 187 | - | ||
| 188 | - | ### Performance Upgrade (2026-03-13) | |
| 189 | - | - **Performance:** A- -> A | |
| 190 | - | - Pre-built endpoint URLs: new `Endpoints` struct computes all 10 API endpoint URLs once at client construction, eliminating per-request `format!()` string allocations | |
| 191 | - | - `Arc<String>` session token: `require_token()` returns `Arc<String>` instead of `String`, making per-request token extraction O(1) refcount bump instead of O(n) string clone (~300-500 byte JWT) | |
| 192 | - | - `key_url_and_token()` returns `(&str, Arc<String>)` instead of `(String, String)`, zero allocations per call | |
| 193 | - | - All 297 tests pass unchanged (2 test assertions updated for Arc deref) | |
| 99 | + | See [audit_history.md](./audit_history.md) for full chronological audit log. |
| @@ -1,18 +1,19 @@ | |||
| 1 | 1 | # SyncKit Client SDK — Todo | |
| 2 | 2 | ||
| 3 | - | Done: All S1-S5 phases complete. All audit items resolved (Run 6 + Run 7). 297 tests + 1 doctest. client/ split into directory module (6 files). | |
| 3 | + | Done: All phases (S1-S5). Active: None. Next: Post-beta items below. | |
| 4 | 4 | ||
| 5 | - | Completed work archived in `docs/shared/synckit/audit_review.md` (Rust Patterns Audit section). | |
| 6 | - | ||
| 7 | - | ## Remaining | |
| 8 | - | - [ ] Write `competition.md` (competitive analysis vs Firebase, Supabase, Realm, CloudKit, etc.) | |
| 5 | + | v0.3.0. Audit grade A. 304 tests. | |
| 9 | 6 | ||
| 10 | 7 | ## Deferred (Post-Beta) | |
| 8 | + | - [ ] Conflict resolution helpers — LWW, field-level merge, custom resolver callback in the SDK. Reduces client-side boilerplate. (Gap vs Ditto, Couchbase) | |
| 11 | 9 | - [ ] Key rotation mechanism (requires server-side re-encryption of all sync_log entries) | |
| 12 | 10 | ||
| 11 | + | - [ ] WASM web client — compile SyncKit to WASM for browser use. Only if a consumer app ships a web companion. | |
| 12 | + | - [ ] C FFI layer — enables Swift/Kotlin/Python bindings. Only if non-Tauri consumers appear. | |
| 13 | + | ||
| 13 | 14 | ## Key Paths | |
| 14 | - | - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, blob, helpers) | |
| 15 | + | - Client: `Shared/synckit-client/src/client/` (mod, auth, encryption, sync, subscribe, blob, helpers) | |
| 15 | 16 | - Crypto: `Shared/synckit-client/src/crypto.rs` | |
| 16 | - | - Types: `Shared/synckit-client/src/types.rs` | |
| 17 | + | - Types: `Shared/synckit-client/src/types.rs` (includes PullFilter, FilteredPullRequest) | |
| 17 | 18 | - Keystore: `Shared/synckit-client/src/keystore.rs` | |
| 18 | 19 | - Tests: `Shared/synckit-client/tests/integration.rs` |
| @@ -60,6 +60,13 @@ | |||
| 60 | 60 | Keychain(String), | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | #[cfg(feature = "keychain")] | |
| 64 | + | impl From<keyring::Error> for SyncKitError { | |
| 65 | + | fn from(e: keyring::Error) -> Self { | |
| 66 | + | SyncKitError::Keychain(e.to_string()) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 63 | 70 | /// Convenience alias. | |
| 64 | 71 | pub type Result<T> = std::result::Result<T, SyncKitError>; | |
| 65 | 72 |
| @@ -36,13 +36,9 @@ | |||
| 36 | 36 | /// Store the master key in the OS keychain. | |
| 37 | 37 | #[cfg(feature = "keychain")] | |
| 38 | 38 | pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<()> { | |
| 39 | - | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id)) | |
| 40 | - | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 41 | - | ||
| 39 | + | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 42 | 40 | let encoded = B64.encode(master_key); | |
| 43 | - | entry | |
| 44 | - | .set_password(&encoded) | |
| 45 | - | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 41 | + | entry.set_password(&encoded)?; | |
| 46 | 42 | ||
| 47 | 43 | tracing::debug!("Master key stored in OS keychain"); | |
| 48 | 44 | Ok(()) | |
| @@ -52,8 +48,7 @@ | |||
| 52 | 48 | /// Returns None if no key is stored (not an error). | |
| 53 | 49 | #[cfg(feature = "keychain")] | |
| 54 | 50 | pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> { | |
| 55 | - | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id)) | |
| 56 | - | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 51 | + | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 57 | 52 | ||
| 58 | 53 | match entry.get_password() { | |
| 59 | 54 | Ok(encoded) => { | |
| @@ -69,15 +64,14 @@ | |||
| 69 | 64 | Ok(Some(key)) | |
| 70 | 65 | } | |
| 71 | 66 | Err(keyring::Error::NoEntry) => Ok(None), | |
| 72 | - | Err(e) => Err(SyncKitError::Keychain(e.to_string())), | |
| 67 | + | Err(e) => Err(e.into()), | |
| 73 | 68 | } | |
| 74 | 69 | } | |
| 75 | 70 | ||
| 76 | 71 | /// Delete the master key from the OS keychain. | |
| 77 | 72 | #[cfg(feature = "keychain")] | |
| 78 | 73 | pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> { | |
| 79 | - | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id)) | |
| 80 | - | .map_err(|e| SyncKitError::Keychain(e.to_string()))?; | |
| 74 | + | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 81 | 75 | ||
| 82 | 76 | match entry.delete_credential() { | |
| 83 | 77 | Ok(()) => { | |
| @@ -85,7 +79,7 @@ | |||
| 85 | 79 | Ok(()) | |
| 86 | 80 | } | |
| 87 | 81 | Err(keyring::Error::NoEntry) => Ok(()), // Already gone | |
| 88 | - | Err(e) => Err(SyncKitError::Keychain(e.to_string())), | |
| 82 | + | Err(e) => Err(e.into()), | |
| 89 | 83 | } | |
| 90 | 84 | } | |
| 91 | 85 |
| @@ -42,12 +42,16 @@ | |||
| 42 | 42 | //! ``` | |
| 43 | 43 | ||
| 44 | 44 | pub mod client; | |
| 45 | + | pub mod conflict; | |
| 45 | 46 | pub mod crypto; | |
| 46 | 47 | pub mod error; | |
| 47 | 48 | pub mod keystore; | |
| 48 | 49 | pub mod types; | |
| 49 | 50 | ||
| 50 | 51 | // Re-exports for convenience | |
| 51 | - | pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig}; | |
| 52 | + | pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream}; | |
| 53 | + | pub use conflict::{ | |
| 54 | + | detect_conflicts, resolve_field_merge, resolve_lww, ConflictPair, ConflictResolver, Resolution, | |
| 55 | + | }; | |
| 52 | 56 | pub use error::{Result, SyncKitError}; | |
| 53 | - | pub use types::{ChangeEntry, ChangeOp, Device, SyncStatus}; | |
| 57 | + | pub use types::{ChangeEntry, ChangeOp, Device, PullFilter, PulledChange, SyncStatus}; |
| @@ -160,6 +160,50 @@ | |||
| 160 | 160 | pub data: Option<serde_json::Value>, | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | + | // ── Filtered pull ── | |
| 164 | + | ||
| 165 | + | /// Optional filters for [`SyncKitClient::pull_filtered`]. | |
| 166 | + | /// | |
| 167 | + | /// Both fields are optional and compose with AND. An empty/default filter | |
| 168 | + | /// is equivalent to an unfiltered pull. | |
| 169 | + | #[derive(Debug, Clone, Default, Serialize)] | |
| 170 | + | pub struct PullFilter { | |
| 171 | + | /// Only return entries for these table names. | |
| 172 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 173 | + | pub tables: Option<Vec<String>>, | |
| 174 | + | /// Only return entries with `client_timestamp >= since`. | |
| 175 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 176 | + | pub since: Option<DateTime<Utc>>, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[derive(Serialize)] | |
| 180 | + | pub(crate) struct FilteredPullRequest { | |
| 181 | + | pub device_id: Uuid, | |
| 182 | + | pub cursor: i64, | |
| 183 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 184 | + | pub tables: Option<Vec<String>>, | |
| 185 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 186 | + | pub since: Option<DateTime<Utc>>, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // ── Pulled change (rich metadata) ── | |
| 190 | + | ||
| 191 | + | /// A change entry from pull with server metadata preserved. | |
| 192 | + | /// | |
| 193 | + | /// Wraps [`ChangeEntry`] with `device_id` and `seq` fields that are normally | |
| 194 | + | /// discarded during `pull()`. Used by [`SyncKitClient::pull_rich`] for | |
| 195 | + | /// conflict detection — the `device_id` identifies whether a change came from | |
| 196 | + | /// another device, and `seq` provides total server ordering. | |
| 197 | + | #[derive(Debug, Clone)] | |
| 198 | + | pub struct PulledChange { | |
| 199 | + | /// The decrypted change entry. | |
| 200 | + | pub entry: ChangeEntry, | |
| 201 | + | /// The device that originated this change. | |
| 202 | + | pub device_id: Uuid, | |
| 203 | + | /// Server sequence number (total ordering). | |
| 204 | + | pub seq: i64, | |
| 205 | + | } | |
| 206 | + | ||
| 163 | 207 | // ── Keys ── | |
| 164 | 208 | ||
| 165 | 209 | #[derive(Serialize)] | |
| @@ -382,4 +426,43 @@ | |||
| 382 | 426 | assert_eq!(format!("{:?}", ChangeOp::Update), "Update"); | |
| 383 | 427 | assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete"); | |
| 384 | 428 | } | |
| 429 | + | ||
| 430 | + | // ── PullFilter ── | |
| 431 | + | ||
| 432 | + | #[test] | |
| 433 | + | fn pull_filter_serialization_with_both_fields() { | |
| 434 | + | let filter = PullFilter { | |
| 435 | + | tables: Some(vec!["tasks".to_string(), "events".to_string()]), | |
| 436 | + | since: Some("2025-06-15T12:00:00Z".parse().unwrap()), | |
| 437 | + | }; | |
| 438 | + | let json = serde_json::to_string(&filter).unwrap(); | |
| 439 | + | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); | |
| 440 | + | assert_eq!(parsed["tables"].as_array().unwrap().len(), 2); | |
| 441 | + | assert!(parsed["since"].is_string()); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | #[test] | |
| 445 | + | fn pull_filter_serialization_with_none_fields() { | |
| 446 | + | let filter = PullFilter::default(); | |
| 447 | + | let json = serde_json::to_string(&filter).unwrap(); | |
| 448 | + | // None fields should be omitted entirely | |
| 449 | + | assert!(!json.contains("tables")); | |
| 450 | + | assert!(!json.contains("since")); | |
| 451 | + | assert_eq!(json, "{}"); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[test] | |
| 455 | + | fn filtered_pull_request_includes_filter_fields() { | |
| 456 | + | let req = FilteredPullRequest { | |
| 457 | + | device_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 458 | + | cursor: 42, | |
| 459 | + | tables: Some(vec!["tasks".to_string()]), | |
| 460 | + | since: Some("2025-01-01T00:00:00Z".parse().unwrap()), | |
| 461 | + | }; | |
| 462 | + | let json = serde_json::to_string(&req).unwrap(); | |
| 463 | + | let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); | |
| 464 | + | assert_eq!(parsed["cursor"], 42); | |
| 465 | + | assert_eq!(parsed["tables"].as_array().unwrap().len(), 1); | |
| 466 | + | assert!(parsed["since"].is_string()); | |
| 467 | + | } | |
| 385 | 468 | } |
| @@ -54,8 +54,7 @@ | |||
| 54 | 54 | let client = client_for(server); | |
| 55 | 55 | let (user_id, app_id) = test_ids(); | |
| 56 | 56 | client | |
| 57 | - | .restore_session(&fresh_token(), user_id, app_id) | |
| 58 | - | .unwrap(); | |
| 57 | + | .restore_session(&fresh_token(), user_id, app_id); | |
| 59 | 58 | client | |
| 60 | 59 | } | |
| 61 | 60 | ||
| @@ -99,7 +98,7 @@ | |||
| 99 | 98 | .await | |
| 100 | 99 | .unwrap(); | |
| 101 | 100 | ||
| 102 | - | let info = client.session_info().unwrap().expect("session stored"); | |
| 101 | + | let info = client.session_info().expect("session stored"); | |
| 103 | 102 | assert_eq!(info.user_id, user_id); | |
| 104 | 103 | assert_eq!(info.app_id, app_id); | |
| 105 | 104 | } | |
| @@ -174,7 +173,7 @@ | |||
| 174 | 173 | ||
| 175 | 174 | assert_eq!(uid, user_id); | |
| 176 | 175 | assert_eq!(aid, app_id); | |
| 177 | - | assert!(client.session_info().unwrap().is_some()); | |
| 176 | + | assert!(client.session_info().is_some()); | |
| 178 | 177 | } | |
| 179 | 178 | ||
| 180 | 179 | // ── Device management ── | |
| @@ -246,7 +245,7 @@ | |||
| 246 | 245 | ||
| 247 | 246 | let client = authed_client(&server); | |
| 248 | 247 | let key = synckit_client::crypto::generate_master_key(); | |
| 249 | - | client.set_master_key_raw(key).unwrap(); | |
| 248 | + | client.set_master_key_raw(key); | |
| 250 | 249 | ||
| 251 | 250 | let device_id = Uuid::new_v4(); | |
| 252 | 251 | let cursor = client | |
| @@ -285,7 +284,7 @@ | |||
| 285 | 284 | ||
| 286 | 285 | let client = authed_client(&server); | |
| 287 | 286 | let key = synckit_client::crypto::generate_master_key(); | |
| 288 | - | client.set_master_key_raw(key).unwrap(); | |
| 287 | + | client.set_master_key_raw(key); | |
| 289 | 288 | ||
| 290 | 289 | // Encrypt a value to simulate what the server would return | |
| 291 | 290 | let plaintext = json!({"title": "Decrypted task"}); | |
| @@ -336,7 +335,7 @@ | |||
| 336 | 335 | ||
| 337 | 336 | let client = authed_client(&server); | |
| 338 | 337 | let key = synckit_client::crypto::generate_master_key(); | |
| 339 | - | client.set_master_key_raw(key).unwrap(); | |
| 338 | + | client.set_master_key_raw(key); | |
| 340 | 339 | ||
| 341 | 340 | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 342 | 341 | assert_eq!(cursor, 5); | |
| @@ -355,7 +354,7 @@ | |||
| 355 | 354 | ||
| 356 | 355 | let client = authed_client(&server); | |
| 357 | 356 | let key = synckit_client::crypto::generate_master_key(); | |
| 358 | - | client.set_master_key_raw(key).unwrap(); | |
| 357 | + | client.set_master_key_raw(key); | |
| 359 | 358 | ||
| 360 | 359 | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 361 | 360 | assert!(matches!(err, SyncKitError::Server { status: 401, .. })); | |
| @@ -367,7 +366,7 @@ | |||
| 367 | 366 | ||
| 368 | 367 | let client = authed_client(&server); | |
| 369 | 368 | let key = synckit_client::crypto::generate_master_key(); | |
| 370 | - | client.set_master_key_raw(key).unwrap(); | |
| 369 | + | client.set_master_key_raw(key); | |
| 371 | 370 | ||
| 372 | 371 | let device_id = Uuid::new_v4(); | |
| 373 | 372 | ||
| @@ -438,7 +437,7 @@ | |||
| 438 | 437 | ||
| 439 | 438 | let client = authed_client(&server); | |
| 440 | 439 | let key = synckit_client::crypto::generate_master_key(); | |
| 441 | - | client.set_master_key_raw(key).unwrap(); | |
| 440 | + | client.set_master_key_raw(key); | |
| 442 | 441 | ||
| 443 | 442 | let plaintext = b"hello blob data"; | |
| 444 | 443 | let presigned = format!("{}{}", server.uri(), upload_path); | |
| @@ -470,7 +469,7 @@ | |||
| 470 | 469 | ||
| 471 | 470 | let client = authed_client(&server); | |
| 472 | 471 | let key = synckit_client::crypto::generate_master_key(); | |
| 473 | - | client.set_master_key_raw(key).unwrap(); | |
| 472 | + | client.set_master_key_raw(key); | |
| 474 | 473 | ||
| 475 | 474 | // Encrypt data to simulate what S3 would return | |
| 476 | 475 | let plaintext = b"decrypted blob content"; | |
| @@ -508,7 +507,7 @@ | |||
| 508 | 507 | ||
| 509 | 508 | let client = authed_client(&server); | |
| 510 | 509 | let key = synckit_client::crypto::generate_master_key(); | |
| 511 | - | client.set_master_key_raw(key).unwrap(); | |
| 510 | + | client.set_master_key_raw(key); | |
| 512 | 511 | ||
| 513 | 512 | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 514 | 513 | let result = client.blob_upload(&presigned, b"data".to_vec()).await; | |
| @@ -524,7 +523,7 @@ | |||
| 524 | 523 | let (user_id, app_id) = test_ids(); | |
| 525 | 524 | ||
| 526 | 525 | let expired = fake_jwt(Utc::now().timestamp() - 3600); | |
| 527 | - | client.restore_session(&expired, user_id, app_id).unwrap(); | |
| 526 | + | client.restore_session(&expired, user_id, app_id); | |
| 528 | 527 | ||
| 529 | 528 | let err = client.status().await.unwrap_err(); | |
| 530 | 529 | assert!( | |
| @@ -542,8 +541,7 @@ | |||
| 542 | 541 | // Token expires in 10 seconds (within 30-second buffer) | |
| 543 | 542 | let near_expiry = fake_jwt(Utc::now().timestamp() + 10); | |
| 544 | 543 | client | |
| 545 | - | .restore_session(&near_expiry, user_id, app_id) | |
| 546 | - | .unwrap(); | |
| 544 | + | .restore_session(&near_expiry, user_id, app_id); | |
| 547 | 545 | ||
| 548 | 546 | let err = client.status().await.unwrap_err(); | |
| 549 | 547 | assert!( | |
| @@ -574,7 +572,7 @@ | |||
| 574 | 572 | assert_eq!(status.total_changes, 0); | |
| 575 | 573 | ||
| 576 | 574 | // Clear session | |
| 577 | - | client.clear_session().unwrap(); | |
| 575 | + | client.clear_session(); | |
| 578 | 576 | ||
| 579 | 577 | // Now should fail | |
| 580 | 578 | let err = client.status().await.unwrap_err(); | |
| @@ -681,7 +679,7 @@ | |||
| 681 | 679 | ||
| 682 | 680 | let client = Arc::new(authed_client(&server)); | |
| 683 | 681 | let key = synckit_client::crypto::generate_master_key(); | |
| 684 | - | client.set_master_key_raw(key).unwrap(); | |
| 682 | + | client.set_master_key_raw(key); | |
| 685 | 683 | ||
| 686 | 684 | let mut handles = Vec::new(); | |
| 687 | 685 | for _ in 0..4 { | |
| @@ -838,7 +836,7 @@ | |||
| 838 | 836 | let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap(); | |
| 839 | 837 | ||
| 840 | 838 | // Cache the master key in the client (simulating normal logged-in state) | |
| 841 | - | client.set_master_key_raw(master_key).unwrap(); | |
| 839 | + | client.set_master_key_raw(master_key); | |
| 842 | 840 | ||
| 843 | 841 | (client, master_key, envelope) | |
| 844 | 842 | } | |
| @@ -1008,7 +1006,7 @@ | |||
| 1008 | 1006 | ||
| 1009 | 1007 | let client = authed_client(&server); | |
| 1010 | 1008 | let key = synckit_client::crypto::generate_master_key(); | |
| 1011 | - | client.set_master_key_raw(key).unwrap(); | |
| 1009 | + | client.set_master_key_raw(key); | |
| 1012 | 1010 | ||
| 1013 | 1011 | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 1014 | 1012 | assert_eq!(cursor, 0); | |
| @@ -1030,7 +1028,7 @@ | |||
| 1030 | 1028 | ||
| 1031 | 1029 | let client = authed_client(&server); | |
| 1032 | 1030 | let key = synckit_client::crypto::generate_master_key(); | |
| 1033 | - | client.set_master_key_raw(key).unwrap(); | |
| 1031 | + | client.set_master_key_raw(key); | |
| 1034 | 1032 | ||
| 1035 | 1033 | let result = client.push(Uuid::new_v4(), vec![]).await; | |
| 1036 | 1034 | assert!(result.is_err(), "Malformed JSON should produce an error"); | |
| @@ -1056,7 +1054,7 @@ | |||
| 1056 | 1054 | ||
| 1057 | 1055 | let client = authed_client(&server); | |
| 1058 | 1056 | let key = synckit_client::crypto::generate_master_key(); | |
| 1059 | - | client.set_master_key_raw(key).unwrap(); | |
| 1057 | + | client.set_master_key_raw(key); | |
| 1060 | 1058 | ||
| 1061 | 1059 | let result = client.pull(Uuid::new_v4(), 0).await; | |
| 1062 | 1060 | assert!(result.is_err(), "Malformed JSON should produce an error"); | |
| @@ -1121,7 +1119,7 @@ | |||
| 1121 | 1119 | ||
| 1122 | 1120 | let client = Arc::new(authed_client(&server)); | |
| 1123 | 1121 | let key = synckit_client::crypto::generate_master_key(); | |
| 1124 | - | client.set_master_key_raw(key).unwrap(); | |
| 1122 | + | client.set_master_key_raw(key); | |
| 1125 | 1123 | ||
| 1126 | 1124 | let mut handles = Vec::new(); | |
| 1127 | 1125 | for i in 0..8 { | |
| @@ -1168,7 +1166,7 @@ | |||
| 1168 | 1166 | ||
| 1169 | 1167 | let client = Arc::new(authed_client(&server)); | |
| 1170 | 1168 | let key = synckit_client::crypto::generate_master_key(); | |
| 1171 | - | client.set_master_key_raw(key).unwrap(); | |
| 1169 | + | client.set_master_key_raw(key); | |
| 1172 | 1170 | ||
| 1173 | 1171 | let mut handles = Vec::new(); | |
| 1174 | 1172 | for i in 0..4 { | |
| @@ -1204,7 +1202,7 @@ | |||
| 1204 | 1202 | ||
| 1205 | 1203 | let client = authed_client(&server); | |
| 1206 | 1204 | let key = synckit_client::crypto::generate_master_key(); | |
| 1207 | - | client.set_master_key_raw(key).unwrap(); | |
| 1205 | + | client.set_master_key_raw(key); | |
| 1208 | 1206 | ||
| 1209 | 1207 | // Create 1000+ change entries | |
| 1210 | 1208 | let changes: Vec<ChangeEntry> = (0..1100) | |
| @@ -1230,10 +1228,9 @@ | |||
| 1230 | 1228 | let (user_id, app_id) = test_ids(); | |
| 1231 | 1229 | ||
| 1232 | 1230 | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 1233 | - | client.restore_session(&expired, user_id, app_id).unwrap(); | |
| 1231 | + | client.restore_session(&expired, user_id, app_id); | |
| 1234 | 1232 | client | |
| 1235 | - | .set_master_key_raw(synckit_client::crypto::generate_master_key()) | |
| 1236 | - | .unwrap(); | |
| 1233 | + | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1237 | 1234 | ||
| 1238 | 1235 | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 1239 | 1236 | assert!( | |
| @@ -1249,10 +1246,9 @@ | |||
| 1249 | 1246 | let (user_id, app_id) = test_ids(); | |
| 1250 | 1247 | ||
| 1251 | 1248 | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 1252 | - | client.restore_session(&expired, user_id, app_id).unwrap(); | |
| 1249 | + | client.restore_session(&expired, user_id, app_id); | |
| 1253 | 1250 | client | |
| 1254 | - | .set_master_key_raw(synckit_client::crypto::generate_master_key()) | |
| 1255 | - | .unwrap(); | |
| 1251 | + | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1256 | 1252 | ||
| 1257 | 1253 | let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err(); | |
| 1258 | 1254 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| @@ -1265,7 +1261,7 @@ | |||
| 1265 | 1261 | let (user_id, app_id) = test_ids(); | |
| 1266 | 1262 | ||
| 1267 | 1263 | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 1268 | - | client.restore_session(&expired, user_id, app_id).unwrap(); | |
| 1264 | + | client.restore_session(&expired, user_id, app_id); | |
| 1269 | 1265 | ||
| 1270 | 1266 | let err = client.register_device("Test", "test").await.unwrap_err(); | |
| 1271 | 1267 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| @@ -1278,7 +1274,7 @@ | |||
| 1278 | 1274 | let (user_id, app_id) = test_ids(); | |
| 1279 | 1275 | ||
| 1280 | 1276 | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 1281 | - | client.restore_session(&expired, user_id, app_id).unwrap(); | |
| 1277 | + | client.restore_session(&expired, user_id, app_id); | |
| 1282 | 1278 | ||
| 1283 | 1279 | let err = client.list_devices().await.unwrap_err(); | |
| 1284 | 1280 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| @@ -1299,7 +1295,7 @@ | |||
| 1299 | 1295 | ||
| 1300 | 1296 | let client = authed_client(&server); | |
| 1301 | 1297 | let key = synckit_client::crypto::generate_master_key(); | |
| 1302 | - | client.set_master_key_raw(key).unwrap(); | |
| 1298 | + | client.set_master_key_raw(key); | |
| 1303 | 1299 | ||
| 1304 | 1300 | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 1305 | 1301 | let result = client.blob_upload(&presigned, vec![]).await; | |
| @@ -1324,7 +1320,7 @@ | |||
| 1324 | 1320 | ||
| 1325 | 1321 | let client = authed_client(&server); | |
| 1326 | 1322 | let key = synckit_client::crypto::generate_master_key(); | |
| 1327 | - | client.set_master_key_raw(key).unwrap(); | |
| 1323 | + | client.set_master_key_raw(key); | |
| 1328 | 1324 | ||
| 1329 | 1325 | let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01"; | |
| 1330 | 1326 | ||
| @@ -1471,7 +1467,7 @@ | |||
| 1471 | 1467 | ||
| 1472 | 1468 | let client = authed_client(&server); | |
| 1473 | 1469 | let key = synckit_client::crypto::generate_master_key(); | |
| 1474 | - | client.set_master_key_raw(key).unwrap(); | |
| 1470 | + | client.set_master_key_raw(key); | |
| 1475 | 1471 | ||
| 1476 | 1472 | let entry = ChangeEntry { | |
| 1477 | 1473 | table: "tasks".into(), | |
| @@ -1505,12 +1501,12 @@ | |||
| 1505 | 1501 | .await; | |
| 1506 | 1502 | ||
| 1507 | 1503 | let client = authed_client(&server); | |
| 1508 | - | assert!(!client.has_master_key().unwrap()); | |
| 1504 | + | assert!(!client.has_master_key()); | |
| 1509 | 1505 | ||
| 1510 | 1506 | client.setup_encryption_new("test-password").await.unwrap(); | |
| 1511 | 1507 | ||
| 1512 | 1508 | // Master key should now be in memory | |
| 1513 | - | assert!(client.has_master_key().unwrap()); | |
| 1509 | + | assert!(client.has_master_key()); | |
| 1514 | 1510 | ||
| 1515 | 1511 | // Verify the PUT body contains a valid envelope unwrappable with the same password | |
| 1516 | 1512 | let requests = server.received_requests().await.unwrap(); | |
| @@ -1557,7 +1553,7 @@ | |||
| 1557 | 1553 | let client = authed_client(&server); | |
| 1558 | 1554 | let result = client.setup_encryption_new("password").await; | |
| 1559 | 1555 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 1560 | - | assert!(client.has_master_key().unwrap()); | |
| 1556 | + | assert!(client.has_master_key()); | |
| 1561 | 1557 | } | |
| 1562 | 1558 | ||
| 1563 | 1559 | #[tokio::test] | |
| @@ -1578,14 +1574,14 @@ | |||
| 1578 | 1574 | .await; | |
| 1579 | 1575 | ||
| 1580 | 1576 | let client = authed_client(&server); | |
| 1581 | - | assert!(!client.has_master_key().unwrap()); | |
| 1577 | + | assert!(!client.has_master_key()); | |
| 1582 | 1578 | ||
| 1583 | 1579 | client | |
| 1584 | 1580 | .setup_encryption_existing("my-password") | |
| 1585 | 1581 | .await | |
| 1586 | 1582 | .unwrap(); | |
| 1587 | 1583 | ||
| 1588 | - | assert!(client.has_master_key().unwrap()); | |
| 1584 | + | assert!(client.has_master_key()); | |
| 1589 | 1585 | } | |
| 1590 | 1586 | ||
| 1591 | 1587 | #[tokio::test] | |
| @@ -1614,7 +1610,7 @@ | |||
| 1614 | 1610 | matches!(err, SyncKitError::DecryptionFailed), | |
| 1615 | 1611 | "Wrong password should produce DecryptionFailed: {err:?}" | |
| 1616 | 1612 | ); | |
| 1617 | - | assert!(!client.has_master_key().unwrap()); | |
| 1613 | + | assert!(!client.has_master_key()); | |
| 1618 | 1614 | } | |
| 1619 | 1615 | ||
| 1620 | 1616 | #[tokio::test] | |
| @@ -1656,7 +1652,7 @@ | |||
| 1656 | 1652 | let client = authed_client(&server); | |
| 1657 | 1653 | let result = client.setup_encryption_existing("password").await; | |
| 1658 | 1654 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 1659 | - | assert!(client.has_master_key().unwrap()); | |
| 1655 | + | assert!(client.has_master_key()); | |
| 1660 | 1656 | } | |
| 1661 | 1657 | ||
| 1662 | 1658 | #[tokio::test] | |
| @@ -1895,7 +1891,7 @@ | |||
| 1895 | 1891 | ||
| 1896 | 1892 | // Client has key2 (wrong key) | |
| 1897 | 1893 | let client = authed_client(&server); | |
| 1898 | - | client.set_master_key_raw(key2).unwrap(); | |
| 1894 | + | client.set_master_key_raw(key2); | |
| 1899 | 1895 | ||
| 1900 | 1896 | let result = client | |
| 1901 | 1897 | .blob_download(&format!("{}{}", server.uri(), download_path)) | |
| @@ -1952,7 +1948,7 @@ | |||
| 1952 | 1948 | ||
| 1953 | 1949 | let client = authed_client(&server); | |
| 1954 | 1950 | let key = synckit_client::crypto::generate_master_key(); | |
| 1955 | - | client.set_master_key_raw(key).unwrap(); | |
| 1951 | + | client.set_master_key_raw(key); | |
| 1956 | 1952 | ||
| 1957 | 1953 | let device_id = Uuid::new_v4(); | |
| 1958 | 1954 | let original_data = json!({ | |
| @@ -2139,7 +2135,7 @@ | |||
| 2139 | 2135 | ||
| 2140 | 2136 | let client = authed_client(&server); | |
| 2141 | 2137 | let key = synckit_client::crypto::generate_master_key(); | |
| 2142 | - | client.set_master_key_raw(key).unwrap(); | |
| 2138 | + | client.set_master_key_raw(key); | |
| 2143 | 2139 | ||
| 2144 | 2140 | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 2145 | 2141 | assert!( | |
| @@ -2166,7 +2162,7 @@ | |||
| 2166 | 2162 | ||
| 2167 | 2163 | let client = authed_client(&server); | |
| 2168 | 2164 | let key = synckit_client::crypto::generate_master_key(); | |
| 2169 | - | client.set_master_key_raw(key).unwrap(); | |
| 2165 | + | client.set_master_key_raw(key); | |
| 2170 | 2166 | ||
| 2171 | 2167 | let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err(); | |
| 2172 | 2168 | assert!( | |
| @@ -2290,7 +2286,7 @@ | |||
| 2290 | 2286 | ||
| 2291 | 2287 | let client = authed_client(&server); | |
| 2292 | 2288 | let key = synckit_client::crypto::generate_master_key(); | |
| 2293 | - | client.set_master_key_raw(key).unwrap(); | |
| 2289 | + | client.set_master_key_raw(key); | |
| 2294 | 2290 | ||
| 2295 | 2291 | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 2296 | 2292 | match err { | |
| @@ -2342,7 +2338,7 @@ | |||
| 2342 | 2338 | assert_eq!(uid2, second_user_id); | |
| 2343 | 2339 | ||
| 2344 | 2340 | // Session should now reflect the second auth | |
| 2345 | - | let info = client.session_info().unwrap().unwrap(); | |
| 2341 | + | let info = client.session_info().unwrap(); | |
| 2346 | 2342 | assert_eq!(info.user_id, second_user_id); | |
| 2347 | 2343 | } | |
| 2348 | 2344 | ||
| @@ -2357,15 +2353,15 @@ | |||
| 2357 | 2353 | .await; | |
| 2358 | 2354 | ||
| 2359 | 2355 | let client = authed_client(&server); | |
| 2360 | - | assert!(client.session_info().unwrap().is_some()); | |
| 2356 | + | assert!(client.session_info().is_some()); | |
| 2361 | 2357 | ||
| 2362 | - | client.clear_session().unwrap(); | |
| 2363 | - | assert!(client.session_info().unwrap().is_none()); | |
| 2358 | + | client.clear_session(); | |
| 2359 | + | assert!(client.session_info().is_none()); | |
| 2364 | 2360 | ||
| 2365 | 2361 | // Re-authenticate should work | |
| 2366 | 2362 | let result = client.authenticate("user@test.com", "pass").await; | |
| 2367 | 2363 | assert!(result.is_ok(), "Should be able to re-authenticate after clear: {result:?}"); | |
| 2368 | - | assert!(client.session_info().unwrap().is_some()); | |
| 2364 | + | assert!(client.session_info().is_some()); | |
| 2369 | 2365 | } | |
| 2370 | 2366 | ||
| 2371 | 2367 | #[tokio::test] | |
| @@ -2375,10 +2371,9 @@ | |||
| 2375 | 2371 | let (user_id, app_id) = test_ids(); | |
| 2376 | 2372 | ||
| 2377 | 2373 | let expired = fake_jwt(Utc::now().timestamp() - 3600); | |
| 2378 | - | client.restore_session(&expired, user_id, app_id).unwrap(); | |
| 2374 | + | client.restore_session(&expired, user_id, app_id); | |
| 2379 | 2375 | client | |
| 2380 | - | .set_master_key_raw(synckit_client::crypto::generate_master_key()) | |
| 2381 | - | .unwrap(); | |
| 2376 | + | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 2382 | 2377 | ||
| 2383 | 2378 | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 2384 | 2379 | assert!( | |
| @@ -2402,11 +2397,11 @@ | |||
| 2402 | 2397 | let client = authed_client(&server); | |
| 2403 | 2398 | ||
| 2404 | 2399 | client.setup_encryption_new("pass1").await.unwrap(); | |
| 2405 | - | assert!(client.has_master_key().unwrap()); | |
| 2400 | + | assert!(client.has_master_key()); | |
| 2406 | 2401 | ||
| 2407 | 2402 | // Second call overwrites | |
| 2408 | 2403 | client.setup_encryption_new("pass2").await.unwrap(); | |
| 2409 | - | assert!(client.has_master_key().unwrap()); | |
| 2404 | + | assert!(client.has_master_key()); | |
| 2410 | 2405 | ||
| 2411 | 2406 | // Verify the second PUT used a different envelope | |
| 2412 | 2407 | let requests = server.received_requests().await.unwrap(); | |
| @@ -2449,7 +2444,7 @@ | |||
| 2449 | 2444 | ||
| 2450 | 2445 | let client = authed_client(&server); | |
| 2451 | 2446 | let key = synckit_client::crypto::generate_master_key(); | |
| 2452 | - | client.set_master_key_raw(key).unwrap(); | |
| 2447 | + | client.set_master_key_raw(key); | |
| 2453 | 2448 | ||
| 2454 | 2449 | let plaintext = b"retry download test"; | |
| 2455 | 2450 | let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); | |
| @@ -2486,7 +2481,7 @@ | |||
| 2486 | 2481 | ||
| 2487 | 2482 | let client = authed_client(&server); | |
| 2488 | 2483 | let key = synckit_client::crypto::generate_master_key(); | |
| 2489 | - | client.set_master_key_raw(key).unwrap(); | |
| 2484 | + | client.set_master_key_raw(key); | |
| 2490 | 2485 | ||
| 2491 | 2486 | let plaintext: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect(); | |
| 2492 | 2487 | let presigned = format!("{}{}", server.uri(), upload_path); | |
| @@ -2574,7 +2569,7 @@ | |||
| 2574 | 2569 | let mut handles = Vec::new(); | |
| 2575 | 2570 | for _ in 0..50 { | |
| 2576 | 2571 | let c = Arc::clone(&client); | |
| 2577 | - | handles.push(tokio::spawn(async move { c.session_info().unwrap() })); | |
| 2572 | + | handles.push(tokio::spawn(async move { c.session_info() })); | |
| 2578 | 2573 | } | |
| 2579 | 2574 | ||
| 2580 | 2575 | for h in handles { | |
| @@ -2588,13 +2583,12 @@ | |||
| 2588 | 2583 | let server = MockServer::start().await; | |
| 2589 | 2584 | let client = Arc::new(authed_client(&server)); | |
| 2590 | 2585 | client | |
| 2591 | - | .set_master_key_raw(synckit_client::crypto::generate_master_key()) | |
| 2592 | - | .unwrap(); | |
| 2586 | + | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 2593 | 2587 | ||
| 2594 | 2588 | let mut handles = Vec::new(); | |
| 2595 | 2589 | for _ in 0..50 { | |
| 2596 | 2590 | let c = Arc::clone(&client); | |
| 2597 | - | handles.push(tokio::spawn(async move { c.has_master_key().unwrap() })); | |
| 2591 | + | handles.push(tokio::spawn(async move { c.has_master_key() })); | |
| 2598 | 2592 | } | |
| 2599 | 2593 | ||
| 2600 | 2594 | for h in handles { | |
| @@ -2643,7 +2637,7 @@ | |||
| 2643 | 2637 | ||
| 2644 | 2638 | let client = Arc::new(authed_client(&server)); | |
| 2645 | 2639 | let key = synckit_client::crypto::generate_master_key(); | |
| 2646 | - | client.set_master_key_raw(key).unwrap(); | |
| 2640 | + | client.set_master_key_raw(key); | |
| 2647 | 2641 | ||
| 2648 | 2642 | let mut handles = Vec::new(); | |
| 2649 | 2643 | for batch in 0..4 { | |
| @@ -2689,8 +2683,7 @@ | |||
| 2689 | 2683 | let client = short_timeout_client(server); | |
| 2690 | 2684 | let (user_id, app_id) = test_ids(); | |
| 2691 | 2685 | client | |
| 2692 | - | .restore_session(&fresh_token(), user_id, app_id) | |
| 2693 | - | .unwrap(); | |
| 2686 | + | .restore_session(&fresh_token(), user_id, app_id); | |
| 2694 | 2687 | client | |
| 2695 | 2688 | } | |
| 2696 | 2689 | ||
| @@ -2742,7 +2735,7 @@ | |||
| 2742 | 2735 | ||
| 2743 | 2736 | let client = authed_short_timeout_client(&server); | |
| 2744 | 2737 | let key = synckit_client::crypto::generate_master_key(); | |
| 2745 | - | client.set_master_key_raw(key).unwrap(); | |
| 2738 | + | client.set_master_key_raw(key); | |
| 2746 | 2739 | ||
| 2747 | 2740 | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 2748 | 2741 | assert_eq!(cursor, 42); |
| @@ -60,7 +60,7 @@ | |||
| 60 | 60 | /// | |
| 61 | 61 | /// Sets the internal session state without making any HTTP calls. | |
| 62 | 62 | /// Used on app startup to restore from stored credentials without re-authenticating. | |
| 63 | - | pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) -> Result<()> { | |
| 63 | + | pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) { | |
| 64 | 64 | let token_exp = jwt_exp(token); | |
| 65 | 65 | *self.session.write() = Some(Session { | |
| 66 | 66 | token: Arc::new(token.to_string()), | |
| @@ -69,7 +69,6 @@ | |||
| 69 | 69 | app_id, | |
| 70 | 70 | }); | |
| 71 | 71 | tracing::info!("Session restored for user {user_id}, app {app_id}"); | |
| 72 | - | Ok(()) | |
| 73 | 72 | } | |
| 74 | 73 | ||
| 75 | 74 | /// Clear the in-memory session and master key. | |
| @@ -77,11 +76,10 @@ | |||
| 77 | 76 | /// After calling this, the client will need to re-authenticate and set up | |
| 78 | 77 | /// encryption again. Does not affect OS keychain storage — call | |
| 79 | 78 | /// `keystore::delete_master_key` separately if needed. | |
| 80 | - | pub fn clear_session(&self) -> Result<()> { | |
| 79 | + | pub fn clear_session(&self) { | |
| 81 | 80 | *self.session.write() = None; | |
| 82 | 81 | *self.master_key.write() = None; | |
| 83 | 82 | tracing::info!("Session and master key cleared"); | |
| 84 | - | Ok(()) | |
| 85 | 83 | } | |
| 86 | 84 | ||
| 87 | 85 | /// Check whether the current session token has expired (or will expire | |
| @@ -89,17 +87,17 @@ | |||
| 89 | 87 | /// if the token's `exp` claim is in the past. Returns `false` if the | |
| 90 | 88 | /// token cannot be decoded (assumes not expired — the server will reject | |
| 91 | 89 | /// it with a 401 if it actually is). | |
| 92 | - | pub fn is_token_expired(&self) -> Result<bool> { | |
| 90 | + | pub fn is_token_expired(&self) -> bool { | |
| 93 | 91 | let guard = self.session.read(); | |
| 94 | 92 | let Some(session) = guard.as_ref() else { | |
| 95 | - | return Ok(true); | |
| 93 | + | return true; | |
| 96 | 94 | }; | |
| 97 | 95 | match session.token_exp { | |
| 98 | 96 | Some(exp) => { | |
| 99 | 97 | let now = chrono::Utc::now().timestamp(); | |
| 100 | - | Ok(now >= exp - TOKEN_EXPIRY_BUFFER_SECS) | |
| 98 | + | now >= exp - TOKEN_EXPIRY_BUFFER_SECS | |
| 101 | 99 | } | |
| 102 | - | None => Ok(false), | |
| 100 | + | None => false, | |
| 103 | 101 | } | |
| 104 | 102 | } | |
| 105 | 103 | ||
| @@ -219,9 +217,9 @@ | |||
| 219 | 217 | let client = SyncKitClient::new(test_config()); | |
| 220 | 218 | let (app_id, user_id) = test_ids(); | |
| 221 | 219 | ||
| 222 | - | client.restore_session("fake-token", user_id, app_id).unwrap(); | |
| 220 | + | client.restore_session("fake-token", user_id, app_id); | |
| 223 | 221 | ||
| 224 | - | let info = client.session_info().unwrap().expect("session should exist"); | |
| 222 | + | let info = client.session_info().expect("session should exist"); | |
| 225 | 223 | assert_eq!(*info.token, "fake-token"); | |
| 226 | 224 | assert_eq!(info.user_id, user_id); | |
| 227 | 225 | assert_eq!(info.app_id, app_id); | |
| @@ -232,10 +230,10 @@ | |||
| 232 | 230 | let client = SyncKitClient::new(test_config()); | |
| 233 | 231 | let (app_id, user_id) = test_ids(); | |
| 234 | 232 | ||
| 235 | - | client.restore_session("first-token", user_id, app_id).unwrap(); | |
| 236 | - | client.restore_session("second-token", user_id, app_id).unwrap(); | |
| 233 | + | client.restore_session("first-token", user_id, app_id); | |
| 234 | + | client.restore_session("second-token", user_id, app_id); | |
| 237 | 235 | ||
| 238 | - | let info = client.session_info().unwrap().unwrap(); | |
| 236 | + | let info = client.session_info().unwrap(); | |
| 239 | 237 | assert_eq!(*info.token, "second-token"); | |
| 240 | 238 | } | |
| 241 | 239 | ||
| @@ -285,7 +283,7 @@ | |||
| 285 | 283 | #[test] | |
| 286 | 284 | fn is_token_expired_true_without_session() { | |
| 287 | 285 | let client = SyncKitClient::new(test_config()); | |
| 288 | - | assert!(client.is_token_expired().unwrap()); | |
| 286 | + | assert!(client.is_token_expired()); | |
| 289 | 287 | } | |
| 290 | 288 | ||
| 291 | 289 | #[test] | |
| @@ -293,8 +291,8 @@ | |||
| 293 | 291 | let client = SyncKitClient::new(test_config()); | |
| 294 | 292 | let (app_id, user_id) = test_ids(); | |
| 295 | 293 | let token = fake_jwt(Utc::now().timestamp() - 3600); | |
| 296 | - | client.restore_session(&token, user_id, app_id).unwrap(); | |
| 297 | - | assert!(client.is_token_expired().unwrap()); | |
| 294 | + | client.restore_session(&token, user_id, app_id); | |
| 295 | + | assert!(client.is_token_expired()); | |
| 298 | 296 | } | |
| 299 | 297 | ||
| 300 | 298 | #[test] | |
| @@ -302,8 +300,8 @@ | |||
| 302 | 300 | let client = SyncKitClient::new(test_config()); | |
| 303 | 301 | let (app_id, user_id) = test_ids(); | |
| 304 | 302 | let token = fake_jwt(Utc::now().timestamp() + 3600); | |
| 305 | - | client.restore_session(&token, user_id, app_id).unwrap(); | |
| 306 | - | assert!(!client.is_token_expired().unwrap()); | |
| 303 | + | client.restore_session(&token, user_id, app_id); | |
| 304 | + | assert!(!client.is_token_expired()); | |
| 307 | 305 | } | |
| 308 | 306 | ||
| 309 | 307 | // ── require_token with expiry ── | |
| @@ -313,7 +311,7 @@ | |||
| 313 | 311 | let client = SyncKitClient::new(test_config()); | |
| 314 | 312 | let (app_id, user_id) = test_ids(); | |
| 315 | 313 | let token = fake_jwt(Utc::now().timestamp() - 3600); | |
| 316 | - | client.restore_session(&token, user_id, app_id).unwrap(); | |
| 314 | + | client.restore_session(&token, user_id, app_id); | |
| 317 | 315 | ||
| 318 | 316 | let err = client.require_token().unwrap_err(); | |
| 319 | 317 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| @@ -324,7 +322,7 @@ | |||
| 324 | 322 | let client = SyncKitClient::new(test_config()); | |
| 325 | 323 | let (app_id, user_id) = test_ids(); | |
| 326 | 324 | let token = fake_jwt(Utc::now().timestamp() + 3600); | |
| 327 | - | client.restore_session(&token, user_id, app_id).unwrap(); | |
| 325 | + | client.restore_session(&token, user_id, app_id); | |
| 328 | 326 | ||
| 329 | 327 | assert!(client.require_token().is_ok()); | |
| 330 | 328 | } | |
| @@ -335,16 +333,16 @@ | |||
| 335 | 333 | fn clear_session_clears_master_key() { | |
| 336 | 334 | let client = SyncKitClient::new(test_config()); | |
| 337 | 335 | let (app_id, user_id) = test_ids(); | |
| 338 | - | client.restore_session("token", user_id, app_id).unwrap(); | |
| 339 | - | client.set_master_key_raw([42u8; 32]).unwrap(); | |
| 336 | + | client.restore_session("token", user_id, app_id); | |
| 337 | + | client.set_master_key_raw([42u8; 32]); | |
| 340 | 338 | ||
| 341 | - | assert!(client.session_info().unwrap().is_some()); | |
| 342 | - | assert!(client.has_master_key().unwrap()); | |
| 339 | + | assert!(client.session_info().is_some()); | |
| 340 | + | assert!(client.has_master_key()); | |
| 343 | 341 | ||
| 344 | - | client.clear_session().unwrap(); | |
| 342 | + | client.clear_session(); | |
| 345 | 343 | ||
| 346 | - | assert!(client.session_info().unwrap().is_none()); | |
| 347 | - | assert!(!client.has_master_key().unwrap()); | |
| 344 | + | assert!(client.session_info().is_none()); | |
| 345 | + | assert!(!client.has_master_key()); | |
| 348 | 346 | } | |
| 349 | 347 | ||
| 350 | 348 | // ── OAuth types ── |
| @@ -188,7 +188,7 @@ | |||
| 188 | 188 | fn key_url_and_token_builds_correct_url() { | |
| 189 | 189 | let client = SyncKitClient::new(test_config()); | |
| 190 | 190 | let (app_id, user_id) = test_ids(); | |
| 191 | - | client.restore_session("bearer-token", user_id, app_id).unwrap(); | |
| 191 | + | client.restore_session("bearer-token", user_id, app_id); | |
| 192 | 192 | ||
| 193 | 193 | let (url, token) = client.key_url_and_token().unwrap(); | |
| 194 | 194 | assert_eq!(url, "https://example.com/api/sync/keys"); |
| @@ -87,6 +87,20 @@ | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | /// Decrypt with a pre-loaded key, preserving `device_id` and `seq` in a [`PulledChange`]. | |
| 91 | + | /// | |
| 92 | + | /// Used by `pull_rich()` to produce conflict-detection-ready results. | |
| 93 | + | pub(super) fn decrypt_change_to_pulled(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<crate::types::PulledChange> { | |
| 94 | + | let device_id = entry.device_id; | |
| 95 | + | let seq = entry.seq; | |
| 96 | + | let decrypted = Self::decrypt_change_with_key(entry, master_key)?; | |
| 97 | + | Ok(crate::types::PulledChange { | |
| 98 | + | entry: decrypted, | |
| 99 | + | device_id, | |
| 100 | + | seq, | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | ||
| 90 | 104 | /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition. | |
| 91 | 105 | pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> { | |
| 92 | 106 | let decrypted_data = match entry.data { | |
| @@ -505,7 +519,7 @@ | |||
| 505 | 519 | fn encrypt_change_preserves_all_metadata() { | |
| 506 | 520 | let client = SyncKitClient::new(test_config()); | |
| 507 | 521 | let key = crypto::generate_master_key(); | |
| 508 | - | client.set_master_key_raw(key).unwrap(); | |
| 522 | + | client.set_master_key_raw(key); | |
| 509 | 523 | ||
| 510 | 524 | let ts = Utc::now(); | |
| 511 | 525 | let entry = ChangeEntry { | |
| @@ -529,7 +543,7 @@ | |||
| 529 | 543 | fn multiple_entries_encrypt_decrypt_roundtrip() { | |
| 530 | 544 | let client = SyncKitClient::new(test_config()); | |
| 531 | 545 | let key = crypto::generate_master_key(); | |
| 532 | - | client.set_master_key_raw(key).unwrap(); | |
| 546 | + | client.set_master_key_raw(key); | |
| 533 | 547 | ||
| 534 | 548 | let entries = [ | |
| 535 | 549 | ChangeEntry { | |
| @@ -589,7 +603,7 @@ | |||
| 589 | 603 | fn encrypt_decrypt_roundtrip_unicode_table() { | |
| 590 | 604 | let client = SyncKitClient::new(test_config()); | |
| 591 | 605 | let key = crypto::generate_master_key(); | |
| 592 | - | client.set_master_key_raw(key).unwrap(); | |
| 606 | + | client.set_master_key_raw(key); | |
| 593 | 607 | ||
| 594 | 608 | let entry = ChangeEntry { | |
| 595 | 609 | table: "\u{65E5}\u{672C}\u{8A9E}\u{30C6}\u{30FC}\u{30D6}\u{30EB}".into(), | |
| @@ -620,7 +634,7 @@ | |||
| 620 | 634 | fn encrypt_decrypt_roundtrip_empty_row_id() { | |
| 621 | 635 | let client = SyncKitClient::new(test_config()); | |
| 622 | 636 | let key = crypto::generate_master_key(); | |
| 623 | - | client.set_master_key_raw(key).unwrap(); | |
| 637 | + | client.set_master_key_raw(key); | |
| 624 | 638 | ||
| 625 | 639 | let entry = ChangeEntry { | |
| 626 | 640 | table: "t".into(), |
| @@ -53,8 +53,11 @@ | |||
| 53 | 53 | mod blob; | |
| 54 | 54 | mod encryption; | |
| 55 | 55 | pub(crate) mod helpers; | |
| 56 | + | mod subscribe; | |
| 56 | 57 | mod sync; | |
| 57 | 58 | ||
| 59 | + | pub use subscribe::SyncNotifyStream; | |
| 60 | + | ||
| 58 | 61 | use parking_lot::RwLock; | |
| 59 | 62 | use reqwest::Client; | |
| 60 | 63 | use std::sync::Arc; | |
| @@ -92,6 +95,7 @@ | |||
| 92 | 95 | devices: String, | |
| 93 | 96 | push: String, | |
| 94 | 97 | pull: String, | |
| 98 | + | subscribe: String, | |
| 95 | 99 | status: String, | |
| 96 | 100 | keys: String, | |
| 97 | 101 | blobs_upload: String, | |
| @@ -107,6 +111,7 @@ | |||
| 107 | 111 | devices: format!("{base}/api/sync/devices"), | |
| 108 | 112 | push: format!("{base}/api/sync/push"), | |
| 109 | 113 | pull: format!("{base}/api/sync/pull"), | |
| 114 | + | subscribe: format!("{base}/api/sync/subscribe"), | |
| 110 | 115 | status: format!("{base}/api/sync/status"), | |
| 111 | 116 | keys: format!("{base}/api/sync/keys"), | |
| 112 | 117 | blobs_upload: format!("{base}/api/sync/blobs/upload"), | |
| @@ -184,25 +189,24 @@ | |||
| 184 | 189 | } | |
| 185 | 190 | ||
| 186 | 191 | /// Returns whether the master encryption key is loaded and ready. | |
| 187 | - | pub fn has_master_key(&self) -> Result<bool> { | |
| 188 | - | Ok(self.master_key.read().is_some()) | |
| 192 | + | pub fn has_master_key(&self) -> bool { | |
| 193 | + | self.master_key.read().is_some() | |
| 189 | 194 | } | |
| 190 | 195 | ||
| 191 | 196 | /// Returns the current session info, if authenticated. | |
| 192 | - | pub fn session_info(&self) -> Result<Option<SessionInfo>> { | |
| 197 | + | pub fn session_info(&self) -> Option<SessionInfo> { | |
| 193 | 198 | let guard = self.session.read(); | |
| 194 | - | Ok(guard.as_ref().map(|s| SessionInfo { | |
| 199 | + | guard.as_ref().map(|s| SessionInfo { | |
| 195 | 200 | token: Arc::clone(&s.token), | |
| 196 | 201 | user_id: s.user_id, | |
| 197 | 202 | app_id: s.app_id, | |
| 198 | - | })) | |
| 203 | + | }) | |
| 199 | 204 | } | |
| 200 | 205 | ||
| 201 | 206 | /// Set a raw 256-bit master key directly (for testing without Argon2 overhead). | |
| 202 | 207 | #[doc(hidden)] | |
| 203 | - | pub fn set_master_key_raw(&self, key: [u8; 32]) -> Result<()> { | |
| 208 | + | pub fn set_master_key_raw(&self, key: [u8; 32]) { | |
| 204 | 209 | *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key)); | |
| 205 | - | Ok(()) | |
| 206 | 210 | } | |
| 207 | 211 | ||
| 208 | 212 | // ── Internal helpers ── | |
| @@ -305,13 +309,13 @@ | |||
| 305 | 309 | #[test] | |
| 306 | 310 | fn new_client_starts_unauthenticated() { | |
| 307 | 311 | let client = SyncKitClient::new(test_config()); | |
| 308 | - | assert!(client.session_info().unwrap().is_none()); | |
| 312 | + | assert!(client.session_info().is_none()); | |
| 309 | 313 | } | |
| 310 | 314 | ||
| 311 | 315 | #[test] | |
| 312 | 316 | fn new_client_has_no_master_key() { | |
| 313 | 317 | let client = SyncKitClient::new(test_config()); | |
| 314 | - | assert!(!client.has_master_key().unwrap()); | |
| 318 | + | assert!(!client.has_master_key()); | |
| 315 | 319 | } | |
| 316 | 320 | ||
| 317 | 321 | #[test] | |
| @@ -352,7 +356,7 @@ | |||
| 352 | 356 | fn require_token_succeeds_with_session() { | |
| 353 | 357 | let client = SyncKitClient::new(test_config()); | |
| 354 | 358 | let (app_id, user_id) = test_ids(); | |
| 355 | - | client.restore_session("my-token", user_id, app_id).unwrap(); | |
| 359 | + | client.restore_session("my-token", user_id, app_id); | |
| 356 | 360 | ||
| 357 | 361 | let token = client.require_token().unwrap(); | |
| 358 | 362 | assert_eq!(*token, "my-token"); | |
| @@ -371,7 +375,7 @@ | |||
| 371 | 375 | fn require_session_ids_returns_correct_ids() { | |
| 372 | 376 | let client = SyncKitClient::new(test_config()); | |
| 373 | 377 | let (app_id, user_id) = test_ids(); | |
| 374 | - | client.restore_session("token", user_id, app_id).unwrap(); | |
| 378 | + | client.restore_session("token", user_id, app_id); | |
| 375 | 379 | ||
| 376 | 380 | let (returned_app, returned_user) = client.require_session_ids().unwrap(); | |
| 377 | 381 | assert_eq!(returned_app, app_id); | |
| @@ -402,14 +406,14 @@ | |||
| 402 | 406 | #[test] | |
| 403 | 407 | fn has_master_key_false_initially() { | |
| 404 | 408 | let client = SyncKitClient::new(test_config()); | |
| 405 | - | assert!(!client.has_master_key().unwrap()); | |
| 409 | + | assert!(!client.has_master_key()); | |
| 406 | 410 | } | |
| 407 | 411 | ||
| 408 | 412 | #[test] | |
| 409 | 413 | fn has_master_key_true_after_set() { | |
| 410 | 414 | let client = SyncKitClient::new(test_config()); | |
| 411 | 415 | *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32])); | |
| 412 | - | assert!(client.has_master_key().unwrap()); | |
| 416 | + | assert!(client.has_master_key()); | |
| 413 | 417 | } | |
| 414 | 418 | ||
| 415 | 419 | // ── set_master_key_raw ── | |
| @@ -417,12 +421,12 @@ | |||
| 417 | 421 | #[test] | |
| 418 | 422 | fn set_master_key_raw_makes_key_available() { | |
| 419 | 423 | let client = SyncKitClient::new(test_config()); | |
| 420 | - | assert!(!client.has_master_key().unwrap()); | |
| 424 | + | assert!(!client.has_master_key()); | |
| 421 | 425 | ||
| 422 | 426 | let key = [99u8; 32]; | |
| 423 | - | client.set_master_key_raw(key).unwrap(); | |
| 427 | + | client.set_master_key_raw(key); | |
| 424 | 428 | ||
| 425 | - | assert!(client.has_master_key().unwrap()); | |
| 429 | + | assert!(client.has_master_key()); | |
| 426 | 430 | assert_eq!(*client.require_master_key().unwrap(), key); | |
| 427 | 431 | } | |
| 428 | 432 | ||
| @@ -432,10 +436,10 @@ | |||
| 432 | 436 | let key1 = [1u8; 32]; | |
| 433 | 437 | let key2 = [2u8; 32]; | |
| 434 | 438 | ||
| 435 | - | client.set_master_key_raw(key1).unwrap(); | |
| 439 | + | client.set_master_key_raw(key1); | |
| 436 | 440 | assert_eq!(*client.require_master_key().unwrap(), key1); | |
| 437 | 441 | ||
| 438 | - | client.set_master_key_raw(key2).unwrap(); | |
| 442 | + | client.set_master_key_raw(key2); | |
| 439 | 443 | assert_eq!(*client.require_master_key().unwrap(), key2); | |
| 440 | 444 | } | |
| 441 | 445 | ||
| @@ -448,8 +452,8 @@ | |||
| 448 | 452 | .build() | |
| 449 | 453 | .unwrap(); | |
| 450 | 454 | let client = SyncKitClient::with_http_client(test_config(), http); | |
| 451 | - | assert!(client.session_info().unwrap().is_none()); | |
| 452 | - | assert!(!client.has_master_key().unwrap()); | |
| 455 | + | assert!(client.session_info().is_none()); | |
| 456 | + | assert!(!client.has_master_key()); | |
| 453 | 457 | } | |
| 454 | 458 | ||
| 455 | 459 | // ── Send + Sync assertions ── |
| @@ -154,6 +154,152 @@ | |||
| 154 | 154 | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | /// Pull changes from the server with optional table and timestamp filters. | |
| 158 | + | /// Decrypts `data` fields automatically. | |
| 159 | + | /// Returns (changes, new_cursor, has_more). | |
| 160 | + | /// | |
| 161 | + | /// Identical to [`pull`](SyncKitClient::pull) when the filter is empty/default. | |
| 162 | + | #[instrument(skip(self, filter))] | |
| 163 | + | pub async fn pull_filtered( | |
| 164 | + | &self, | |
| 165 | + | device_id: Uuid, | |
| 166 | + | cursor: i64, | |
| 167 | + | filter: PullFilter, | |
| 168 | + | ) -> Result<(Vec<ChangeEntry>, i64, bool)> { | |
| 169 | + | let token = self.require_token()?; | |
| 170 | + | ||
| 171 | + | let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest { | |
| 172 | + | device_id, | |
| 173 | + | cursor, | |
| 174 | + | tables: filter.tables, | |
| 175 | + | since: filter.since, | |
| 176 | + | })?); | |
| 177 | + | ||
| 178 | + | let resp = self | |
| 179 | + | .retry_request(|| { | |
| 180 | + | let req = self | |
| 181 | + | .http | |
| 182 | + | .post(&self.endpoints.pull) | |
| 183 | + | .bearer_auth(&token) | |
| 184 | + | .header("content-type", "application/json") | |
| 185 | + | .body(body.clone()); | |
| 186 | + | async move { check_response(req.send().await?).await } | |
| 187 | + | }) | |
| 188 | + | .await?; | |
| 189 | + | ||
| 190 | + | let pull_resp: PullResponse = resp.json().await?; | |
| 191 | + | ||
| 192 | + | let has_data = pull_resp.changes.iter().any(|c| c.data.is_some()); | |
| 193 | + | let key_holder = if has_data { | |
| 194 | + | self.require_master_key()? | |
| 195 | + | } else { | |
| 196 | + | crypto::ZeroizeOnDrop([0u8; 32]) | |
| 197 | + | }; | |
| 198 | + | let master_key: &[u8; 32] = &key_holder; | |
| 199 | + | let changes = pull_resp | |
| 200 | + | .changes | |
| 201 | + | .into_iter() | |
| 202 | + | .map(|c| Self::decrypt_change_with_key(c, master_key)) | |
| 203 | + | .collect::<Result<Vec<_>>>()?; | |
| 204 | + | ||
| 205 | + | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Pull changes from the server, preserving `device_id` and `seq` metadata. | |
| 209 | + | /// | |
| 210 | + | /// Same HTTP call and decryption as [`pull`](SyncKitClient::pull), but returns | |
| 211 | + | /// [`PulledChange`] wrappers that retain server metadata needed for conflict | |
| 212 | + | /// detection. Returns (changes, new_cursor, has_more). | |
| 213 | + | #[instrument(skip(self))] | |
| 214 | + | pub async fn pull_rich( | |
| 215 | + | &self, | |
| 216 | + | device_id: Uuid, | |
| 217 | + | cursor: i64, | |
| 218 | + | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| 219 | + | let token = self.require_token()?; | |
| 220 | + | ||
| 221 | + | let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?); | |
| 222 | + | ||
| 223 | + | let resp = self | |
| 224 | + | .retry_request(|| { | |
| 225 | + | let req = self | |
| 226 | + | .http | |
| 227 | + | .post(&self.endpoints.pull) | |
| 228 | + | .bearer_auth(&token) | |
| 229 | + | .header("content-type", "application/json") | |
| 230 | + | .body(body.clone()); | |
| 231 | + | async move { check_response(req.send().await?).await } | |
| 232 | + | }) | |
| 233 | + | .await?; | |
| 234 | + | ||
| 235 | + | let pull_resp: PullResponse = resp.json().await?; | |
| 236 | + | ||
| 237 | + | let has_data = pull_resp.changes.iter().any(|c| c.data.is_some()); | |
| 238 | + | let key_holder = if has_data { | |
| 239 | + | self.require_master_key()? | |
| 240 | + | } else { | |
| 241 | + | crypto::ZeroizeOnDrop([0u8; 32]) | |
| 242 | + | }; | |
| 243 | + | let master_key: &[u8; 32] = &key_holder; | |
| 244 | + | let changes = pull_resp | |
| 245 | + | .changes | |
| 246 | + | .into_iter() | |
| 247 | + | .map(|c| Self::decrypt_change_to_pulled(c, master_key)) | |
| 248 | + | .collect::<Result<Vec<_>>>()?; | |
| 249 | + | ||
| 250 | + | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Pull changes with filters, preserving `device_id` and `seq` metadata. | |
| 254 | + | /// | |
| 255 | + | /// Same as [`pull_rich`](SyncKitClient::pull_rich) but with table/timestamp | |
| 256 | + | /// filtering support. Returns (changes, new_cursor, has_more). | |
| 257 | + | #[instrument(skip(self, filter))] | |
| 258 | + | pub async fn pull_filtered_rich( | |
| 259 | + | &self, | |
| 260 | + | device_id: Uuid, | |
| 261 | + | cursor: i64, | |
| 262 | + | filter: PullFilter, | |
| 263 | + | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| 264 | + | let token = self.require_token()?; | |
| 265 | + | ||
| 266 | + | let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest { | |
| 267 | + | device_id, | |
| 268 | + | cursor, | |
| 269 | + | tables: filter.tables, | |
| 270 | + | since: filter.since, | |
| 271 | + | })?); | |
| 272 | + | ||
| 273 | + | let resp = self | |
| 274 | + | .retry_request(|| { | |
| 275 | + | let req = self | |
| 276 | + | .http | |
| 277 | + | .post(&self.endpoints.pull) | |
| 278 | + | .bearer_auth(&token) | |
| 279 | + | .header("content-type", "application/json") | |
| 280 | + | .body(body.clone()); | |
| 281 | + | async move { check_response(req.send().await?).await } | |
| 282 | + | }) | |
| 283 | + | .await?; | |
| 284 | + | ||
| 285 | + | let pull_resp: PullResponse = resp.json().await?; | |
| 286 | + | ||
| 287 | + | let has_data = pull_resp.changes.iter().any(|c| c.data.is_some()); | |
| 288 | + | let key_holder = if has_data { | |
| 289 | + | self.require_master_key()? | |
| 290 | + | } else { | |
| 291 | + | crypto::ZeroizeOnDrop([0u8; 32]) | |
| 292 | + | }; | |
| 293 | + | let master_key: &[u8; 32] = &key_holder; | |
| 294 | + | let changes = pull_resp | |
| 295 | + | .changes | |
| 296 | + | .into_iter() | |
| 297 | + | .map(|c| Self::decrypt_change_to_pulled(c, master_key)) | |
| 298 | + | .collect::<Result<Vec<_>>>()?; | |
| 299 | + | ||
| 300 | + | Ok((changes, pull_resp.cursor, pull_resp.has_more)) | |
| 301 | + | } | |
| 302 | + | ||
| 157 | 303 | /// Get sync status (total changes, latest cursor). | |
| 158 | 304 | #[instrument(skip(self))] | |
| 159 | 305 | pub async fn status(&self) -> Result<SyncStatus> { |
| @@ -1,0 +1,101 @@ | |||
| 1 | + | # SyncKit Client SDK -- Audit History | |
| 2 | + | ||
| 3 | + | See [audit_review.md](./audit_review.md) for current scorecard and grades. | |
| 4 | + | ||
| 5 | + | ## Changes Since Last Audit | |
| 6 | + | ||
| 7 | + | ### Seventh audit (2026-03-28, Run 12 cross-project) | |
| 8 | + | - **Test count:** 297 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. 0 failures. | |
| 9 | + | - **Grade:** A (maintained). v0.3.0. | |
| 10 | + | - **No code changes since Run 9.** | |
| 11 | + | - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`. | |
| 12 | + | - **Mandatory surprise:** None new. Previous surprise (fresh random Argon2 salt per wrap) still valid and impressive. | |
| 13 | + | - **No new findings.** All previous items remain resolved. | |
| 14 | + | ||
| 15 | + | ### Rust Patterns Audit (2026-03-21) | |
| 16 | + | - `SessionInfo.token` changed from `String` to `Arc<String>` -- `Arc::clone` instead of String clone | |
| 17 | + | - Auth request structs already use `&'a str` -- confirmed optimal, no change needed | |
| 18 | + | ||
| 19 | + | ### Sixth audit (2026-03-18, Run 9 cross-project) | |
| 20 | + | - **Test count:** 298 (197 unit + 99 integration + 1 doctest). 0 clippy warnings. | |
| 21 | + | - **Grade:** A (maintained). v0.3.0. | |
| 22 | + | - **No new findings.** All previous items remain resolved. | |
| 23 | + | - **Crypto audit notes:** XChaCha20-Poly1305, Argon2id with OWASP minimums, ZeroizeOnDrop keys, NFC normalization. 100+ crypto-specific tests. | |
| 24 | + | - **No sensitive data in logs:** Confirmed — tracing calls log events (e.g., "Master key generated") without leaking key material or passwords. | |
| 25 | + | - **Mandatory surprise:** Argon2 salt uniqueness — every wrap generates fresh random salt (crypto.rs:130-131). Verified by `two_wraps_use_different_salts` test. Correct design, uncommon rigor. | |
| 26 | + | ||
| 27 | + | ### Concurrency Upgrade (2026-03-13) | |
| 28 | + | - **Concurrency:** B+ -> A- | |
| 29 | + | - Replaced std::sync::RwLock with parking_lot::RwLock. Removed 16 poison-handling .map_err() sites. All 234 tests pass. | |
| 30 | + | ||
| 31 | + | ### Second audit (2026-03-13, pre-launch skeptical lens) | |
| 32 | + | - **Grade:** B+ (maintained). S4 fixes resolved 4/6 first-audit issues. New critical finding: blob data not encrypted. | |
| 33 | + | - **Test count:** 13 -> 109 (+96 tests, mostly from S4 remediation) | |
| 34 | + | - **S4 fixes:** await_holding_lock, HTTP timeouts, retry with backoff, token expiry detection, client.rs tests (66), keystore.rs tests (18), ChangeOp enum | |
| 35 | + | - **New findings:** Blob encryption gap (CRITICAL), no key rotation, Mutex unwraps, master key copies not zeroized, public types that should be pub(crate) | |
| 36 | + | - **Deterministic Argon2 salt:** Persists from first audit (tracked in MNW todo as "consider random salt") | |
| 37 | + | ||
| 38 | + | ### Post-audit remediation (2026-03-13) | |
| 39 | + | - **Grade:** B+ -> A-. 5 of 6 new findings from second audit resolved. Only key rotation deferred. | |
| 40 | + | - **Test count:** 109 -> 118 (+9 tests: 7 blob encrypt/decrypt, 2 salt tests) | |
| 41 | + | - **Blob encryption:** encrypt_bytes/decrypt_bytes in crypto.rs. blob_upload/blob_download encrypt/decrypt transparently using master key. 40-byte overhead (24 nonce + 16 tag). | |
| 42 | + | - **Random Argon2 salt:** wrap_master_key generates random 32-byte salt per operation. unwrap_master_key reads salt from envelope. Eliminates deterministic salt precomputation risk. | |
| 43 | + | - **Previous S4 fixes verified:** Mutex unwraps, ZeroizeOnDrop, pub(crate) restrictions -- all still in place. | |
| 44 | + | - **Key rotation:** Deferred post-beta. Requires server-side re-encryption of all sync_log entries. | |
| 45 | + | - Documentation upgraded to A: Device/SyncStatus/ChangeEntry/BlobUploadUrlResponse field docs added. All 12 error variants documented with when-they-occur. Keystore platform behavior documented (macOS/Linux/Windows backends). Client helpers documented (require_token, require_session_ids, etc). SessionInfo field docs. client.rs module doc expanded to 50 lines. architecture.md created (217 lines), README created (78 lines). | |
| 46 | + | ||
| 47 | + | ### Observability Upgrade (2026-03-13) | |
| 48 | + | - Added Observability dimension to scorecard (grade A) | |
| 49 | + | - Added 16 `#[instrument]` annotations to all pub async methods in client.rs with appropriate skip params | |
| 50 | + | - Sensitive params skipped: password, old_password, new_password, email, code, code_verifier, presigned_url, data | |
| 51 | + | - `use tracing::instrument;` import added to client.rs | |
| 52 | + | - `cargo check` passes clean | |
| 53 | + | ||
| 54 | + | ### Performance Upgrade (2026-03-13) | |
| 55 | + | - **Performance:** B -> A- | |
| 56 | + | - Cached JWT `exp` claim in Session struct — `require_token()` and `is_token_expired()` no longer re-parse the JWT on every call | |
| 57 | + | - Retry request bodies use `bytes::Bytes` instead of `Vec<u8>` — clone in retry closures is O(1) refcount bump, not O(n) copy (10 sites) | |
| 58 | + | - Batch encrypt/decrypt in `push()`/`pull()` extracts master key once before the loop instead of per-entry lock acquisition | |
| 59 | + | ||
| 60 | + | ### Resilience Upgrade (2026-03-13) | |
| 61 | + | - **Resilience:** B- -> A- | |
| 62 | + | - Added 9 integration tests for encryption setup flows: `setup_encryption_new` (happy path, no-auth, server retry), `setup_encryption_existing` (happy path, wrong password, no-auth, server retry, missing key 404), cross-device encryption roundtrip | |
| 63 | + | - Cross-device test proves full two-device flow: device 1 creates encryption → pushes data → device 2 recovers key → pulls and decrypts successfully | |
| 64 | + | - **Test count:** 234 -> 243 (+9 integration tests). 170 unit + 72 integration + 1 doctest. | |
| 65 | + | ||
| 66 | + | ### Adversarial Test Audit (2026-03-13) | |
| 67 | + | - **Grade:** A- -> A-. Testing grade upgraded from B- to A. | |
| 68 | + | - **Test count:** 150 -> 234 (+84 tests: 52 unit, 32 integration). Test density ~94 tests/KLOC. | |
| 69 | + | - **CRITICAL fix: change_password bypass** -- Old password verification skipped when master key was cached in memory. Attacker with session access (stolen device, malware) could change encryption password without knowing the old one. Fixed: always verify old password against server envelope regardless of cache state. Added 8 tests covering cache hit/miss, wrong old password, concurrent password changes. | |
| 70 | + | - **HIGH fix: Unicode password normalization** -- NFC vs NFD normalization inconsistency across operating systems could derive different keys from "same" password (e.g., é as single codepoint vs e+combining-acute). Added `unicode-normalization` crate, NFC normalization before all key derivation (wrap_master_key, unwrap_master_key, change_password). 4 tests covering NFC/NFD/mixed inputs. | |
| 71 | + | - **Empty password rejection** -- wrap_master_key, unwrap_master_key, change_password now return error on empty password. 3 tests. | |
| 72 | + | - **Password length limits** -- 1024-byte max after UTF-8 encoding. Prevents resource exhaustion on Argon2 (linear memory cost with input length). 2 tests. | |
| 73 | + | - **Comprehensive crypto tests** -- Tamper detection (flip bits in nonce/ciphertext/tag), envelope validation (version mismatch, truncated fields), key rotation simulation (decrypt with wrong key), concurrent encryption (nonce uniqueness under load), large payload handling (1MB encrypt/decrypt). 28 new crypto unit tests. | |
| 74 | + | - **Integration tests** -- Error mapping for all 4xx/5xx codes (400/401/403/404/409/413/429/500/502/503), retry behavior (transient vs permanent), auth enforcement (missing token, invalid token, expired token), blob roundtrips (upload -> download, tamper detection, decrypt failure), malformed response handling (invalid JSON, missing fields). 32 new integration tests. | |
| 75 | + | - **Concurrency tests** -- Parallel encrypt operations (nonce uniqueness), concurrent password changes (last-write-wins, cache invalidation), device registration race (409 conflict), push/pull interleaving (optimistic locking). 9 tests across unit and integration. | |
| 76 | + | - **Resolved findings:** All 2 critical vulnerabilities from adversarial audit fixed. No new security issues discovered. | |
| 77 | + | ||
| 78 | + | ### Third audit (2026-03-16, Run 6 cross-project) | |
| 79 | + | - **Test count:** 297 (unchanged) | |
| 80 | + | - **Grade:** A (maintained). | |
| 81 | + | - **Source LOC:** 4,327 src + 2,749 test | |
| 82 | + | - **New finding (MEDIUM):** Wrapping key in `crypto.rs:99` (`derive_wrapping_key`) is computed on the stack but not wrapped in `ZeroizeOnDrop`. Intermediate key material sits in memory after function returns. Other keys properly use ZeroizeOnDrop. | |
| 83 | + | - **New finding (LOW):** Unused `sha2` dependency in Cargo.toml. | |
| 84 | + | - **Mandatory surprise:** Wrapping key not zeroized — Genuine issue (MEDIUM). | |
| 85 | + | - **Previous items verified:** All previous remediated items confirmed intact. Key rotation still deferred (post-beta). | |
| 86 | + | ||
| 87 | + | ### Testing Push (2026-03-13) | |
| 88 | + | - **Grade:** A- -> A. Testing A -> A+. Code Quality, Type Safety, Concurrency, Resilience all upgraded to A. | |
| 89 | + | - **Test count:** 243 -> 297 (+54 tests). 197 unit + 99 integration + 1 doctest. | |
| 90 | + | - **types.rs:** 13 unit tests added. Serde roundtrip, Display/serde consistency, from_str_opt edge cases, Copy/Hash trait verification, skip_serializing_if, extra unknown fields tolerance, ISO timestamp deserialization. | |
| 91 | + | - **error.rs:** 10 unit tests added. Send+Sync compile-time assert, Display for all 8 variants, Debug no-panic, source() chain verification (Json, Base64 have source; leaf variants do not), empty/very-long server messages. | |
| 92 | + | - **client.rs:** 4 unit tests added. SyncKitClient Send+Sync compile-time assert, with_http_client constructor, unicode table name encrypt/decrypt roundtrip, empty row_id roundtrip. | |
| 93 | + | - **Integration tests:** 27 new. Retry count verification (exhaustion at 4 requests, 404 not retried, 3rd-attempt success). Malformed responses (HTML body, empty body, missing has_more, wrong cursor type, missing app_id, missing already_exists, 413 error, extra fields ignored). Session edge cases (double authenticate, clear then re-auth, expired token on restore). Encryption setup overwrite. Blob edge cases (confirm retry, download retry, 1MB upload overhead). Device edge cases (empty name, unicode name, empty list). Concurrency stress (50 concurrent session_info reads, 50 has_master_key reads, 20 status checks, 4x100-entry pushes). Timeout tests (slow server timeout, retry after timeout). | |
| 94 | + | - **New constructor:** `with_http_client(config, client)` enables custom timeout testing without modifying production defaults. | |
| 95 | + | ||
| 96 | + | ### Performance Upgrade (2026-03-13) | |
| 97 | + | - **Performance:** A- -> A | |
| 98 | + | - Pre-built endpoint URLs: new `Endpoints` struct computes all 10 API endpoint URLs once at client construction, eliminating per-request `format!()` string allocations | |
| 99 | + | - `Arc<String>` session token: `require_token()` returns `Arc<String>` instead of `String`, making per-request token extraction O(1) refcount bump instead of O(n) string clone (~300-500 byte JWT) | |
| 100 | + | - `key_url_and_token()` returns `(&str, Arc<String>)` instead of `(String, String)`, zero allocations per call | |
| 101 | + | - All 297 tests pass unchanged (2 test assertions updated for Arc deref) |
| @@ -1,0 +1,142 @@ | |||
| 1 | + | # SyncKit Client SDK -- Competitive Analysis | |
| 2 | + | ||
| 3 | + | Last updated: 2026-04-10 | |
| 4 | + | ||
| 5 | + | ## Positioning | |
| 6 | + | ||
| 7 | + | SyncKit is an E2E encrypted, Rust-native, offline-first sync SDK for indie desktop and mobile apps. The server (hosted on MNW) stores only encrypted blobs -- zero-knowledge by design. Bundled with OTA updates and device management. Consumers: GoingsOn, Balanced Breakfast, audiofiles (all Tauri apps). | |
| 8 | + | ||
| 9 | + | The key differentiators are server-zero-knowledge encryption (XChaCha20-Poly1305 + Argon2id, keys never leave the device), opaque-blob storage (bring-your-own-schema, no server-side migrations), and the bundled OTA + device management layer that no sync competitor offers. Pricing is bundled with MNW creator tiers ($10-40/mo), not per-read/write metered. | |
| 10 | + | ||
| 11 | + | ## Pricing Comparison | |
| 12 | + | ||
| 13 | + | | Tool | Price | Model | | |
| 14 | + | |------|-------|-------| | |
| 15 | + | | **SyncKit** | $10-40/mo (bundled) | Included in MNW creator tier | | |
| 16 | + | | Firebase Firestore | Pay-per-use | $0.18/100K reads+writes, $0.26/GB | | |
| 17 | + | | Supabase | $0-$599/mo | Freemium + usage overages | | |
| 18 | + | | PowerSync | $0-$599/mo | Usage-based (GB synced) | | |
| 19 | + | | ElectricSQL | Pay-per-write | $1/M writes, reads free | | |
| 20 | + | | Turso | $0-$417/mo | Storage-based tiers | | |
| 21 | + | | Convex | $0-$25/member/mo | Freemium + usage overages | | |
| 22 | + | | Ditto | Enterprise (custom) | Sales-driven | | |
| 23 | + | | Couchbase Mobile | Enterprise (~25K+ EUR/yr) | License-based | | |
| 24 | + | | Etebase | Free (self-host) | Source-available, hosted beta | | |
| 25 | + | ||
| 26 | + | ## Feature Matrix | |
| 27 | + | ||
| 28 | + | | Feature | SyncKit | Firebase | Supabase | PowerSync | ElectricSQL | Ditto | Etebase | | |
| 29 | + | |---------|:-------:|:--------:|:--------:|:---------:|:-----------:|:-----:|:-------:| | |
| 30 | + | | E2E encrypted | Y | N | N | N | N | N | Y | | |
| 31 | + | | Server-zero-knowledge | Y | N | N | N | N | N | Y | | |
| 32 | + | | Rust SDK (native) | Y | N | N | Alpha | Y | Y | Y | | |
| 33 | + | | Tauri integration | Y | N | N | Alpha | N | N | N | | |
| 34 | + | | Offline-first | Y | Partial | N | Y | Partial | Y | Y | | |
| 35 | + | | Bring-your-own-schema | Y | N | N | N | N | N | Partial | | |
| 36 | + | | OTA updates | Y | N | N | N | N | N | N | | |
| 37 | + | | Device management | Y | N | N | N | N | N | N | | |
| 38 | + | | OS keychain storage | Y | N | N | N | N | N | N | | |
| 39 | + | | Blob/file sync | Y | Y | Y | N | N | N | Y | | |
| 40 | + | | Self-hostable | Y | N | Y | Y | Y | Y | Y | | |
| 41 | + | | Real-time push | N | Y | Y | Y | Y | Y | N | | |
| 42 | + | | P2P sync (no server) | N | N | N | N | N | Y | N | | |
| 43 | + | | CRDT conflict resolution | N | N | N | N | N | Y | N | | |
| 44 | + | | Rich query engine | N | Y | Y | Y | Y | Y | N | | |
| 45 | + | ||
| 46 | + | ## Competitor Deep Dives | |
| 47 | + | ||
| 48 | + | ### 1. Firebase (Google) | |
| 49 | + | ||
| 50 | + | Managed BaaS with Realtime Database and Firestore. Massive ecosystem (Auth, Functions, Hosting, Analytics). Generous free tier. Near-instant real-time push via persistent connections. No native Rust SDK (community crates are server-side only, not offline-capable). | |
| 51 | + | ||
| 52 | + | **What SyncKit lacks:** real-time push subscriptions, multi-platform mobile SDKs, hosted auth, serverless functions, web dashboard. **What Firebase lacks:** E2E encryption, Rust SDK, Tauri support, offline desktop sync, OTA updates, device management, data portability (complete vendor lock-in, no self-hosting). | |
| 53 | + | ||
| 54 | + | ### 2. Supabase | |
| 55 | + | ||
| 56 | + | Open-source Firebase alternative on PostgreSQL. Full SQL power, RLS for access control, self-hostable. Growing ecosystem. Realtime via Postgres CDC. No offline-first without PowerSync add-on. | |
| 57 | + | ||
| 58 | + | **What SyncKit lacks:** SQL query engine, built-in auth, edge functions, web dashboard, large community. **What Supabase lacks:** E2E encryption, offline-first (requires PowerSync add-on), Rust SDK, Tauri support, OTA updates, device management. | |
| 59 | + | ||
| 60 | + | ### 3. PowerSync -- Primary Threat | |
| 61 | + | ||
| 62 | + | Offline-first sync layer between your existing database and client-side SQLite. **Released a Tauri SDK (alpha, March 2026)** built on a Rust SDK. Works with Postgres, MongoDB, MySQL, SQL Server. Self-hostable Open Edition. | |
| 63 | + | ||
| 64 | + | **What SyncKit lacks:** multi-database source support, client-side SQL queries, partial replication (sync rules), larger team and community. **What PowerSync lacks:** E2E encryption (sync service sees all data), OTA updates, device management, blob/file sync, OS keychain integration. Write-path goes directly to your backend -- PowerSync does not handle write conflicts. | |
| 65 | + | ||
| 66 | + | PowerSync is the most direct competitor. If they add encryption, they become serious competition. Their Tauri SDK being alpha-quality is a window. | |
| 67 | + | ||
| 68 | + | ### 4. ElectricSQL | |
| 69 | + | ||
| 70 | + | Postgres CDC engine streaming "shapes" (filtered table subsets) to clients. Read-path only -- writes go through your own API. Open-source (Apache 2.0). Innovative pricing: writes cost money, reads/fan-out are free and unlimited. Rust client available. | |
| 71 | + | ||
| 72 | + | **What SyncKit lacks:** read-path fan-out, per-shape subscriptions, 10-language client support. **What ElectricSQL lacks:** E2E encryption, offline-first writes (no local write queue built in), OTA updates, device management, conflict resolution (your problem), blob sync. | |
| 73 | + | ||
| 74 | + | ### 5. Ditto | |
| 75 | + | ||
| 76 | + | Enterprise P2P sync with Bluetooth/WiFi Direct mesh networking. Rust core. CRDT-based automatic conflict resolution. $82M raised (March 2025). Targets airlines, military, retail. | |
| 77 | + | ||
| 78 | + | **What SyncKit lacks:** P2P mesh sync, CRDT conflict resolution, enterprise support. **What Ditto lacks:** E2E application-layer encryption, indie pricing (enterprise sales only), OTA updates, bring-your-own-schema (CRDTs need structure). | |
| 79 | + | ||
| 80 | + | ### 6. Couchbase Lite + Sync Gateway | |
| 81 | + | ||
| 82 | + | Enterprise mobile database with bidirectional sync. Battle-tested in large deployments. Gained momentum from MongoDB Realm shutdown (Sept 2025). Configurable conflict handlers. P2P sync between Couchbase Lite instances. | |
| 83 | + | ||
| 84 | + | **What SyncKit lacks:** P2P sync, rich on-device query engine, enterprise track record. **What Couchbase lacks:** E2E encryption, indie pricing (~25K EUR/yr), Rust SDK (experimental C bindings only), simplicity (multi-component architecture), OTA updates. | |
| 85 | + | ||
| 86 | + | ### 7. Etebase -- Philosophical Peer | |
| 87 | + | ||
| 88 | + | The only other E2E encrypted sync SDK with a Rust library. Open-source server, self-hostable. SDKs for Rust, JS, Java/Kotlin, Python, C, C#. Used by EteSync (contacts/calendar sync). | |
| 89 | + | ||
| 90 | + | **What SyncKit lacks:** broader language coverage (6 languages vs 1). **What Etebase lacks:** Tauri integration, OTA updates, device management, OS keychain, blob support via presigned URLs, commercial backing, community momentum (very small team, unclear trajectory). | |
| 91 | + | ||
| 92 | + | ### 8. Realm / Atlas Device Sync (MongoDB) -- Shut Down | |
| 93 | + | ||
| 94 | + | End-of-life as of September 30, 2025. MongoDB deprecated all Atlas Device SDKs. Developers displaced into Couchbase, Ditto, PowerSync, and ObjectBox. The shutdown created a significant gap in the offline-first sync market. | |
| 95 | + | ||
| 96 | + | ### 9. Others | |
| 97 | + | ||
| 98 | + | **Turso:** Edge SQLite replication. Read replicas only, writes go to primary. Cheap ($5/mo) but not a multi-device sync solution -- no bidirectional sync, no offline writes. | |
| 99 | + | ||
| 100 | + | **Convex:** Reactive backend with automatic query subscriptions. No offline support (requires internet). Recently open-sourced (BSL, converts to Apache 2.0 after 3 years). Rust client available but secondary to TypeScript. | |
| 101 | + | ||
| 102 | + | **CouchDB/PouchDB:** Document-oriented database with built-in sync protocol. Offline-first, conflict handling via revision trees. No E2E encryption. Mature but aging. JavaScript-focused. | |
| 103 | + | ||
| 104 | + | **Syncthing:** P2P file sync. E2E encrypted, no central server. Designed for folder/file sync, not structured app data. No changelog-based sync, no SDK API, no conflict resolution for structured data. | |
| 105 | + | ||
| 106 | + | **CRDT libraries (Automerge, Yjs, Loro):** Building blocks for conflict-free merge, not sync services. Handle data structure merging; bring-your-own transport/storage/auth. Incompatible with SyncKit's zero-knowledge model (server cannot merge what it cannot read). | |
| 107 | + | ||
| 108 | + | ## What We Offer That Competitors Don't | |
| 109 | + | ||
| 110 | + | - **Server-zero-knowledge** -- the server stores only encrypted blobs. No data breaches because there is no data to breach. Compliance-friendly (GDPR, NIS2). | |
| 111 | + | - **Bring-your-own-schema** -- table names, row IDs, and data shapes are opaque to the server. No server-side migrations when your app schema changes. | |
| 112 | + | - **Bundled OTA updates** -- Tauri-compatible auto-update protocol. No competitor offers sync + OTA in one SDK. | |
| 113 | + | - **Bundled device management** -- register, list, deregister devices. Track sync state per device. | |
| 114 | + | - **OS keychain integration** -- encryption keys stored in macOS Keychain, Linux Secret Service, or Windows Credential Manager. Key material never touches disk. | |
| 115 | + | - **Minimal blob overhead** -- binary files encrypted with only 40 bytes overhead (24-byte nonce + 16-byte auth tag). No base64 expansion. | |
| 116 | + | - **Key zeroization** -- `ZeroizeOnDrop` on all key material. No key residue in memory after use. | |
| 117 | + | - **Flat pricing** -- included in MNW creator tier. No per-read/write metering, no surprise bills. | |
| 118 | + | ||
| 119 | + | ## Market Tailwinds | |
| 120 | + | ||
| 121 | + | - **MongoDB Realm shutdown (Sept 2025)** displaced developers seeking offline-first sync alternatives | |
| 122 | + | - **Tauri adoption growing ~55% YoY**, creating demand for Rust-native backends | |
| 123 | + | - **Regulatory pressure (GDPR, NIS2)** pushing toward E2E encryption and data minimization | |
| 124 | + | - **Local-first movement** gaining mainstream traction (Notion, Linear, Figma adopting offline-first) | |
| 125 | + | - **PowerSync Tauri SDK is alpha** -- their Rust/Tauri story is immature, giving SyncKit a window | |
| 126 | + | ||
| 127 | + | ## Target Users | |
| 128 | + | ||
| 129 | + | - Indie developers building Tauri desktop apps who need cloud sync without running a backend | |
| 130 | + | - Developers who prioritize user privacy and want zero-knowledge sync by default | |
| 131 | + | - Small teams shipping cross-platform apps (macOS/Windows/Linux) that need offline-first data | |
| 132 | + | - Anyone displaced from MongoDB Realm looking for a simpler, encrypted alternative | |
| 133 | + | ||
| 134 | + | ## Gaps and Potential Roadmap Items | |
| 135 | + | ||
| 136 | + | Based on what competitors offer that SyncKit does not: | |
| 137 | + | ||
| 138 | + | - **Real-time push notifications** -- Firebase/Supabase/Convex push changes instantly. SyncKit is pull-based (clients poll). A lightweight SSE channel for "something changed, pull now" would close this gap without compromising E2E encryption (the notification carries no data, just a signal). | |
| 139 | + | - **Selective sync / sync rules** -- PowerSync and ElectricSQL let clients sync subsets of data. SyncKit syncs the full changelog. For apps with large datasets, filtered sync (by device, by date range, by collection) would reduce bandwidth and latency. | |
| 140 | + | - **Conflict resolution helpers** -- Ditto and Couchbase offer configurable merge strategies. SyncKit leaves conflict resolution to the client. A toolkit of common strategies (LWW, field-level merge, custom resolver callback) in the SDK would reduce boilerplate. | |
| 141 | + | - **Web client (WASM)** -- every major competitor has a JavaScript/TypeScript SDK. A WASM-compiled SyncKit client would open the web platform. Low priority (current consumers are all desktop), but relevant if any consumer app ships a web companion. | |
| 142 | + | - **Multi-language SDKs** -- Etebase covers 6 languages, PowerSync covers 10+. SyncKit is Rust-only. A C FFI layer would enable bindings for Swift, Kotlin, Python, and JS. Only worth doing if non-Tauri consumers appear. |