max / synckit-client
19 files changed,
+1592 insertions,
-237 deletions
| @@ -1467,6 +1467,7 @@ dependencies = [ | |||
| 1467 | 1467 | "serde_json", | |
| 1468 | 1468 | "thiserror", | |
| 1469 | 1469 | "tokio", | |
| 1470 | + | "tokio-stream", | |
| 1470 | 1471 | "tracing", | |
| 1471 | 1472 | "unicode-normalization", | |
| 1472 | 1473 | "urlencoding", | |
| @@ -1612,6 +1613,17 @@ dependencies = [ | |||
| 1612 | 1613 | ] | |
| 1613 | 1614 | ||
| 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 | + | ||
| 1626 | + | [[package]] | |
| 1615 | 1627 | name = "tokio-util" | |
| 1616 | 1628 | version = "0.7.18" | |
| 1617 | 1629 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -21,6 +21,7 @@ zeroize = "1" | |||
| 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"] } |
| @@ -0,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) |
| @@ -94,100 +94,6 @@ More critically, if `put_server_key` fails after the old envelope is fetched, th | |||
| 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 | |
| 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) | |
| 97 | + | --- | |
| 98 | + | ||
| 99 | + | See [audit_history.md](./audit_history.md) for full chronological audit log. |
| @@ -0,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. |
| @@ -0,0 +1,301 @@ | |||
| 1 | + | # SyncKit Client SDK -- Integration Patterns | |
| 2 | + | ||
| 3 | + | How GoingsOn, Balanced Breakfast, and audiofiles consume the SyncKit client SDK. Use this guide to add sync to a new app. | |
| 4 | + | ||
| 5 | + | ## Common Architecture | |
| 6 | + | ||
| 7 | + | All three apps follow the same pattern: | |
| 8 | + | ||
| 9 | + | ``` | |
| 10 | + | App (SQLite) | |
| 11 | + | ├── Syncable tables with INSERT/UPDATE/DELETE triggers | |
| 12 | + | ├── sync_changelog table (captures local mutations) | |
| 13 | + | ├── sync_state table (key-value: device_id, cursor, flags) | |
| 14 | + | ├── Sync service module (push/pull logic) | |
| 15 | + | └── Scheduler (background timer, exponential backoff) | |
| 16 | + | ``` | |
| 17 | + | ||
| 18 | + | ### Trigger-Based Changelog | |
| 19 | + | ||
| 20 | + | Each syncable table has SQL triggers that write to `sync_changelog`: | |
| 21 | + | - Triggers fire on INSERT, UPDATE, DELETE | |
| 22 | + | - When `applying_remote = '1'` in sync_state, triggers are suppressed (prevents echo-back) | |
| 23 | + | - Only whitelisted columns are included in the JSON payload | |
| 24 | + | - Row IDs are UUIDs (GO, BB) or content hashes (AF) | |
| 25 | + | ||
| 26 | + | ### Push/Pull Cycle | |
| 27 | + | ||
| 28 | + | 1. **Push**: Read unpushed changelog entries (batch 500) → encrypt via SDK → send to MNW → mark as pushed | |
| 29 | + | 2. **Pull**: Fetch remote changes from MNW → decrypt via SDK → apply locally with triggers suppressed → update cursor | |
| 30 | + | 3. **Cleanup**: Delete pushed entries older than 7 days | |
| 31 | + | ||
| 32 | + | ### FK-Safe Ordering | |
| 33 | + | ||
| 34 | + | Both push and pull use foreign-key-safe ordering: | |
| 35 | + | - **Upserts**: Parents first (projects before tasks, VFS before nodes) | |
| 36 | + | - **Deletes**: Children first (tasks before projects, nodes before VFS) | |
| 37 | + | ||
| 38 | + | --- | |
| 39 | + | ||
| 40 | + | ## Per-App Integration | |
| 41 | + | ||
| 42 | + | ### GoingsOn (13 tables) | |
| 43 | + | ||
| 44 | + | **Tables synced:** | |
| 45 | + | ``` | |
| 46 | + | projects → milestones, tasks, events | |
| 47 | + | tasks → annotations, subtasks | |
| 48 | + | contacts → contact_emails, contact_phones, contact_social_handles, contact_custom_fields | |
| 49 | + | email_accounts (16 config columns only — credentials excluded) | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | **Special handling:** | |
| 53 | + | - Email account passwords and OAuth tokens are excluded from the column whitelist — never leave the device | |
| 54 | + | - Tasks with `source_email_id` referencing unsynced emails: FK enforcement relaxed during remote apply | |
| 55 | + | ||
| 56 | + | **Location:** `src-tauri/src/sync_service.rs` (1814 lines, 43 tests) | |
| 57 | + | ||
| 58 | + | ### Balanced Breakfast (5 tables) | |
| 59 | + | ||
| 60 | + | **Tables synced:** | |
| 61 | + | ``` | |
| 62 | + | feeds → feed_tags, query_feeds | |
| 63 | + | user_config | |
| 64 | + | feed_items (partial: is_read + is_starred only) | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | **Special handling:** | |
| 68 | + | - `feed_items`: Uses UPDATE (not INSERT OR REPLACE) — only syncs user read/star state, never full content | |
| 69 | + | - `feed_items` deletes are ignored — content re-fetches from source feeds | |
| 70 | + | - Changelog retention cap: MAX_CHANGELOG_ENTRIES = 10,000 (prevents unbounded growth) | |
| 71 | + | ||
| 72 | + | **Location:** `src-tauri/src/sync_service.rs` (1062 lines, 30 tests) | |
| 73 | + | ||
| 74 | + | ### audiofiles (9 tables) | |
| 75 | + | ||
| 76 | + | **Tables synced:** | |
| 77 | + | ``` | |
| 78 | + | vfs → vfs_nodes | |
| 79 | + | samples → audio_analysis, tags, collection_members | |
| 80 | + | collections → collection_members | |
| 81 | + | smart_folders | |
| 82 | + | user_config | |
| 83 | + | ``` | |
| 84 | + | ||
| 85 | + | **Special handling — blob sync:** | |
| 86 | + | 1. VFS entries have a `sync_files` flag controlling blob sync | |
| 87 | + | 2. Samples marked `cloud_only = 1` if blob doesn't exist locally | |
| 88 | + | 3. After push/pull, upload pending blobs (local files in sync-enabled VFS) | |
| 89 | + | 4. Download missing blobs (cloud_only samples where file is needed) | |
| 90 | + | ||
| 91 | + | **Location:** `crates/audiofiles-sync/src/service.rs` (1438 lines, 48 tests) | |
| 92 | + | ||
| 93 | + | --- | |
| 94 | + | ||
| 95 | + | ## SDK Public API | |
| 96 | + | ||
| 97 | + | ### Authentication | |
| 98 | + | ||
| 99 | + | ```rust | |
| 100 | + | use synckit_client::SyncKitClient; | |
| 101 | + | ||
| 102 | + | // Create client | |
| 103 | + | let client = SyncKitClient::new(SyncKitConfig { | |
| 104 | + | server_url: "https://makenot.work".into(), | |
| 105 | + | api_key: "your-app-api-key".into(), | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | // OAuth2 PKCE flow | |
| 109 | + | let auth_url = client.build_authorize_url(port, state, code_challenge); | |
| 110 | + | // ... user completes browser flow ... | |
| 111 | + | let (user_id, app_id) = client.authenticate_with_code(code, code_verifier, port).await?; | |
| 112 | + | ||
| 113 | + | // Session management | |
| 114 | + | client.is_token_expired() -> bool | |
| 115 | + | client.session_info() -> Option<SessionInfo> | |
| 116 | + | client.clear_session() -> Result<()> | |
| 117 | + | ``` | |
| 118 | + | ||
| 119 | + | ### Encryption Setup | |
| 120 | + | ||
| 121 | + | ```rust | |
| 122 | + | // First device: generate new master key | |
| 123 | + | client.setup_encryption_new(password).await?; | |
| 124 | + | ||
| 125 | + | // Subsequent devices: decrypt existing key from server | |
| 126 | + | client.setup_encryption_existing(password).await?; | |
| 127 | + | ||
| 128 | + | // Try restore from OS keychain (macOS Keychain, Linux secret-service, Windows Credential Manager) | |
| 129 | + | client.try_load_key_from_keychain().await? -> bool | |
| 130 | + | ||
| 131 | + | // Check if server has encrypted key (determines new vs existing flow) | |
| 132 | + | client.has_server_key().await? -> bool | |
| 133 | + | client.has_master_key() -> bool | |
| 134 | + | ``` | |
| 135 | + | ||
| 136 | + | ### Device Management | |
| 137 | + | ||
| 138 | + | ```rust | |
| 139 | + | client.register_device(hostname, platform).await? -> Device | |
| 140 | + | client.list_devices().await? -> Vec<Device> | |
| 141 | + | ``` | |
| 142 | + | ||
| 143 | + | ### Push/Pull | |
| 144 | + | ||
| 145 | + | ```rust | |
| 146 | + | use synckit_client::{ChangeEntry, ChangeOp}; | |
| 147 | + | ||
| 148 | + | // Push encrypted changes to server | |
| 149 | + | client.push(device_id, changes: Vec<ChangeEntry>).await?; | |
| 150 | + | ||
| 151 | + | // Pull decrypted changes from server | |
| 152 | + | let (changes, new_cursor, has_more) = client.pull(device_id, cursor).await?; | |
| 153 | + | ``` | |
| 154 | + | ||
| 155 | + | `ChangeEntry` fields: | |
| 156 | + | - `table`: Table name (string) | |
| 157 | + | - `op`: `ChangeOp::Insert`, `Update`, or `Delete` | |
| 158 | + | - `row_id`: Primary key (string) | |
| 159 | + | - `timestamp`: When the change was made | |
| 160 | + | - `data`: `Option<serde_json::Value>` (None for deletes) | |
| 161 | + | ||
| 162 | + | ### Blob Operations (audiofiles only) | |
| 163 | + | ||
| 164 | + | ```rust | |
| 165 | + | // Upload | |
| 166 | + | let resp = client.blob_upload_url(hash, size).await?; | |
| 167 | + | client.blob_upload(resp.url, data).await?; | |
| 168 | + | client.blob_confirm(hash, size).await?; | |
| 169 | + | ||
| 170 | + | // Download | |
| 171 | + | let url = client.blob_download_url(hash).await?; | |
| 172 | + | let data = client.blob_download(url).await?; | |
| 173 | + | ``` | |
| 174 | + | ||
| 175 | + | --- | |
| 176 | + | ||
| 177 | + | ## First-Run Sequence | |
| 178 | + | ||
| 179 | + | ### First Device | |
| 180 | + | ||
| 181 | + | 1. User clicks "Connect to Sync" | |
| 182 | + | 2. App builds auth URL with PKCE challenge → opens browser | |
| 183 | + | 3. User logs in on MNW, approves scopes | |
| 184 | + | 4. Browser redirects to `localhost:PORT` with authorization code | |
| 185 | + | 5. App exchanges code for JWT via SDK | |
| 186 | + | 6. App detects no server key → shows "Set Encryption Password" dialog | |
| 187 | + | 7. User enters password → `setup_encryption_new(password)` generates and uploads encrypted key | |
| 188 | + | 8. App registers device → stores device_id in sync_state | |
| 189 | + | 9. App creates initial snapshot (INSERT all existing rows to changelog) | |
| 190 | + | 10. First sync cycle runs | |
| 191 | + | ||
| 192 | + | ### Same Device, Later Run | |
| 193 | + | ||
| 194 | + | 1. App calls `try_load_key_from_keychain()` → restores session + key | |
| 195 | + | 2. If success, ready to sync | |
| 196 | + | 3. If keychain empty, re-run OAuth flow | |
| 197 | + | ||
| 198 | + | ### Additional Device | |
| 199 | + | ||
| 200 | + | 1. OAuth flow as above | |
| 201 | + | 2. App detects server has key → shows "Enter Encryption Password" dialog | |
| 202 | + | 3. `setup_encryption_existing(password)` decrypts server key → saves to local keychain | |
| 203 | + | 4. Register new device, create initial snapshot, sync | |
| 204 | + | ||
| 205 | + | --- | |
| 206 | + | ||
| 207 | + | ## Adding Sync to a New App | |
| 208 | + | ||
| 209 | + | ### 1. Database Schema | |
| 210 | + | ||
| 211 | + | Add these tables: | |
| 212 | + | ||
| 213 | + | ```sql | |
| 214 | + | CREATE TABLE sync_changelog ( | |
| 215 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 216 | + | table_name TEXT NOT NULL, | |
| 217 | + | op TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' | |
| 218 | + | row_id TEXT NOT NULL, | |
| 219 | + | timestamp INTEGER NOT NULL, | |
| 220 | + | data TEXT, -- JSON, NULL for DELETE | |
| 221 | + | pushed INTEGER DEFAULT 0 | |
| 222 | + | ); | |
| 223 | + | ||
| 224 | + | CREATE TABLE sync_state ( | |
| 225 | + | key TEXT PRIMARY KEY, | |
| 226 | + | value TEXT NOT NULL | |
| 227 | + | ); | |
| 228 | + | ||
| 229 | + | -- Seed defaults | |
| 230 | + | INSERT INTO sync_state VALUES ('pull_cursor', '0'); | |
| 231 | + | INSERT INTO sync_state VALUES ('applying_remote', '0'); | |
| 232 | + | INSERT INTO sync_state VALUES ('initial_snapshot_done', '0'); | |
| 233 | + | INSERT INTO sync_state VALUES ('auto_sync_enabled', '1'); | |
| 234 | + | INSERT INTO sync_state VALUES ('sync_interval_minutes', '15'); | |
| 235 | + | ``` | |
| 236 | + | ||
| 237 | + | ### 2. Triggers | |
| 238 | + | ||
| 239 | + | For each syncable table, add three triggers: | |
| 240 | + | ||
| 241 | + | ```sql | |
| 242 | + | CREATE TRIGGER after_insert_my_table AFTER INSERT ON my_table | |
| 243 | + | BEGIN | |
| 244 | + | SELECT CASE | |
| 245 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 246 | + | THEN (INSERT INTO sync_changelog (table_name, op, row_id, timestamp, data) | |
| 247 | + | VALUES ('my_table', 'INSERT', NEW.id, strftime('%s','now'), | |
| 248 | + | json_object('col1', NEW.col1, 'col2', NEW.col2))) | |
| 249 | + | END; | |
| 250 | + | END; | |
| 251 | + | -- Repeat for UPDATE and DELETE (DELETE uses OLD.id, data = NULL) | |
| 252 | + | ``` | |
| 253 | + | ||
| 254 | + | ### 3. Core Module | |
| 255 | + | ||
| 256 | + | Implement these functions: | |
| 257 | + | ||
| 258 | + | | Function | Purpose | | |
| 259 | + | |----------|---------| | |
| 260 | + | | `get_sync_state(key)` | Read from sync_state | | |
| 261 | + | | `set_sync_state(key, value)` | Write to sync_state | | |
| 262 | + | | `ensure_device_registered(client)` | Cache device_id after first registration | | |
| 263 | + | | `create_initial_snapshot()` | One-time: INSERT all existing rows to changelog | | |
| 264 | + | | `push_changes(client, device_id)` | Batch 500, read/encrypt/send/mark-pushed | | |
| 265 | + | | `pull_changes(client, device_id)` | Loop until no more, decrypt/apply/save-cursor | | |
| 266 | + | | `apply_upsert(table, row_id, data)` | INSERT OR REPLACE with FK order | | |
| 267 | + | | `apply_delete(table, row_id)` | DELETE with FK order | | |
| 268 | + | | `cleanup_changelog()` | Prune pushed entries older than 7 days | | |
| 269 | + | ||
| 270 | + | ### 4. Scheduler | |
| 271 | + | ||
| 272 | + | - Check every 60 seconds if sync is due | |
| 273 | + | - On first run: `create_initial_snapshot()` if needed | |
| 274 | + | - Exponential backoff on failure (2^N minutes, capped at 15) | |
| 275 | + | - Emit status events for UI updates | |
| 276 | + | ||
| 277 | + | ### 5. Commands / UI | |
| 278 | + | ||
| 279 | + | Expose these to the frontend: | |
| 280 | + | - `sync_status()` — configured, authenticated, encryption, device, pending changes | |
| 281 | + | - `sync_start_auth()` — returns auth URL + state + verifier | |
| 282 | + | - `sync_complete_auth(code, state, verifier)` — exchanges for JWT | |
| 283 | + | - `sync_setup_encryption_new/existing(password)` — calls SDK method | |
| 284 | + | - `sync_now()` — triggers immediate cycle | |
| 285 | + | - `sync_disconnect()` — clears credentials and session | |
| 286 | + | ||
| 287 | + | --- | |
| 288 | + | ||
| 289 | + | ## Key Files | |
| 290 | + | ||
| 291 | + | | What | Where | | |
| 292 | + | |------|-------| | |
| 293 | + | | SDK source | `Shared/synckit-client/src/` | | |
| 294 | + | | SDK auth | `Shared/synckit-client/src/client/auth.rs` | | |
| 295 | + | | SDK push/pull | `Shared/synckit-client/src/client/sync.rs` | | |
| 296 | + | | SDK encryption | `Shared/synckit-client/src/crypto.rs` | | |
| 297 | + | | GO sync service | `Apps/goingson/src-tauri/src/sync_service.rs` | | |
| 298 | + | | BB sync service | `Apps/balanced_breakfast/src-tauri/src/sync_service.rs` | | |
| 299 | + | | AF sync service | `Apps/audiofiles/crates/audiofiles-sync/src/service.rs` | | |
| 300 | + | | Server endpoints | `MNW/src/routes/synckit.rs` | | |
| 301 | + | | Server DB | `MNW/src/db/synckit.rs` | |
| @@ -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,7 +60,7 @@ impl SyncKitClient { | |||
| 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 @@ impl SyncKitClient { | |||
| 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 @@ impl SyncKitClient { | |||
| 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 @@ impl SyncKitClient { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ impl SyncKitClient { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod auth; | |||
| 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 @@ struct Endpoints { | |||
| 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 @@ impl Endpoints { | |||
| 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 @@ impl SyncKitClient { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 @@ mod tests { | |||
| 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 ── |
| @@ -0,0 +1,148 @@ | |||
| 1 | + | //! SSE push notification stream for real-time sync notifications. | |
| 2 | + | //! | |
| 3 | + | //! The server sends zero-data `event: changed` events over SSE whenever | |
| 4 | + | //! another device pushes changes. The client should pull on each event. | |
| 5 | + | //! No auto-reconnect — consumer apps handle reconnect in their scheduler. | |
| 6 | + | ||
| 7 | + | use crate::error::{Result, SyncKitError}; | |
| 8 | + | ||
| 9 | + | use super::SyncKitClient; | |
| 10 | + | ||
| 11 | + | /// A stream of SSE "changed" notifications from the SyncKit server. | |
| 12 | + | /// | |
| 13 | + | /// Created by [`SyncKitClient::subscribe`]. Wraps a raw HTTP response byte | |
| 14 | + | /// stream and parses SSE events line by line. Yields `Some(())` for each | |
| 15 | + | /// `event: changed`, `None` when the stream ends or encounters an error. | |
| 16 | + | pub struct SyncNotifyStream { | |
| 17 | + | buffer: String, | |
| 18 | + | response: reqwest::Response, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl SyncNotifyStream { | |
| 22 | + | pub(crate) fn new(response: reqwest::Response) -> Self { | |
| 23 | + | Self { | |
| 24 | + | buffer: String::new(), | |
| 25 | + | response, | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Wait for the next "changed" notification. | |
| 30 | + | /// | |
| 31 | + | /// Returns `Some(())` when the server signals new changes are available. | |
| 32 | + | /// Returns `None` when the stream ends (server closed, network error). | |
| 33 | + | /// Keepalive comments (lines starting with `:`) are silently ignored. | |
| 34 | + | pub async fn next_change(&mut self) -> Option<()> { | |
| 35 | + | loop { | |
| 36 | + | // Try to extract a complete SSE block from the buffer | |
| 37 | + | if let Some(pos) = self.buffer.find("\n\n") { | |
| 38 | + | let block = self.buffer[..pos].to_string(); | |
| 39 | + | self.buffer = self.buffer[pos + 2..].to_string(); | |
| 40 | + | ||
| 41 | + | if parse_sse_block_is_changed(&block) { | |
| 42 | + | return Some(()); | |
| 43 | + | } | |
| 44 | + | // Not a "changed" event — skip and try next block | |
| 45 | + | continue; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Need more data from the stream | |
| 49 | + | match self.response.chunk().await { | |
| 50 | + | Ok(Some(chunk)) => { | |
| 51 | + | let text = String::from_utf8_lossy(&chunk); | |
| 52 | + | self.buffer.push_str(&text); | |
| 53 | + | } | |
| 54 | + | Ok(None) | Err(_) => return None, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Parse an SSE block and return `true` if it contains `event: changed`. | |
| 61 | + | /// | |
| 62 | + | /// An SSE block is one or more lines separated by `\n`, terminated by `\n\n`. | |
| 63 | + | /// Lines starting with `:` are comments (keepalive). The `event:` field | |
| 64 | + | /// specifies the event type, `data:` the payload (ignored here). | |
| 65 | + | fn parse_sse_block_is_changed(block: &str) -> bool { | |
| 66 | + | for line in block.lines() { | |
| 67 | + | let trimmed = line.trim(); | |
| 68 | + | // Skip comments | |
| 69 | + | if trimmed.starts_with(':') { | |
| 70 | + | continue; | |
| 71 | + | } | |
| 72 | + | if let Some(value) = trimmed.strip_prefix("event:") { | |
| 73 | + | if value.trim() == "changed" { | |
| 74 | + | return true; | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | false | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | impl SyncKitClient { | |
| 82 | + | /// Open an SSE connection for real-time push notifications. | |
| 83 | + | /// | |
| 84 | + | /// Returns a [`SyncNotifyStream`] that yields `Some(())` each time the | |
| 85 | + | /// server signals new changes are available. The caller should pull after | |
| 86 | + | /// each notification to get the actual changes. | |
| 87 | + | /// | |
| 88 | + | /// The stream does not auto-reconnect. If the connection drops, | |
| 89 | + | /// `next_change()` returns `None` and the caller should reconnect | |
| 90 | + | /// (typically after a short delay). | |
| 91 | + | #[tracing::instrument(skip(self))] | |
| 92 | + | pub async fn subscribe(&self) -> Result<SyncNotifyStream> { | |
| 93 | + | let token = self.require_token()?; | |
| 94 | + | let (app_id, _user_id) = self.require_session_ids()?; | |
| 95 | + | let url = format!("{}?app_id={}", self.endpoints.subscribe, app_id); | |
| 96 | + | ||
| 97 | + | let resp = self | |
| 98 | + | .http | |
| 99 | + | .get(&url) | |
| 100 | + | .bearer_auth(&*token) | |
| 101 | + | .send() | |
| 102 | + | .await | |
| 103 | + | .map_err(SyncKitError::Http)?; | |
| 104 | + | ||
| 105 | + | let status = resp.status().as_u16(); | |
| 106 | + | if status >= 400 { | |
| 107 | + | let message = resp.text().await.unwrap_or_default(); | |
| 108 | + | return Err(SyncKitError::Server { status, message }); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | Ok(SyncNotifyStream::new(resp)) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[cfg(test)] | |
| 116 | + | mod tests { | |
| 117 | + | use super::*; | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn parse_changed_event() { | |
| 121 | + | let block = "event: changed\ndata: {}"; | |
| 122 | + | assert!(parse_sse_block_is_changed(block)); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn parse_ignores_keepalive_comments() { | |
| 127 | + | let block = ": keepalive"; | |
| 128 | + | assert!(!parse_sse_block_is_changed(block)); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn parse_handles_malformed_lines() { | |
| 133 | + | let block = "garbled nonsense"; | |
| 134 | + | assert!(!parse_sse_block_is_changed(block)); | |
| 135 | + | ||
| 136 | + | let block2 = ""; | |
| 137 | + | assert!(!parse_sse_block_is_changed(block2)); | |
| 138 | + | ||
| 139 | + | let block3 = "event:other\ndata: test"; | |
| 140 | + | assert!(!parse_sse_block_is_changed(block3)); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn parse_changed_with_extra_whitespace() { | |
| 145 | + | let block = "event: changed \ndata: {}"; | |
| 146 | + | assert!(parse_sse_block_is_changed(block)); | |
| 147 | + | } | |
| 148 | + | } |
| @@ -154,6 +154,152 @@ impl SyncKitClient { | |||
| 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> { |
| @@ -0,0 +1,630 @@ | |||
| 1 | + | //! Client-side conflict detection and resolution for SyncKit. | |
| 2 | + | //! | |
| 3 | + | //! SyncKit uses E2E encryption — the server never sees row contents and cannot | |
| 4 | + | //! merge or compare data. All conflict resolution must happen client-side after | |
| 5 | + | //! decryption. | |
| 6 | + | //! | |
| 7 | + | //! This module provides: | |
| 8 | + | //! - [`detect_conflicts`]: pure function that splits pulled changes into clean | |
| 9 | + | //! (non-conflicting) and conflicting sets | |
| 10 | + | //! - [`resolve_lww`]: last-write-wins resolution by `client_timestamp` | |
| 11 | + | //! - [`resolve_field_merge`]: 3-way JSON object merge using a base version | |
| 12 | + | //! - [`ConflictResolver`]: trait for custom resolution strategies | |
| 13 | + | ||
| 14 | + | use std::collections::HashMap; | |
| 15 | + | ||
| 16 | + | use chrono::{DateTime, Utc}; | |
| 17 | + | use uuid::Uuid; | |
| 18 | + | ||
| 19 | + | use crate::types::{ChangeEntry, ChangeOp, PulledChange}; | |
| 20 | + | ||
| 21 | + | /// A remote change that conflicts with a local pending change. | |
| 22 | + | #[derive(Debug, Clone)] | |
| 23 | + | pub struct ConflictPair { | |
| 24 | + | /// The remote change from pull. | |
| 25 | + | pub remote: PulledChange, | |
| 26 | + | /// The local pending change that conflicts. | |
| 27 | + | pub local: ChangeEntry, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// How a conflict should be resolved. | |
| 31 | + | #[derive(Debug, Clone)] | |
| 32 | + | pub enum Resolution { | |
| 33 | + | /// Keep the local version; skip the remote change. | |
| 34 | + | /// The local version will push on the next sync cycle. | |
| 35 | + | KeepLocal, | |
| 36 | + | /// Apply the remote change, discarding the local version. | |
| 37 | + | KeepRemote, | |
| 38 | + | /// Apply a merged result that combines both changes. | |
| 39 | + | Merged(serde_json::Value), | |
| 40 | + | /// Skip both changes (neither apply nor push). | |
| 41 | + | Skip, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Trait for custom conflict resolution strategies. | |
| 45 | + | /// | |
| 46 | + | /// Implement this to plug in app-specific merge logic. The `base` parameter | |
| 47 | + | /// is `Some` only if the app provides a base-store adapter. | |
| 48 | + | pub trait ConflictResolver: Send + Sync { | |
| 49 | + | fn resolve( | |
| 50 | + | &self, | |
| 51 | + | local: &ChangeEntry, | |
| 52 | + | remote: &PulledChange, | |
| 53 | + | base: Option<&serde_json::Value>, | |
| 54 | + | ) -> Resolution; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Split pulled changes into non-conflicting and conflicting sets. | |
| 58 | + | /// | |
| 59 | + | /// A conflict exists when a remote change and a local pending change both | |
| 60 | + | /// modify the same `(table, row_id)` from different devices. Changes from | |
| 61 | + | /// our own device (echoes) are never treated as conflicts. | |
| 62 | + | /// | |
| 63 | + | /// Returns `(clean, conflicts)` where `clean` changes can be applied directly. | |
| 64 | + | pub fn detect_conflicts( | |
| 65 | + | remote: Vec<PulledChange>, | |
| 66 | + | local_pending: &[ChangeEntry], | |
| 67 | + | our_device_id: Uuid, | |
| 68 | + | ) -> (Vec<PulledChange>, Vec<ConflictPair>) { | |
| 69 | + | // Build lookup: (table, row_id) -> last local pending entry | |
| 70 | + | let mut local_map: HashMap<(&str, &str), &ChangeEntry> = HashMap::new(); | |
| 71 | + | for entry in local_pending { | |
| 72 | + | local_map.insert((&entry.table, &entry.row_id), entry); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | let mut clean = Vec::new(); | |
| 76 | + | let mut conflicts = Vec::new(); | |
| 77 | + | ||
| 78 | + | for pulled in remote { | |
| 79 | + | // Our own echo — never a conflict | |
| 80 | + | if pulled.device_id == our_device_id { | |
| 81 | + | clean.push(pulled); | |
| 82 | + | continue; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | let key = (pulled.entry.table.as_str(), pulled.entry.row_id.as_str()); | |
| 86 | + | if let Some(&local_entry) = local_map.get(&key) { | |
| 87 | + | conflicts.push(ConflictPair { | |
| 88 | + | remote: pulled, | |
| 89 | + | local: local_entry.clone(), | |
| 90 | + | }); | |
| 91 | + | } else { | |
| 92 | + | clean.push(pulled); | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | (clean, conflicts) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Last-write-wins resolution by `client_timestamp`. | |
| 100 | + | /// | |
| 101 | + | /// - DELETE vs non-DELETE: delete wins regardless of timestamp. | |
| 102 | + | /// - Both INSERT/UPDATE: newer timestamp wins. Ties go to local. | |
| 103 | + | pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution { | |
| 104 | + | // DELETE wins over non-DELETE | |
| 105 | + | if local.op == ChangeOp::Delete || remote.entry.op == ChangeOp::Delete { | |
| 106 | + | return if local.op == ChangeOp::Delete { | |
| 107 | + | Resolution::KeepLocal | |
| 108 | + | } else { | |
| 109 | + | Resolution::KeepRemote | |
| 110 | + | }; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Both are INSERT/UPDATE: newer timestamp wins, ties go to local | |
| 114 | + | if local.timestamp >= remote.entry.timestamp { | |
| 115 | + | Resolution::KeepLocal | |
| 116 | + | } else { | |
| 117 | + | Resolution::KeepRemote | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// 3-way field-level merge for JSON objects. | |
| 122 | + | /// | |
| 123 | + | /// Compares `local` and `remote` against `base` to determine which fields | |
| 124 | + | /// each side changed, then merges non-overlapping changes. For overlapping | |
| 125 | + | /// fields, the newer timestamp wins. | |
| 126 | + | /// | |
| 127 | + | /// Returns `Resolution::KeepRemote` if any input is not a JSON object. | |
| 128 | + | pub fn resolve_field_merge( | |
| 129 | + | local: &serde_json::Value, | |
| 130 | + | remote: &serde_json::Value, | |
| 131 | + | base: &serde_json::Value, | |
| 132 | + | local_ts: DateTime<Utc>, | |
| 133 | + | remote_ts: DateTime<Utc>, | |
| 134 | + | ) -> Resolution { | |
| 135 | + | let (Some(local_obj), Some(remote_obj), Some(base_obj)) = ( | |
| 136 | + | local.as_object(), | |
| 137 | + | remote.as_object(), | |
| 138 | + | base.as_object(), | |
| 139 | + | ) else { | |
| 140 | + | return Resolution::KeepRemote; | |
| 141 | + | }; | |
| 142 | + | ||
| 143 | + | // Compute diffs: keys where local/remote differ from base | |
| 144 | + | let mut local_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new(); | |
| 145 | + | let mut remote_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new(); | |
| 146 | + | ||
| 147 | + | // Check keys in base for changes or deletions | |
| 148 | + | for key in base_obj.keys() { | |
| 149 | + | let base_val = &base_obj[key]; | |
| 150 | + | ||
| 151 | + | match local_obj.get(key) { | |
| 152 | + | Some(local_val) if local_val != base_val => { | |
| 153 | + | local_changed.insert(key, Some(local_val)); | |
| 154 | + | } | |
| 155 | + | None => { | |
| 156 | + | // Key deleted on local side | |
| 157 | + | local_changed.insert(key, None); | |
| 158 | + | } | |
| 159 | + | _ => {} | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | match remote_obj.get(key) { | |
| 163 | + | Some(remote_val) if remote_val != base_val => { | |
| 164 | + | remote_changed.insert(key, Some(remote_val)); | |
| 165 | + | } | |
| 166 | + | None => { | |
| 167 | + | // Key deleted on remote side | |
| 168 | + | remote_changed.insert(key, None); | |
| 169 | + | } | |
| 170 | + | _ => {} | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Check for new keys added by local (not in base) | |
| 175 | + | for (key, val) in local_obj { | |
| 176 | + | if !base_obj.contains_key(key) { | |
| 177 | + | local_changed.insert(key, Some(val)); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Check for new keys added by remote (not in base) | |
| 182 | + | for (key, val) in remote_obj { | |
| 183 | + | if !base_obj.contains_key(key) { | |
| 184 | + | remote_changed.insert(key, Some(val)); | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Build merged result starting from base | |
| 189 | + | let mut result = base_obj.clone(); | |
| 190 | + | ||
| 191 | + | // Apply local changes | |
| 192 | + | for (key, val) in &local_changed { | |
| 193 | + | match val { | |
| 194 | + | Some(v) => { result.insert((*key).to_string(), (*v).clone()); } | |
| 195 | + | None => { result.remove(*key); } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Apply remote changes (non-overlapping only, or newer timestamp wins) | |
| 200 | + | for (key, val) in &remote_changed { | |
| 201 | + | if local_changed.contains_key(key) { | |
| 202 | + | // Overlapping: newer timestamp wins, ties go to local | |
| 203 | + | if remote_ts > local_ts { | |
| 204 | + | match val { | |
| 205 | + | Some(v) => { result.insert((*key).to_string(), (*v).clone()); } | |
| 206 | + | None => { result.remove(*key); } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | // else: local already applied above | |
| 210 | + | } else { | |
| 211 | + | // Non-overlapping: apply remote | |
| 212 | + | match val { | |
| 213 | + | Some(v) => { result.insert((*key).to_string(), (*v).clone()); } | |
| 214 | + | None => { result.remove(*key); } | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | Resolution::Merged(serde_json::Value::Object(result)) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[cfg(test)] | |
| 223 | + | mod tests { | |
| 224 | + | use super::*; | |
| 225 | + | use serde_json::json; | |
| 226 | + | ||
| 227 | + | fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry { | |
| 228 | + | ChangeEntry { | |
| 229 | + | table: table.to_string(), | |
| 230 | + | op, | |
| 231 | + | row_id: row_id.to_string(), | |
| 232 | + | timestamp: ts, | |
| 233 | + | data: Some(json!({"value": "test"})), | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | fn make_pulled( | |
| 238 | + | table: &str, | |
| 239 | + | row_id: &str, | |
| 240 | + | op: ChangeOp, | |
| 241 | + | ts: DateTime<Utc>, | |
| 242 | + | device_id: Uuid, | |
| 243 | + | seq: i64, | |
| 244 | + | ) -> PulledChange { | |
| 245 | + | PulledChange { | |
| 246 | + | entry: make_entry(table, row_id, op, ts), | |
| 247 | + | device_id, | |
| 248 | + | seq, | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // ── detect_conflicts ── | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn no_conflicts_when_different_rows() { | |
| 256 | + | let our_device = Uuid::new_v4(); | |
| 257 | + | let other_device = Uuid::new_v4(); | |
| 258 | + | let now = Utc::now(); | |
| 259 | + | ||
| 260 | + | let remote = vec![ | |
| 261 | + | make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), | |
| 262 | + | ]; | |
| 263 | + | let local = vec![ | |
| 264 | + | make_entry("tasks", "r2", ChangeOp::Update, now), | |
| 265 | + | ]; | |
| 266 | + | ||
| 267 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 268 | + | assert_eq!(clean.len(), 1); | |
| 269 | + | assert!(conflicts.is_empty()); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn conflict_detected_same_row_different_device() { | |
| 274 | + | let our_device = Uuid::new_v4(); | |
| 275 | + | let other_device = Uuid::new_v4(); | |
| 276 | + | let now = Utc::now(); | |
| 277 | + | ||
| 278 | + | let remote = vec![ | |
| 279 | + | make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), | |
| 280 | + | ]; | |
| 281 | + | let local = vec![ | |
| 282 | + | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 283 | + | ]; | |
| 284 | + | ||
| 285 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 286 | + | assert!(clean.is_empty()); | |
| 287 | + | assert_eq!(conflicts.len(), 1); | |
| 288 | + | assert_eq!(conflicts[0].remote.entry.row_id, "r1"); | |
| 289 | + | assert_eq!(conflicts[0].local.row_id, "r1"); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn own_echo_not_treated_as_conflict() { | |
| 294 | + | let our_device = Uuid::new_v4(); | |
| 295 | + | let now = Utc::now(); | |
| 296 | + | ||
| 297 | + | let remote = vec![ | |
| 298 | + | make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1), | |
| 299 | + | ]; | |
| 300 | + | let local = vec![ | |
| 301 | + | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 302 | + | ]; | |
| 303 | + | ||
| 304 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 305 | + | assert_eq!(clean.len(), 1); | |
| 306 | + | assert!(conflicts.is_empty()); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[test] | |
| 310 | + | fn different_tables_same_row_id_no_conflict() { | |
| 311 | + | let our_device = Uuid::new_v4(); | |
| 312 | + | let other_device = Uuid::new_v4(); | |
| 313 | + | let now = Utc::now(); | |
| 314 | + | ||
| 315 | + | let remote = vec![ | |
| 316 | + | make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), | |
| 317 | + | ]; | |
| 318 | + | let local = vec![ | |
| 319 | + | make_entry("events", "r1", ChangeOp::Update, now), | |
| 320 | + | ]; | |
| 321 | + | ||
| 322 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 323 | + | assert_eq!(clean.len(), 1); | |
| 324 | + | assert!(conflicts.is_empty()); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn detect_conflicts_correct_split() { | |
| 329 | + | let our_device = Uuid::new_v4(); | |
| 330 | + | let other_device = Uuid::new_v4(); | |
| 331 | + | let now = Utc::now(); | |
| 332 | + | ||
| 333 | + | let remote = vec![ | |
| 334 | + | make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), | |
| 335 | + | make_pulled("tasks", "r2", ChangeOp::Insert, now, other_device, 2), | |
| 336 | + | make_pulled("events", "r3", ChangeOp::Delete, now, other_device, 3), | |
| 337 | + | ]; | |
| 338 | + | let local = vec![ | |
| 339 | + | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 340 | + | // r2 not in local → clean | |
| 341 | + | // r3 not in local → clean | |
| 342 | + | ]; | |
| 343 | + | ||
| 344 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 345 | + | assert_eq!(clean.len(), 2); | |
| 346 | + | assert_eq!(conflicts.len(), 1); | |
| 347 | + | assert_eq!(conflicts[0].remote.entry.row_id, "r1"); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn empty_remote_produces_no_conflicts() { | |
| 352 | + | let our_device = Uuid::new_v4(); | |
| 353 | + | let now = Utc::now(); | |
| 354 | + | ||
| 355 | + | let remote = vec![]; | |
| 356 | + | let local = vec![ | |
| 357 | + | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 358 | + | ]; | |
| 359 | + | ||
| 360 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 361 | + | assert!(clean.is_empty()); | |
| 362 | + | assert!(conflicts.is_empty()); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn empty_local_produces_no_conflicts() { | |
| 367 | + | let our_device = Uuid::new_v4(); | |
| 368 | + | let other_device = Uuid::new_v4(); | |
| 369 | + | let now = Utc::now(); | |
| 370 | + | ||
| 371 | + | let remote = vec![ | |
| 372 | + | make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), | |
| 373 | + | ]; | |
| 374 | + | let local: Vec<ChangeEntry> = vec![]; | |
| 375 | + | ||
| 376 | + | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 377 | + | assert_eq!(clean.len(), 1); | |
| 378 | + | assert!(conflicts.is_empty()); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // ── resolve_lww ── | |
| 382 | + | ||
| 383 | + | #[test] | |
| 384 | + | fn lww_picks_newer_timestamp() { | |
| 385 | + | let other_device = Uuid::new_v4(); | |
| 386 | + | let old = Utc::now() - chrono::Duration::seconds(60); | |
| 387 | + | let new = Utc::now(); | |
| 388 | + | ||
| 389 | + | let local = make_entry("tasks", "r1", ChangeOp::Update, old); | |
| 390 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Update, new, other_device, 1); | |
| 391 | + | ||
| 392 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn lww_local_wins_when_newer() { | |
| 397 | + | let other_device = Uuid::new_v4(); | |
| 398 | + | let old = Utc::now() - chrono::Duration::seconds(60); | |
| 399 | + | let new = Utc::now(); | |
| 400 | + | ||
| 401 | + | let local = make_entry("tasks", "r1", ChangeOp::Update, new); | |
| 402 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Update, old, other_device, 1); | |
| 403 | + | ||
| 404 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | #[test] | |
| 408 | + | fn lww_equal_timestamps_local_wins() { | |
| 409 | + | let other_device = Uuid::new_v4(); | |
| 410 | + | let now = Utc::now(); | |
| 411 | + | ||
| 412 | + | let local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 413 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1); | |
| 414 | + | ||
| 415 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | #[test] | |
| 419 | + | fn lww_local_delete_wins_over_update() { | |
| 420 | + | let other_device = Uuid::new_v4(); | |
| 421 | + | let now = Utc::now(); | |
| 422 | + | ||
| 423 | + | let local = make_entry("tasks", "r1", ChangeOp::Delete, now); | |
| 424 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1); | |
| 425 | + | ||
| 426 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | #[test] | |
| 430 | + | fn lww_remote_delete_wins_over_update() { | |
| 431 | + | let other_device = Uuid::new_v4(); | |
| 432 | + | let now = Utc::now(); | |
| 433 | + | ||
| 434 | + | let local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 435 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); | |
| 436 | + | ||
| 437 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[test] | |
| 441 | + | fn lww_both_delete_local_wins() { | |
| 442 | + | let other_device = Uuid::new_v4(); | |
| 443 | + | let now = Utc::now(); | |
| 444 | + | ||
| 445 | + | let local = make_entry("tasks", "r1", ChangeOp::Delete, now); | |
| 446 | + | let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); | |
| 447 | + | ||
| 448 | + | assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // ── resolve_field_merge ── | |
| 452 | + | ||
| 453 | + | #[test] | |
| 454 | + | fn field_merge_non_overlapping_changes() { | |
| 455 | + | let base = json!({"title": "old", "status": "pending", "priority": 1}); | |
| 456 | + | let local = json!({"title": "new title", "status": "pending", "priority": 1}); | |
| 457 | + | let remote = json!({"title": "old", "status": "done", "priority": 1}); | |
| 458 | + | let now = Utc::now(); | |
| 459 | + | ||
| 460 | + | let result = resolve_field_merge(&local, &remote, &base, now, now); | |
| 461 | + | match result { | |
| 462 | + | Resolution::Merged(v) => { | |
| 463 | + | assert_eq!(v["title"], "new title"); | |
| 464 | + | assert_eq!(v["status"], "done"); | |
| 465 | + | assert_eq!(v["priority"], 1); | |
| 466 | + | } | |
| 467 | + | _ => panic!("Expected Merged"), | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | #[test] | |
| 472 | + | fn field_merge_overlapping_newer_wins() { | |
| 473 | + | let base = json!({"title": "old"}); | |
| 474 | + | let local = json!({"title": "local title"}); | |
| 475 | + | let remote = json!({"title": "remote title"}); | |
| 476 | + | let old = Utc::now() - chrono::Duration::seconds(60); | |
| 477 | + | let new = Utc::now(); | |
| 478 | + | ||
| 479 | + | // Remote is newer → remote wins the overlapping field | |
| 480 | + | let result = resolve_field_merge(&local, &remote, &base, old, new); | |
| 481 | + | match result { | |
| 482 | + | Resolution::Merged(v) => { | |
| 483 | + | assert_eq!(v["title"], "remote title"); | |
| 484 | + | } | |
| 485 | + | _ => panic!("Expected Merged"), | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // Local is newer → local wins the overlapping field | |
| 489 | + | let result = resolve_field_merge(&local, &remote, &base, new, old); | |
| 490 | + | match result { | |
| 491 | + | Resolution::Merged(v) => { | |
| 492 | + | assert_eq!(v["title"], "local title"); | |
| 493 | + | } | |
| 494 | + | _ => panic!("Expected Merged"), | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | #[test] | |
| 499 | + | fn field_merge_key_deleted_on_one_side() { | |
| 500 | + | let base = json!({"title": "old", "notes": "some notes"}); |
Lines truncated
| @@ -60,6 +60,13 @@ pub enum SyncKitError { | |||
| 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 @@ fn user_key(user_id: Uuid) -> String { | |||
| 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 @@ pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<( | |||
| 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 @@ pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> { | |||
| 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 @@ pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> { | |||
| 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 @@ pub(crate) struct PullChangeEntry { | |||
| 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 @@ mod tests { | |||
| 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 @@ fn authed_client(server: &MockServer) -> SyncKitClient { | |||
| 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 @@ async fn authenticate_success_stores_session() { | |||
| 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 @@ async fn authenticate_with_code_success() { | |||
| 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 @@ async fn push_encrypts_data() { | |||
| 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 @@ async fn pull_decrypts_data() { | |||
| 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 @@ async fn push_retries_on_503() { | |||
| 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 @@ async fn push_fails_immediately_on_401() { | |||
| 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 @@ async fn pull_with_has_more_pagination() { | |||
| 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 @@ async fn blob_upload_encrypts_data() { | |||
| 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 @@ async fn blob_download_decrypts_data() { | |||
| 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 @@ async fn blob_upload_retries_on_503() { | |||
| 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 @@ async fn expired_jwt_returns_token_expired() { | |||
| 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 @@ async fn near_expiry_jwt_returns_token_expired() { | |||
| 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 @@ async fn restore_then_clear_session() { | |||
| 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 @@ async fn concurrent_push_pull_no_panics() { | |||
| 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 @@ async fn setup_change_password_test( | |||
| 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 @@ async fn push_empty_changes_succeeds() { | |||
| 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 @@ async fn push_malformed_json_response_handled() { | |||
| 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 @@ async fn pull_malformed_json_response_handled() { | |||
| 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 @@ async fn concurrent_push_operations_no_data_corruption() { | |||
| 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 @@ async fn concurrent_push_and_pull_interleaved() { | |||
| 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 @@ async fn push_many_changes_succeeds() { | |||
| 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 @@ async fn expired_token_detected_before_push() { | |||
| 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 @@ async fn expired_token_detected_before_pull() { | |||
| 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 @@ async fn expired_token_detected_before_register_device() { | |||
| 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 @@ async fn expired_token_detected_before_list_devices() { | |||
| 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 @@ async fn blob_upload_zero_byte_data() { | |||
| 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 @@ async fn blob_upload_download_roundtrip() { | |||
| 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 @@ async fn double_push_same_data_both_succeed() { | |||
| 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 @@ async fn setup_encryption_new_stores_key_and_uploads_envelope() { | |||
| 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 @@ async fn setup_encryption_new_retries_on_server_error() { | |||
| 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 @@ async fn setup_encryption_existing_recovers_key() { | |||
| 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 @@ async fn setup_encryption_existing_wrong_password_fails() { | |||
| 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 @@ async fn setup_encryption_existing_retries_on_server_error() { | |||
| 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 @@ async fn blob_download_with_wrong_key_fails() { | |||
| 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 @@ async fn end_to_end_push_pull_encryption_roundtrip() { | |||
| 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 @@ async fn push_empty_response_body_returns_error() { | |||
| 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 @@ async fn pull_response_missing_has_more_returns_error() { | |||
| 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 @@ async fn server_returns_413_request_entity_too_large() { | |||
| 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 @@ async fn double_authenticate_overwrites_session() { | |||
| 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 @@ async fn clear_session_then_authenticate_succeeds() { | |||
| 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 @@ async fn restore_session_with_expired_token_then_push_returns_token_expired() { | |||
| 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 @@ async fn setup_encryption_new_twice_overwrites() { | |||
| 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 @@ async fn blob_download_retries_on_503() { | |||
| 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 @@ async fn blob_upload_1mb_with_correct_overhead() { | |||
| 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 @@ async fn concurrent_session_info_reads() { | |||
| 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 @@ async fn concurrent_has_master_key_reads() { | |||
| 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 @@ async fn concurrent_push_100_entries_each() { | |||
| 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 @@ fn authed_short_timeout_client(server: &MockServer) -> SyncKitClient { | |||
| 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 @@ async fn push_retries_on_timeout_then_succeeds() { | |||
| 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); |