Skip to main content

max / makenotwork

Move synckit-client and synckit-config out to the synckit repo The sync SDK is its own repo now (~/Code/synckit), so it versions and deploys independently of MNW. mnw-cli repoints its path dep to the new location; the server is unaffected, since it implements the sync endpoints rather than linking the client crate. The synckit server routes stay in this server. Splitting them into a standalone service is the separate mnw-strategy-synckit-vps-separation plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 21:30 UTC
Commit: 9d43b2ac5fb15ea3d36779042d4873da54e532cd
Parent: aec45f4
47 files changed, +1 insertion, -22587 deletions
@@ -17,7 +17,7 @@ tracing = "0.1"
17 17 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
18 18 anyhow = "1"
19 19 bytes = "1"
20 - synckit-client = { path = "../shared/synckit-client", default-features = false }
20 + synckit-client = { path = "../../synckit/synckit-client", default-features = false }
21 21 uuid = "1"
22 22
23 23 [lints.rust]
@@ -1,14 +0,0 @@
1 - /target
2 -
3 - # Environment
4 - .env
5 - .env.*
6 -
7 - # OS
8 - .DS_Store
9 -
10 - # IDE
11 - .idea/
12 - .vscode/
13 - *.swp
14 - *.swo
@@ -1,3347 +0,0 @@
1 - # This file is automatically @generated by Cargo.
2 - # It is not intended for manual editing.
3 - version = 4
4 -
5 - [[package]]
6 - name = "aead"
7 - version = "0.6.1"
8 - source = "registry+https://github.com/rust-lang/crates.io-index"
9 - checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
10 - dependencies = [
11 - "crypto-common 0.2.2",
12 - "inout 0.2.2",
13 - ]
14 -
15 - [[package]]
16 - name = "aes"
17 - version = "0.8.4"
18 - source = "registry+https://github.com/rust-lang/crates.io-index"
19 - checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
20 - dependencies = [
21 - "cfg-if",
22 - "cipher 0.4.4",
23 - "cpufeatures 0.2.17",
24 - ]
25 -
26 - [[package]]
27 - name = "aho-corasick"
28 - version = "1.1.4"
29 - source = "registry+https://github.com/rust-lang/crates.io-index"
30 - checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
31 - dependencies = [
32 - "memchr",
33 - ]
34 -
35 - [[package]]
36 - name = "android_system_properties"
37 - version = "0.1.5"
38 - source = "registry+https://github.com/rust-lang/crates.io-index"
39 - checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
40 - dependencies = [
41 - "libc",
42 - ]
43 -
44 - [[package]]
45 - name = "anyhow"
46 - version = "1.0.102"
47 - source = "registry+https://github.com/rust-lang/crates.io-index"
48 - checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
49 -
50 - [[package]]
51 - name = "apple-native-keyring-store"
52 - version = "1.0.1"
53 - source = "registry+https://github.com/rust-lang/crates.io-index"
54 - checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29"
55 - dependencies = [
56 - "keyring-core",
57 - "log",
58 - "security-framework",
59 - ]
60 -
61 - [[package]]
62 - name = "argon2"
63 - version = "0.5.3"
64 - source = "registry+https://github.com/rust-lang/crates.io-index"
65 - checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
66 - dependencies = [
67 - "base64ct",
68 - "blake2",
69 - "cpufeatures 0.2.17",
70 - "password-hash",
71 - ]
72 -
73 - [[package]]
74 - name = "assert-json-diff"
75 - version = "2.0.2"
76 - source = "registry+https://github.com/rust-lang/crates.io-index"
77 - checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
78 - dependencies = [
79 - "serde",
80 - "serde_json",
81 - ]
82 -
83 - [[package]]
84 - name = "async-broadcast"
85 - version = "0.7.2"
86 - source = "registry+https://github.com/rust-lang/crates.io-index"
87 - checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
88 - dependencies = [
89 - "event-listener",
90 - "event-listener-strategy",
91 - "futures-core",
92 - "pin-project-lite",
93 - ]
94 -
95 - [[package]]
96 - name = "async-channel"
97 - version = "2.5.0"
98 - source = "registry+https://github.com/rust-lang/crates.io-index"
99 - checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
100 - dependencies = [
101 - "concurrent-queue",
102 - "event-listener-strategy",
103 - "futures-core",
104 - "pin-project-lite",
105 - ]
106 -
107 - [[package]]
108 - name = "async-executor"
109 - version = "1.14.0"
110 - source = "registry+https://github.com/rust-lang/crates.io-index"
111 - checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
112 - dependencies = [
113 - "async-task",
114 - "concurrent-queue",
115 - "fastrand",
116 - "futures-lite",
117 - "pin-project-lite",
118 - "slab",
119 - ]
120 -
121 - [[package]]
122 - name = "async-io"
123 - version = "2.6.0"
124 - source = "registry+https://github.com/rust-lang/crates.io-index"
125 - checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
126 - dependencies = [
127 - "autocfg",
128 - "cfg-if",
129 - "concurrent-queue",
130 - "futures-io",
131 - "futures-lite",
132 - "parking",
133 - "polling",
134 - "rustix",
135 - "slab",
136 - "windows-sys 0.61.2",
137 - ]
138 -
139 - [[package]]
140 - name = "async-lock"
141 - version = "3.4.2"
142 - source = "registry+https://github.com/rust-lang/crates.io-index"
143 - checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
144 - dependencies = [
145 - "event-listener",
146 - "event-listener-strategy",
147 - "pin-project-lite",
148 - ]
149 -
150 - [[package]]
151 - name = "async-process"
152 - version = "2.5.0"
153 - source = "registry+https://github.com/rust-lang/crates.io-index"
154 - checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
155 - dependencies = [
156 - "async-channel",
157 - "async-io",
158 - "async-lock",
159 - "async-signal",
160 - "async-task",
161 - "blocking",
162 - "cfg-if",
163 - "event-listener",
164 - "futures-lite",
165 - "rustix",
166 - ]
167 -
168 - [[package]]
169 - name = "async-recursion"
170 - version = "1.1.1"
171 - source = "registry+https://github.com/rust-lang/crates.io-index"
172 - checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
173 - dependencies = [
174 - "proc-macro2",
175 - "quote",
176 - "syn 2.0.117",
177 - ]
178 -
179 - [[package]]
180 - name = "async-signal"
181 - version = "0.2.14"
182 - source = "registry+https://github.com/rust-lang/crates.io-index"
183 - checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
184 - dependencies = [
185 - "async-io",
186 - "async-lock",
187 - "atomic-waker",
188 - "cfg-if",
189 - "futures-core",
190 - "futures-io",
191 - "rustix",
192 - "signal-hook-registry",
193 - "slab",
194 - "windows-sys 0.61.2",
195 - ]
196 -
197 - [[package]]
198 - name = "async-task"
199 - version = "4.7.1"
200 - source = "registry+https://github.com/rust-lang/crates.io-index"
201 - checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
202 -
203 - [[package]]
204 - name = "async-trait"
205 - version = "0.1.91"
206 - source = "registry+https://github.com/rust-lang/crates.io-index"
207 - checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
208 - dependencies = [
209 - "proc-macro2",
210 - "quote",
211 - "syn 3.0.2",
212 - ]
213 -
214 - [[package]]
215 - name = "atomic-waker"
216 - version = "1.1.2"
217 - source = "registry+https://github.com/rust-lang/crates.io-index"
218 - checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
219 -
220 - [[package]]
221 - name = "autocfg"
222 - version = "1.5.0"
223 - source = "registry+https://github.com/rust-lang/crates.io-index"
224 - checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
225 -
226 - [[package]]
227 - name = "base64"
228 - version = "0.22.1"
229 - source = "registry+https://github.com/rust-lang/crates.io-index"
230 - checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
231 -
232 - [[package]]
233 - name = "base64ct"
234 - version = "1.8.3"
235 - source = "registry+https://github.com/rust-lang/crates.io-index"
236 - checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
237 -
238 - [[package]]
239 - name = "bitflags"
240 - version = "2.11.0"
241 - source = "registry+https://github.com/rust-lang/crates.io-index"
242 - checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
243 -
244 - [[package]]
245 - name = "blake2"
246 - version = "0.10.6"
247 - source = "registry+https://github.com/rust-lang/crates.io-index"
248 - checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
249 - dependencies = [
250 - "digest 0.10.7",
251 - ]
252 -
253 - [[package]]
254 - name = "block-buffer"
255 - version = "0.10.4"
256 - source = "registry+https://github.com/rust-lang/crates.io-index"
257 - checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
258 - dependencies = [
259 - "generic-array",
260 - ]
261 -
262 - [[package]]
263 - name = "block-buffer"
264 - version = "0.12.1"
265 - source = "registry+https://github.com/rust-lang/crates.io-index"
266 - checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
267 - dependencies = [
268 - "hybrid-array",
269 - ]
270 -
271 - [[package]]
272 - name = "block-padding"
273 - version = "0.3.3"
274 - source = "registry+https://github.com/rust-lang/crates.io-index"
275 - checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
276 - dependencies = [
277 - "generic-array",
278 - ]
279 -
280 - [[package]]
281 - name = "blocking"
282 - version = "1.6.2"
283 - source = "registry+https://github.com/rust-lang/crates.io-index"
284 - checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
285 - dependencies = [
286 - "async-channel",
287 - "async-task",
288 - "futures-io",
289 - "futures-lite",
290 - "piper",
291 - ]
292 -
293 - [[package]]
294 - name = "bumpalo"
295 - version = "3.20.2"
296 - source = "registry+https://github.com/rust-lang/crates.io-index"
297 - checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
298 -
299 - [[package]]
300 - name = "byteorder"
301 - version = "1.5.0"
302 - source = "registry+https://github.com/rust-lang/crates.io-index"
303 - checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
304 -
305 - [[package]]
306 - name = "bytes"
307 - version = "1.11.1"
308 - source = "registry+https://github.com/rust-lang/crates.io-index"
309 - checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
310 -
311 - [[package]]
312 - name = "cbc"
313 - version = "0.1.2"
314 - source = "registry+https://github.com/rust-lang/crates.io-index"
315 - checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
316 - dependencies = [
317 - "cipher 0.4.4",
318 - ]
319 -
320 - [[package]]
321 - name = "cc"
322 - version = "1.2.56"
323 - source = "registry+https://github.com/rust-lang/crates.io-index"
324 - checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
325 - dependencies = [
326 - "find-msvc-tools",
327 - "shlex",
328 - ]
329 -
330 - [[package]]
331 - name = "cfg-if"
332 - version = "1.0.4"
333 - source = "registry+https://github.com/rust-lang/crates.io-index"
334 - checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
335 -
336 - [[package]]
337 - name = "chacha20"
338 - version = "0.10.1"
339 - source = "registry+https://github.com/rust-lang/crates.io-index"
340 - checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
341 - dependencies = [
342 - "cfg-if",
343 - "cipher 0.5.2",
344 - "cpufeatures 0.3.0",
345 - "rand_core 0.10.1",
346 - ]
347 -
348 - [[package]]
349 - name = "chacha20poly1305"
350 - version = "0.11.0"
351 - source = "registry+https://github.com/rust-lang/crates.io-index"
352 - checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
353 - dependencies = [
354 - "aead",
355 - "chacha20",
356 - "cipher 0.5.2",
357 - "poly1305",
358 - ]
359 -
360 - [[package]]
361 - name = "chrono"
362 - version = "0.4.44"
363 - source = "registry+https://github.com/rust-lang/crates.io-index"
364 - checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
365 - dependencies = [
366 - "iana-time-zone",
367 - "js-sys",
368 - "num-traits",
369 - "serde",
370 - "wasm-bindgen",
371 - "windows-link",
372 - ]
373 -
374 - [[package]]
375 - name = "cipher"
376 - version = "0.4.4"
377 - source = "registry+https://github.com/rust-lang/crates.io-index"
378 - checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
379 - dependencies = [
380 - "crypto-common 0.1.7",
381 - "inout 0.1.4",
382 - ]
383 -
384 - [[package]]
385 - name = "cipher"
386 - version = "0.5.2"
387 - source = "registry+https://github.com/rust-lang/crates.io-index"
388 - checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
389 - dependencies = [
390 - "block-buffer 0.12.1",
391 - "crypto-common 0.2.2",
392 - "inout 0.2.2",
393 - ]
394 -
395 - [[package]]
396 - name = "cmov"
397 - version = "0.5.4"
398 - source = "registry+https://github.com/rust-lang/crates.io-index"
399 - checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
400 -
401 - [[package]]
402 - name = "combine"
403 - version = "4.6.7"
404 - source = "registry+https://github.com/rust-lang/crates.io-index"
405 - checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
406 - dependencies = [
407 - "bytes",
408 - "memchr",
409 - ]
410 -
411 - [[package]]
412 - name = "concurrent-queue"
413 - version = "2.5.0"
414 - source = "registry+https://github.com/rust-lang/crates.io-index"
415 - checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
416 - dependencies = [
417 - "crossbeam-utils",
418 - ]
419 -
420 - [[package]]
421 - name = "const-oid"
422 - version = "0.10.2"
423 - source = "registry+https://github.com/rust-lang/crates.io-index"
424 - checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
425 -
426 - [[package]]
427 - name = "core-foundation"
428 - version = "0.9.4"
429 - source = "registry+https://github.com/rust-lang/crates.io-index"
430 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
431 - dependencies = [
432 - "core-foundation-sys",
433 - "libc",
434 - ]
435 -
436 - [[package]]
437 - name = "core-foundation"
438 - version = "0.10.1"
439 - source = "registry+https://github.com/rust-lang/crates.io-index"
440 - checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
441 - dependencies = [
442 - "core-foundation-sys",
443 - "libc",
444 - ]
445 -
446 - [[package]]
447 - name = "core-foundation-sys"
448 - version = "0.8.7"
449 - source = "registry+https://github.com/rust-lang/crates.io-index"
450 - checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
451 -
452 - [[package]]
453 - name = "cpufeatures"
454 - version = "0.2.17"
455 - source = "registry+https://github.com/rust-lang/crates.io-index"
456 - checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
457 - dependencies = [
458 - "libc",
459 - ]
460 -
461 - [[package]]
462 - name = "cpufeatures"
463 - version = "0.3.0"
464 - source = "registry+https://github.com/rust-lang/crates.io-index"
465 - checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
466 - dependencies = [
467 - "libc",
468 - ]
469 -
470 - [[package]]
471 - name = "crossbeam-utils"
472 - version = "0.8.22"
473 - source = "registry+https://github.com/rust-lang/crates.io-index"
474 - checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
475 -
476 - [[package]]
477 - name = "crypto-common"
478 - version = "0.1.7"
479 - source = "registry+https://github.com/rust-lang/crates.io-index"
480 - checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
481 - dependencies = [
482 - "generic-array",
483 - "typenum",
484 - ]
485 -
486 - [[package]]
487 - name = "crypto-common"
488 - version = "0.2.2"
489 - source = "registry+https://github.com/rust-lang/crates.io-index"
490 - checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
491 - dependencies = [
492 - "getrandom 0.4.1",
493 - "hybrid-array",
494 - "rand_core 0.10.1",
495 - ]
496 -
497 - [[package]]
498 - name = "ctutils"
499 - version = "0.4.2"
500 - source = "registry+https://github.com/rust-lang/crates.io-index"
Lines truncated
@@ -1,123 +0,0 @@
1 - [package]
2 - name = "synckit-client"
3 - version = "0.6.0"
4 - edition = "2024"
5 - description = "SyncKit client SDK with end-to-end encryption"
6 - license-file = "LICENSE"
7 -
8 - [features]
9 - default = ["keychain", "store"]
10 - keychain = ["dep:keyring"]
11 - # The higher-level SyncStore engine (owns a rusqlite connection to the app's DB).
12 - # Default-on so app consumers get it for free; a consumer that only needs the
13 - # transport/crypto SDK (e.g. mnw-cli, which sets `default-features = false`) skips
14 - # it and avoids compiling bundled SQLite.
15 - store = ["dep:rusqlite", "dep:synckit-config"]
16 - # Exposes test-only constructors (`set_master_key_raw`, `with_http_client`) that
17 - # bypass key derivation. Enabled automatically for this crate's own test builds
18 - # via the self dev-dependency below; never part of `default`, so a real consumer
19 - # cannot reach a raw-key injection point.
20 - testing = []
21 -
22 - [dependencies]
23 - # Encryption
24 - chacha20poly1305 = "0.11"
25 - argon2 = "0.5"
26 - rand = "0.10"
27 - base64 = "0.22"
28 - zeroize = "1"
29 - # X25519 ECDH — the only asymmetric primitive in the crate, used solely by the
30 - # group-key layer (`identity.rs`) to seal a Group Content Key to a member's public
31 - # key. Pure-Rust (curve25519-dalek), no aws-lc/ring. Deliberately just the curve op:
32 - # the GCK itself is sealed with the XChaCha20-Poly1305 above, not a sealed-box crate.
33 - # `static_secrets` enables the reusable `StaticSecret` (a member's long-lived key).
34 - x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] }
35 -
36 - # PKCE (OAuth2) challenge hashing
37 - sha2 = "0.11"
38 - hex = "0.4"
39 -
40 - # HTTP
41 - # rustls-no-provider: rustls TLS with the OS-native trust store (rustls-platform-verifier),
42 - # no bundled crypto provider — the consuming app installs one process default (ring).
43 - reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "stream", "form", "charset", "http2", "system-proxy"] }
44 - bytes = "1"
45 - tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "io-util"] }
46 - tokio-stream = "0.1"
47 -
48 - # Serialization
49 - serde = { version = "1", features = ["derive"] }
50 - serde_json = "1"
51 - chrono = { version = "0.4", features = ["serde"] }
52 - uuid = { version = "1", features = ["v4", "serde"] }
53 -
54 - # OS keychain (optional)
55 - keyring = { version = "4", optional = true }
56 -
57 - # URL encoding
58 - urlencoding = "2"
59 -
60 - # Unicode
61 - unicode-normalization = "0.1"
62 -
63 - # Synchronization
64 - parking_lot = "0.12"
65 -
66 - # Error handling & logging
67 - thiserror = "2"
68 - tracing = "0.1"
69 -
70 - # SyncStore engine (feature = "store"). Owns the app's SQLite connection. Bundled
71 - # so no system libsqlite is needed; `functions` to register the `hash_row_id` UDF.
72 - rusqlite = { version = "0.39", features = ["bundled", "functions"], optional = true }
73 - # The local config store (posture types + KV store). The config sync adapter
74 - # (src/store/config.rs) turns a ConfigSpec into a SyncTable. Path dep: sibling in
75 - # the MNW tree, versioned together.
76 - synckit-config = { path = "../synckit-config", optional = true }
77 -
78 - [dev-dependencies]
79 - wiremock = "0.6"
80 - # reqwest is built `rustls-no-provider`, so real consumers install a rustls crypto
81 - # provider at startup (audiofiles installs ring). The test suite has no such app, so
82 - # it installs ring itself before building any client. Dev-only: never in a consumer build.
83 - rustls = { version = "0.23", default-features = false, features = ["ring"] }
84 - # Self dev-dependency: turns on the `testing` feature for this crate's own
85 - # unit/integration test builds without leaking it into a consumer's default set.
86 - # `default-features = false` so it only ADDS `testing` to whatever the test run
87 - # already selected — under a plain `cargo test` the package still builds with its
88 - # own defaults (keychain + store), but `cargo test --no-default-features` can turn
89 - # keychain OFF (making `keystore::store_key` the no-op stub) to run the keychain-
90 - # free rotation orchestration test below.
91 - synckit-client = { path = ".", default-features = false, features = ["testing"] }
92 -
93 - [lints.rust]
94 - unused = "warn"
95 - unreachable_pub = "warn"
96 -
97 - [lints.clippy]
98 - pedantic = { level = "warn", priority = -1 }
99 - # Allow-list tuned from a measured breakdown across server/multithreaded/pter
100 - # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
101 - # else in `pedantic` stays a warning. Keep this block identical across repos.
102 - module_name_repetitions = "allow"
103 - # Doc lints. No docs-completeness push is underway.
104 - missing_errors_doc = "allow"
105 - missing_panics_doc = "allow"
106 - doc_markdown = "allow"
107 - # Numeric casts. Endemic and mostly intentional in size and byte math.
108 - cast_possible_truncation = "allow"
109 - cast_sign_loss = "allow"
110 - cast_precision_loss = "allow"
111 - cast_possible_wrap = "allow"
112 - cast_lossless = "allow"
113 - # Subjective structure and style nags. High churn, low signal.
114 - must_use_candidate = "allow"
115 - too_many_lines = "allow"
116 - struct_excessive_bools = "allow"
117 - similar_names = "allow"
118 - items_after_statements = "allow"
119 - single_match_else = "allow"
120 - # Frequent false-positives in TUI and router-heavy code.
121 - match_same_arms = "allow"
122 - unnecessary_wraps = "allow"
123 - type_complexity = "allow"
@@ -1,120 +0,0 @@
1 - Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work)
2 -
3 - PolyForm Noncommercial License 1.0.0
4 -
5 - <https://polyformproject.org/licenses/noncommercial/1.0.0>
6 -
7 - Acceptance
8 -
9 - In order to get any license under these terms, you must agree to them as
10 - both strict obligations and conditions to all your licenses.
11 -
12 - Copyright License
13 -
14 - The licensor grants you a copyright license for the software to do
15 - everything you might do with the software that would otherwise infringe
16 - the licensor's copyright in it for any permitted purpose. However, you
17 - may only distribute the software according to Distribution License and
18 - make changes or new works based on the software according to Changes and
19 - New Works License.
20 -
21 - Distribution License
22 -
23 - The licensor grants you an additional copyright license to distribute
24 - copies of the software. Your license to distribute covers distributing
25 - the software with changes and new works permitted by Changes and New
26 - Works License.
27 -
28 - Notices
29 -
30 - You must ensure that anyone who gets a copy of any part of the software
31 - from you also gets a copy of these terms or the URL for them above, as
32 - well as copies of any plain-text lines beginning with "Required Notice:"
33 - that the licensor provided with the software. For example:
34 -
35 - Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
36 -
37 - Changes and New Works License
38 -
39 - The licensor grants you an additional copyright license to make changes
40 - and new works based on the software for any permitted purpose.
41 -
42 - Patent License
43 -
44 - The licensor grants you a patent license for the software that covers
45 - patent claims the licensor can license, or becomes able to license, that
46 - you would infringe by using the software.
47 -
48 - Noncommercial Purposes
49 -
50 - Any noncommercial purpose is a permitted purpose.
51 -
52 - Personal Uses
53 -
54 - Personal use for research, experiment, and testing for the benefit of
55 - public knowledge, personal study, private entertainment, hobby projects,
56 - amateur pursuits, or religious observance, without any anticipated
57 - commercial application, is use for a permitted purpose.
58 -
59 - Noncommercial Organizations
60 -
61 - Use by any charitable organization, educational institution, public
62 - research organization, public safety or health organization,
63 - environmental protection organization, or government institution is use
64 - for a permitted purpose regardless of the source of funding or
65 - obligations resulting from the funding.
66 -
67 - Fair Use
68 -
69 - You may have "fair use" rights for the software under the law. These
70 - terms do not limit them.
71 -
72 - No Other Rights
73 -
74 - These terms do not allow you to sublicense or transfer any of your
75 - licenses to anyone else, or prevent the licensor from granting licenses
76 - to anyone else. These terms do not imply any other licenses.
77 -
78 - Patent Defense
79 -
80 - If you make any written claim that the software infringes or contributes
81 - to infringement of any patent, your patent license for the software
82 - granted under these terms ends immediately. If your company makes such a
83 - claim, your patent license ends immediately for work on behalf of your
84 - company.
85 -
86 - Violations
87 -
88 - The first time you are notified in writing that you have violated any of
89 - these terms, or done anything with the software not covered by your
90 - licenses, your licenses can nonetheless continue if you come into full
91 - compliance with these terms, and take practical steps to correct past
92 - violations, within 32 days of receiving notice. Otherwise, all your
93 - licenses end immediately.
94 -
95 - No Liability
96 -
97 - As far as the law allows, the software comes as is, without any warranty
98 - or condition, and the licensor will not be liable to you for any damages
99 - arising out of these terms or the use or nature of the software, under
100 - any kind of legal claim.
101 -
102 - Definitions
103 -
104 - The licensor is the individual or entity offering these terms, and the
105 - software is the software the licensor makes available under these terms.
106 -
107 - You refers to the individual or entity agreeing to these terms.
108 -
109 - Your company is any legal entity, sole proprietorship, or other kind of
110 - organization that you work for, plus all organizations that have control
111 - over, are under the control of, or are under common control with that
112 - organization. Control means ownership of substantially all the assets of
113 - an entity, or the power to direct its management and policies by vote,
114 - contract, or otherwise. Control can be direct or indirect.
115 -
116 - Your licenses are all the licenses granted to you for the software under
117 - these terms.
118 -
119 - Use means anything you do with the software requiring one of your
120 - licenses.
@@ -1,79 +0,0 @@
1 - # synckit-client
2 -
3 - End-to-end encrypted cloud sync SDK for Rust applications, built for the [MNW SyncKit](https://makenot.work) server.
4 -
5 - All row data and binary blobs are encrypted client-side (XChaCha20-Poly1305) before leaving the device. The server only ever stores ciphertext.
6 -
7 - ## Features
8 -
9 - - **E2E encryption** -- XChaCha20-Poly1305 with Argon2id key derivation (64 MB, 3 iterations)
10 - - **OS keychain integration** -- master key cached in macOS Keychain, Linux secret-service, or Windows Credential Manager
11 - - **Blob encryption** -- binary files encrypted with fixed 40-byte overhead (no base64 expansion)
12 - - **Retry with backoff** -- transient failures (network, 5xx, 429) retried up to 3 times with exponential delay
13 - - **OAuth2 PKCE** -- browser-based auth flow alongside email/password
14 - - **Token expiry detection** -- client-side JWT check with 30-second buffer
15 -
16 - ## Quick Start
17 -
18 - ```rust
19 - use synckit_client::{SyncKitClient, SyncKitConfig, ChangeEntry, ChangeOp};
20 - use chrono::Utc;
21 -
22 - let client = SyncKitClient::new(SyncKitConfig {
23 - server_url: "https://makenot.work".into(),
24 - api_key: "your-api-key".into(),
25 - });
26 -
27 - // Authenticate. The third arg is the developer-defined SDK key for billing
28 - // attribution — typically one key per workspace/org/end-user.
29 - let (user_id, app_id) = client.authenticate("user@example.com", "password", "workspace-42").await?;
30 -
31 - // Set up encryption (first device)
32 - client.setup_encryption_new("password").await?;
33 -
34 - // Register this device
35 - let device = client.register_device("MacBook Pro", "macos").await?;
36 -
37 - // Push encrypted data
38 - let cursor = client.push(device.id, vec![
39 - ChangeEntry {
40 - table: "tasks".into(),
41 - op: ChangeOp::Insert,
42 - row_id: uuid::Uuid::new_v4().to_string(),
43 - timestamp: Utc::now(),
44 - data: Some(serde_json::json!({"title": "Buy milk"})),
45 - },
46 - ]).await?;
47 -
48 - // Pull and auto-decrypt
49 - let (changes, cursor, has_more) = client.pull(device.id, 0).await?;
50 - ```
51 -
52 - ## Crate Structure
53 -
54 - | File | Role |
55 - |------|------|
56 - | `lib.rs` | Crate root, re-exports, doc example |
57 - | `client.rs` | `SyncKitClient` -- HTTP methods, retry logic, token expiry detection |
58 - | `crypto.rs` | Key derivation (Argon2id), key wrapping, per-entry and per-blob encrypt/decrypt |
59 - | `error.rs` | `SyncKitError` enum (10 variants: HTTP, server, JSON, crypto, keychain, auth) |
60 - | `keystore.rs` | OS keychain read/write/delete, feature-gated with no-op stubs |
61 - | `types.rs` | Wire protocol types (`ChangeEntry`, `ChangeOp`, `Device`, `SyncStatus`) |
62 -
63 - ## Feature Flags
64 -
65 - | Flag | Default | Description |
66 - |------|---------|-------------|
67 - | `keychain` | on | OS keychain storage via the `keyring` crate. Disable with `default-features = false` for headless/CI environments. |
68 -
69 - ## Security Properties
70 -
71 - - **Server-zero-knowledge** -- the server never receives the plaintext master key or user data
72 - - **Key zeroization** -- volatile writes clear the master key from memory on drop
73 - - **Random salt per wrap** -- re-wrapping with the same password produces a different envelope
74 - - **Minimum ciphertext validation** -- decryption rejects inputs shorter than 40 bytes (24-byte nonce + 16-byte tag)
75 - - **No key material in logs** -- tracing events never include key bytes or ciphertext
76 -
77 - ## License
78 -
79 - PolyForm Noncommercial 1.0.0
@@ -1,13 +0,0 @@
1 - # A raw `Response::json`/`text`/`bytes` reads the whole body with no size limit,
2 - # so a hostile or buggy server can stream an arbitrarily large body and OOM the
3 - # client (worst case: the public, unauthenticated OTA updater check). Every body
4 - # read must go through `client::helpers::read_body_capped` (or its `read_json_capped`
5 - # / `read_text_capped` wrappers), which drains `bytes_stream()` under a hard cap.
6 - # `blob_download` streams `bytes_stream()` directly under its own 4 GiB cap.
7 - # This seal makes a new uncapped read a compile-time (clippy) error, not a latent
8 - # OOM lever someone has to remember to avoid.
9 - disallowed-methods = [
10 - { path = "reqwest::Response::json", reason = "unbounded body read; use client::helpers::read_json_capped" },
11 - { path = "reqwest::Response::text", reason = "unbounded body read; use client::helpers::read_text_capped" },
12 - { path = "reqwest::Response::bytes", reason = "unbounded body read; use client::helpers::read_body_capped (or bytes_stream() under an explicit cap)" },
13 - ]
@@ -1,944 +0,0 @@
1 - # SyncKit Client SDK, Architecture
2 -
3 - ## Overview
4 -
5 - The SyncKit Client SDK (`synckit-client`) is a Rust crate that provides
6 - end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps
7 - (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull
8 - changelog entries without the server ever seeing plaintext data.
9 -
10 - Version: 0.5.0.
11 -
12 - ## Crate structure
13 -
14 - ```
15 - src/
16 - lib.rs Crate root. Re-exports public types, quick-start doc example.
17 - client/ SyncKitClient struct and all HTTP methods, split across
18 - submodules (auth, sync, blob, encryption, rotation,
19 - subscribe, subscription, helpers). Retry logic and token
20 - expiry detection live here.
21 - crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping
22 - (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob
23 - encrypt/decrypt, ZeroizeOnDrop guard.
24 - error.rs SyncKitError enum (thiserror, #[non_exhaustive]): Http, Server,
25 - Json, NoMasterKey, DecryptionFailed, IntegrityFailed,
26 - InvalidEnvelope, Crypto, Base64, NotAuthenticated, TokenExpired,
27 - InvalidArgument, Internal, and Keychain (feature-gated behind
28 - `keychain`).
29 - keystore.rs OS keychain integration (macOS Keychain, Linux secret-service,
30 - Windows Credential Manager) via the `keyring` crate.
31 - Feature-gated behind `keychain` (default on). No-op stubs
32 - when disabled.
33 - types.rs Request/response types matching the server wire protocol.
34 - Public types: ChangeEntry, ChangeOp, Device, SyncStatus,
35 - BlobUploadUrlResponse. Internal types: auth, push/pull, key,
36 - OAuth, blob requests.
37 -
38 - Public re-exports from lib.rs:
39 - SyncKitClient, SyncKitConfig, SessionInfo,
40 - ChangeEntry, ChangeOp, Device, SyncStatus,
41 - SyncKitError, Result
42 - ```
43 -
44 - ## Key lifecycle
45 -
46 - ```
47 - password
48 - |
49 - v
50 - Argon2id (64 MB, 3 iterations, parallelism 1)
51 - + random 32-byte salt (stored in envelope)
52 - |
53 - v
54 - wrapping_key (256-bit)
55 - |
56 - v
57 - XChaCha20-Poly1305 encrypt/decrypt
58 - |
59 - v
60 - master_key (256-bit, randomly generated on first device)
61 - |
62 - v
63 - XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt
64 - ```
65 -
66 - - **First device**: generates a random master key, wraps it with the password,
67 - pushes the encrypted envelope to the server, caches plaintext in OS keychain.
68 - - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps
69 - with the password, caches in OS keychain.
70 - - **Subsequent launches**: loads the master key directly from the OS keychain
71 - (no password prompt, no server call).
72 - - **Password change**: decrypts master key with old password, re-wraps with new
73 - password (fresh random salt), pushes new envelope to server. The master key
74 - itself does not change, so existing ciphertext remains valid.
75 -
76 - ## Authentication flow
77 -
78 - ### Email/password
79 -
80 - 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key.
81 - 2. Server validates credentials and returns a JWT, user ID, and app ID.
82 - 3. Client stores the session in an internal `Mutex<Option<Session>>`.
83 -
84 - ### OAuth2 PKCE
85 -
86 - 1. Caller generates a PKCE code verifier and challenge.
87 - 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge.
88 - 3. Caller opens the URL in a browser and starts a localhost callback server.
89 - 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges
90 - the authorization code for a JWT via `/oauth/token`.
91 - 5. Session is stored identically to email/password auth.
92 -
93 - ### Session restoration
94 -
95 - `restore_session(token, user_id, app_id)` populates the session from stored
96 - credentials (e.g., OS keychain) without making any HTTP calls. The caller is
97 - responsible for checking `is_token_expired()` and re-authenticating if needed.
98 -
99 - ### Session utilities
100 -
101 - - `session_info()` returns `Option<SessionInfo>` (token, user_id, app_id)
102 - without making HTTP calls. Returns `None` if not authenticated.
103 - - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer.
104 - Returns `true` if no session exists or the token is about to expire.
105 - - `clear_session()` clears in-memory session and master key. Does not
106 - affect OS keychain (call `keystore::delete_key` separately).
107 - - `config()` returns a reference to the `SyncKitConfig`.
108 -
109 - ## Encryption setup flow
110 -
111 - ```
112 - has_server_key()?
113 - |
114 - +-- false --> setup_encryption_new(password)
115 - | Generate master key, wrap, push envelope, cache in keychain
116 - |
117 - +-- true --> setup_encryption_existing(password)
118 - Pull envelope, unwrap with password, cache in keychain
119 - ```
120 -
121 - On subsequent launches, `try_load_key_from_keychain()` loads the master key
122 - from the OS keychain without any server interaction or password prompt.
123 -
124 - ## Push/Pull protocol
125 -
126 - ### The HLC envelope
127 -
128 - Every change, **including Deletes**, is encrypted as a JSON *envelope*, not as
129 - the bare row data:
130 -
131 - ```json
132 - { "__skver": 2, "__skhlc": { "wall_ms": …, "counter": …, "node": "…" }, "data": <row|null> }
133 - ```
134 -
135 - Encrypting the envelope (rather than just the row) is what carries the hybrid
136 - logical clock (HLC) inside the E2E ciphertext, so the server can order entries by
137 - `seq` but never sees or orders by the clock. A Delete has `data: null` but still
138 - seals an envelope, so its HLC travels too. On decrypt, a payload whose embedded
139 - `__skhlc` parses as an `Hlc` is treated as an envelope; anything else is a legacy
140 - bare row whose HLC is synthesized from `node` + the entry's `client_timestamp`.
141 -
142 - ### Wire version tag and AAD binding
143 -
144 - The encrypted `data` string is **position-bound**. v2 ciphertext is prefixed
145 - `sk2:` and sealed with AEAD associated data `aad_for_entry(table, row_id)` =
146 - `table` `0x1f` `row_id`. Because the tag is read before decryption, the reader
147 - always knows which AAD to apply:
148 -
149 - - `sk2:`-tagged → open with the entry's `(table, row_id)` AAD.
150 - - untagged (legacy, pre-v2) → open with empty AAD.
151 -
152 - This means a malicious or compromised server cannot relocate a valid ciphertext
153 - to a different row or table: the AAD no longer matches and the open fails closed.
154 - Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts
155 - legacy entries into the v2 form opportunistically.
156 -
157 - The AAD is never passed as raw bytes. Callers name a context, `AeadContext::Entry
158 - { table, row_id }` or `AeadContext::Blob { hash }`, and the crypto layer derives
159 - the AAD from it. The byte-level builder is private, so an empty or mismatched AAD
160 - is not expressible at the call site.
161 -
162 - ### Envelope version dispatch (`__skver`)
163 -
164 - The decrypted entry payload is an envelope `{ __skver: 2, __skhlc, data }`.
165 - Splitting it back into `(hlc, data)` dispatches on the explicit `__skver` tag via
166 - a typed `WireVersion`: an unknown future version is a hard error, not a silent
167 - fall-through to a bare-row read. A tag-less payload carrying an embedded `__skhlc`
168 - is a gen-1 envelope; anything else is a legacy bare row.
169 -
170 - ### Chunked blob format (`sk3:`)
171 -
172 - Blobs use a chunked AEAD format so a reader decrypts and verifies one chunk at a
173 - time. Layout: `sk3:` `version(u8=3)` `chunk_size(u32 LE)` `total_len(u64 LE)`
174 - followed by sealed 1 MiB chunks. Each chunk's AAD binds `(content_hash,
175 - chunk_index, chunk_count)`, so reorder, truncation, duplication, and cross-blob
176 - substitution all fail closed. `blob_download` streams the body and decrypts
177 - chunk-by-chunk (peak memory is the plaintext plus one in-flight chunk, not
178 - ciphertext *and* plaintext together); legacy `sk2:`/untagged blobs still decrypt
179 - whole-buffer with no flag-day.
180 -
181 - ### Push
182 -
183 - 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields and an HLC.
184 - 2. Client wraps each `(hlc, data)` in an envelope, encrypts it with the master
185 - key (XChaCha20-Poly1305, random nonce, `(table, row_id)` AAD), and emits the
186 - `sk2:`-tagged base64 string.
187 - 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID and a
188 - client-generated `batch_id` (idempotent push: a replayed batch returns the
189 - existing cursor).
190 - 4. Server appends entries to the changelog and returns the new cursor (i64).
191 - 5. Retries on transient failures (see below).
192 -
193 - ### Pull
194 -
195 - 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor.
196 - 2. Server returns changelog entries since that cursor, a new cursor, and a
197 - `has_more` flag for pagination.
198 - 3. Client decrypts each entry's envelope (auto-detecting the wire version) and
199 - returns plaintext `ChangeEntry` values, HLC included, to the caller.
200 - 4. Retries on transient failures.
201 -
202 - **Caller contract.** The SDK does not persist the cursor or dedup re-delivered
203 - changes. Persist the returned cursor only *after* applying the returned changes
204 - (same transaction where possible), and make apply idempotent per
205 - `(table, row_id, hlc)`, a reconnect can re-deliver a batch.
206 -
207 - ### Conflict resolution
208 -
209 - Conflicts are resolved client-side (the server cannot read plaintext).
210 - `resolve_lww` orders by HLC, operation-agnostically: the higher `(wall_ms,
211 - counter, node)` wins, so a newer edit beats an older delete and vice-versa, and
212 - every device converges on the same winner. An *exactly* equal HLC normally means
213 - a same-device echo; if two installs share a `node` UUID (cloned config), the tie
214 - is broken deterministically on the canonical payload bytes so both devices still
215 - converge.
216 -
217 - `detect_conflicts` only flags rows with an *un-pushed* local edit. Its clean set
218 - is returned as an opaque `CleanChanges`, not a bare `Vec`: the only way to read
219 - the applicable entries out is `gated(committed_hlc)`, which drops any clean change
220 - older-or-equal to the row's committed HLC. (`row_keys()` lets the caller pre-fetch
221 - those clocks first.) This makes "apply a clean change without checking the
222 - committed clock", which would let an older remote edit clobber a newer local
223 - value, unrepresentable rather than a contract the caller has to remember.
224 -
225 - ### Sequence numbers
226 -
227 - The server assigns a monotonically increasing sequence number (`seq`) to each
228 - changelog entry. The cursor is the `seq` of the last entry the client has seen.
229 - Pulling with cursor 0 fetches from the beginning.
230 -
231 - ## Blob encryption
232 -
233 - Binary blobs (files, images, audio samples) are encrypted client-side before
234 - upload:
235 -
236 - 1. `blob_upload_url(hash, plaintext_size)` requests a presigned S3 PUT URL from
237 - the server. If the blob already exists (content-addressed by hash), no upload
238 - is needed. The declared size is converted to the ciphertext length before it
239 - is sent, because the server signs it as `Content-Length` and the PUT carries
240 - sealed bytes.
241 - 2. `blob_upload(hash, presigned_url, data)` encrypts the plaintext bytes with the
242 - master key, **binding the content hash as AEAD associated data**, and PUTs the
243 - ciphertext to S3.
244 - 3. `blob_confirm(hash, size)` tells the server the upload completed.
245 -
246 - Steps 1 and 2 are the in-memory path, for bytes with no file behind them. Every
247 - consumer app instead calls `blob_upload_streaming(hash, path)` (below), which
248 - covers the same ground with bounded memory and no size ceiling, then step 3.
249 - 4. `blob_download_url(hash)` requests a presigned GET URL.
250 - 5. `blob_download(expected_hash, presigned_url)` fetches the ciphertext, decrypts
251 - with the hash as AAD, then **re-verifies** that the plaintext hashes to
252 - `expected_hash` (returning `IntegrityFailed` otherwise). The AEAD tag proves
253 - the bytes were not forged; the re-hash proves the server served the bytes for
254 - *this* address, closing the substitution/rollback gap.
255 -
256 - Blob encryption uses `encrypt_bytes_aad`/`decrypt_bytes_aad` (raw bytes, no
257 - base64) to avoid the ~33% base64 overhead on large files. v2 overhead is 44 bytes
258 - per blob: a 4-byte `sk2:` wire tag + 24-byte XChaCha20 nonce + 16-byte Poly1305
259 - tag.
260 -
261 - ### Large blobs: the multipart path
262 -
263 - Steps 1 and 2 buffer the whole ciphertext, and the server refuses a one-shot PUT
264 - above its own ceiling. For a blob already on disk, `blob_upload_streaming(hash,
265 - path)` replaces both with a multipart session and holds one part at a time:
266 -
267 - 1. It computes the exact ciphertext length from the file size alone
268 - (`blob_encrypted_len`) and declares it to `blobs/multipart/start`, which
269 - returns the part geometry both sides then derive identically.
270 - 2. It seals 1 MiB v3 chunks straight out of the file, staging them until a full
271 - part is ready, and PUTs each part to a presigned URL signed for that exact
272 - `Content-Length`. Each presigned part URL is requested on its own, one per
273 - part, since a part's checksum only exists once it has been sealed and only one
274 - part is ever held in memory.
275 - 3. It recomputes the SHA-256 as it reads, so a file that changed since the
276 - caller hashed it fails rather than landing under a stale content address.
277 - 4. Any failure past `start` aborts the session, since uploaded parts bill until
278 - released. `blob_confirm` is still the caller's next step, unchanged.
279 -
280 - It works at any size (a small blob is a single part), so prefer it whenever the
281 - blob is file-backed. Not yet resumable across a process restart: the session id
282 - is not persisted.
283 -
284 - ## Keychain integration
285 -
286 - The `keychain` feature (default on) uses the `keyring` crate (v4) to store
287 - the master key in the OS credential store:
288 -
289 - - **macOS**: Keychain Services
290 - - **Linux**: secret-service (D-Bus Secret Service API)
291 - - **Windows**: Windows Credential Manager
292 -
293 - Keychain entries are keyed by `synckit:<app_id>` (service) and `<user_id>`
294 - (account). When the feature is disabled, store/load/delete are no-ops that
295 - return `Ok`.
296 -
297 - ## Retry and resilience
298 -
299 - Push and pull use `retry_request()` with exponential backoff:
300 -
301 - - **Max retries**: 3
302 - - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt)
303 - - **Transient errors** (retried): network failures (timeout, DNS, connection
304 - refused), server errors (5xx), rate limiting (429)
305 - - **Permanent errors** (not retried): client errors (4xx except 429),
306 - serialization errors, encryption errors, missing session/key
307 -
308 - Token expiry is detected client-side by decoding the JWT `exp` claim with a
309 - 30-second buffer. If the token is about to expire, the client returns
310 - `SyncKitError::TokenExpired` so the caller can re-authenticate before sending
311 - a request that would fail with 401.
312 -
313 - ## Key design decisions
314 -
315 - ### Why XChaCha20-Poly1305
316 -
317 - - 192-bit nonces are large enough to generate randomly without realistic
318 - collision risk (unlike AES-GCM's 96-bit nonces).
319 - - Removes the need for a nonce counter or nonce-misuse resistance scheme.
320 - - Performance is comparable to AES-GCM on modern hardware.
321 -
322 - ### Why Argon2id
323 -
324 - - OWASP-recommended for password hashing. Resists both side-channel (Argon2i)
325 - and GPU brute-force (Argon2d) attacks.
326 - - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum.
327 -
328 - ### Why random salt per wrap
329 -
330 - Each `wrap_master_key` call generates a fresh 32-byte random salt. This means
331 - re-wrapping with the same password produces a completely different envelope,
332 - preventing precomputation attacks and ensuring password changes are
333 - cryptographically distinct.
334 -
335 - ### Why no token refresh
336 -
337 - The server currently issues short-lived JWTs without refresh tokens. The client
338 - detects expiry and returns `TokenExpired`, leaving re-authentication to the
339 - caller. This keeps the SDK stateless with respect to refresh logic and avoids
340 - storing long-lived credentials in memory.
341 -
342 - ## Security properties
343 -
344 - - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync
345 - entries, blobs). It never receives the plaintext master key or user data.
346 - - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to
347 - clear the master key from memory when the guard is dropped.
348 - - **No key material in logs**: Tracing statements log events (key stored, key
349 - loaded) but never log key bytes or ciphertext.
350 - - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes
351 - (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks.
352 - - **Ciphertext position binding (AAD)**: v2 entry ciphertext is sealed with
353 - `(table, row_id)` as AEAD associated data, and blob ciphertext with the content
354 - hash; downloaded blobs are additionally re-hashed and rejected if they do not
355 - match their requested address. So the untrusted server cannot relocate or
356 - substitute a valid ciphertext without the open (or the hash check) failing
357 - closed. See "Wire version tag and AAD binding".
358 - - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
359 - and travels with its Argon2 cost parameters, so the work factor can be raised
360 - later without breaking existing envelopes.
361 -
362 - ---
363 -
364 - # The `SyncStore` higher-level helper
365 -
366 - > Status: shipped in 0.6 behind the default-on `store` feature (`src/store/`).
367 - > Everything above describes the base SDK (transport + crypto + HLC + conflict
368 - > primitives); this section describes the layer *above* those primitives that
369 - > absorbs the SQLite plumbing the three first-party apps otherwise hand-write.
370 - > The design was reverse-engineered from the GoingsOn, audiofiles, and Balanced
371 - > Breakfast sync services; a consumer that only needs the transport/crypto SDK
372 - > (e.g. mnw-cli) sets `default-features = false` and skips it.
373 -
374 - ## Motivation
375 -
376 - Without this layer the SDK stops at "encrypt/decrypt a `Vec<ChangeEntry>` and
377 - move it over the wire," and each consuming app writes ~700–1,000 lines of
378 - *identical* engine on top of it: a `sync_changelog` + `sync_state` schema, per-table triggers, a
379 - drain-loop push, a cursor-loop pull, an FK-ordered apply with JSON→SQL binding,
380 - HLC stamping, an initial snapshot, retention/cleanup, and a scheduler
381 - (tick + SSE + backoff + status events). Measured across the three apps this is
382 - the overwhelming majority of their sync code, and it is mechanical.
383 -
384 - The single largest boilerplate source is **column-list triplication**: every
385 - syncable table names its synced columns three or four times, in the trigger DDL
386 - (`json_object(...)`), in the initial-snapshot projection, and in the apply-side
387 - whitelist, kept in agreement only by hand and a round-trip drift test. When they
388 - drift, a column silently arrives NULL (this is a real shipped bug class). One
389 - declared column list should generate all of them.
390 -
391 - The goal of `SyncStore` is: an app **declares its tables and policy once**, and
392 - the engine owns everything mechanical. This is the "simple" pillar of the product
393 - (see the private roadmap), the differentiators (private, secure, economical) are
394 - already in the 0.6 primitives; this layer is what makes them easy to adopt.
395 -
396 - ## What is generic (absorbed) vs. what is policy (declared)
397 -
398 - The three apps agree, byte-for-byte in spirit, on all of this, it moves into the
399 - engine:
400 -
401 - - `sync_state` KV + the transactional `applying_remote` echo-suppression flag.
402 - - `sync_changelog` schema, the `pushed` flag, cursor bookkeeping.
403 - - Push: HLC-stamp unpushed rows, batch ≤500, map to `ChangeEntry`, `client.push`,
404 - mark pushed, skip-and-retain corrupt/unknown-op rows, drain until empty.
405 - - Pull: paginated `client.pull_rich` loop, apply-then-persist-cursor (the SDK's
406 - documented crash-safe contract), auto-decrypt.
407 - - Apply: partition into upserts/deletes, iterate the declared table order
408 - (parents-first upserts, children-first deletes), JSON→SQL type coercion,
409 - per-row constraint-violation *skip-and-log* (a poison row never wedges the
410 - cursor), HLC gating against committed clocks, `resolve_lww` for true conflicts.
411 - - Initial snapshot, `cleanup_changelog` (prune pushed > N days), changelog
412 - retention cap, pending-change count.
413 - - The scheduler skeleton: 60 s tick raced against the SSE `changed` stream, the
414 - auth/key/enabled gate chain, a shared sync mutex, exponential backoff, and the
415 - 402 → subscription-required special case.
416 - - Device registration and the `perform_sync` orchestration order.
417 -
418 - Everything an app genuinely varies is small and enumerable. It becomes a
419 - declarative `SyncSchema` plus a couple of trait hooks:
420 -
421 - | Policy | Seen in | Expressed as |
422 - |---|---|---|
423 - | Which tables, their columns, FK order | all | `SyncSchema { tables: [SyncTable…] }`, declaration order = FK order |
424 - | Primary key shape (single / composite) | BB tag tables, AF `tags` | `PrimaryKey::{Single, Composite}` |
425 - | Partial-column, UPDATE-only sync | BB `feed_items` (is_read/is_starred) | `SyncMode::PartialUpdate { set }` |
426 - | Ignore remote deletes | BB `feed_items` | `DeleteMode::Ignore` |
427 - | Soft-delete (tombstone) instead of hard | AF `samples.deleted_at` (CASCADE safety) | `DeleteMode::Tombstone { column }` |
428 - | Preserve local-only columns on upsert | GO `email_accounts` credentials | `preserve_local: &[col]` |
429 - | Column defaults on first insert | GO `password=''` for NOT NULL | `insert_defaults: &[(col, val)]` |
430 - | Row references an *unsynced* table (relax FK) | GO `tasks.source_email_id` | `references_unsynced: true` |
431 - | Row-id privacy hashing on the wire | AF content tables (`hash_row_id(salt,…)`) | `RowIdScheme::Hashed` |
432 - | Exclude some rows/keys from sync | AF `user_config` `sync_*`, `loose_files` | `exclude_where: Option<&str>` (a SQL predicate, see AF example; must be SQL because it also compiles into the generated trigger's WHEN clause, not just the apply guard) |
433 - | Conflict model | GO/AF (HLC), BB (server-order) | `ConflictStrategy::{HybridLogicalClock, ServerOrder}` |
434 - | Blob-bearing tables | AF only | `BlobPolicy` trait (below) |
435 - | Retention cap, cleanup window, batch, interval | all (different numbers) | `SyncConfig` |
436 - | Device-name source, status sink, error mapping | all (framework-specific) | `SyncObserver` trait |
437 -
438 - User scoping (a multi-tenant `WHERE user_id = ?` filter) is deliberately **not**
439 - an engine-level concern. The GoingsOn worked example (below) confirmed the single-
440 - user desktop apps never scope the *changelog* by user, the only place a user
441 - filter appears is inside GO's blob queries, which is policy-local to its
442 - `BlobPolicy`. A changelog-level user filter belongs to the future multi-tenant
443 - work (a third-party-phase prerequisite), not this layer.
444 -
445 - ## The declarative schema
446 -
447 - ```rust
448 - // Abridged from the shipped `store::schema` types. The real fields are private
449 - // (`pub(crate)`) and constructed through the `SyncSchema`/`SyncTable` builders
450 - // (`.table(...)`, `.pk(&[...])`, `.conflict(...)`); shown here public for shape.
451 - pub struct SyncSchema {
452 - /// Declaration order IS the FK order: parents first. The engine upserts in
453 - /// this order and deletes in the reverse. No hand-maintained reversed list.
454 - pub tables: Vec<SyncTable>,
455 - /// Schema-wide conflict model (HLC or server-order).
456 - pub conflict: ConflictStrategy,
457 - }
458 -
459 - pub struct SyncTable {
460 - pub name: &'static str,
461 - /// The one column whitelist. Drives generated triggers, the snapshot
462 - /// projection, and apply-side binding — killing the triplication.
463 - pub columns: &'static [&'static str],
464 - /// PK column(s): one element = single key, more = composite (concatenated
465 - /// with ':' for the wire row-id). A slice, not a Single/Composite enum —
466 - /// simpler and matches the `.pk(&[...])` builder. Defaults to `["id"]`.
467 - pub pk: &'static [&'static str],
468 - pub mode: SyncMode,
469 - pub deletes: DeleteMode,
470 - pub row_id: RowIdScheme,
471 - /// Columns to leave untouched on upsert (secrets that stay per-device).
472 - pub preserve_local: &'static [&'static str],
473 - /// Values to inject only on first INSERT (satisfy NOT NULL on preserved cols).
474 - pub insert_defaults: &'static [(&'static str, &'static str)],
475 - /// This table carries FKs into a table that is not synced; relax FK
476 - /// enforcement while applying it.
477 - pub references_unsynced: bool,
478 - /// Optional SQL predicate that excludes rows from sync (AF config-key
479 - /// denylist). A SQL string, not a closure, because it also compiles into
480 - /// the generated trigger's WHEN clause — not just the apply-side guard.
481 - pub exclude_where: Option<&'static str>,
482 - }
483 -
484 - pub enum SyncMode {
485 - /// Upsert every whitelisted column (INSERT … ON CONFLICT DO UPDATE).
486 - Full,
487 - /// UPDATE only `set` columns where PK matches; never insert, never replace.
488 - /// BB feed_items: sync just is_read/is_starred, never the article body.
489 - PartialUpdate { set: &'static [&'static str] },
490 - }
491 -
492 - pub enum DeleteMode {
493 - Hard,
494 - /// Remote deletes are dropped on the floor (BB feed_items — content
495 - /// re-fetches from source).
496 - Ignore,
497 - /// Apply as `UPDATE … SET <column> = now()` instead of DELETE, so local
498 - /// ON DELETE CASCADE can't wipe organization (AF samples).
499 - Tombstone { column: &'static str },
500 - }
Lines truncated
@@ -1,307 +0,0 @@
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, sdk_key).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 a file-backed blob. Streams it a part at a time, so peak memory is
166 - // one part rather than the whole file, and dedups server-side before reading.
167 - if client.blob_upload_streaming(hash, &path).await? == BlobUploadOutcome::Uploaded {
168 - client.blob_confirm(hash, size).await?;
169 - }
170 -
171 - // Download
172 - let url = client.blob_download_url(hash).await?;
173 - let data = client.blob_download(url).await?;
174 - ```
175 -
176 - For bytes already in memory (no file to stream), the one-shot pair still exists:
177 - `blob_upload_url(hash, plaintext_size)` then `blob_upload(hash, url, data)`, then
178 - `blob_confirm`. It buffers the whole ciphertext and the server refuses it above
179 - its one-shot ceiling, so prefer the streaming call whenever there is a path.
180 -
181 - ---
182 -
183 - ## First-Run Sequence
184 -
185 - ### First Device
186 -
187 - 1. User clicks "Connect to Sync"
188 - 2. App builds auth URL with PKCE challenge → opens browser
189 - 3. User logs in on MNW, approves scopes
190 - 4. Browser redirects to `localhost:PORT` with authorization code
191 - 5. App exchanges code for JWT via SDK
192 - 6. App detects no server key → shows "Set Encryption Password" dialog
193 - 7. User enters password → `setup_encryption_new(password)` generates and uploads encrypted key
194 - 8. App registers device → stores device_id in sync_state
195 - 9. App creates initial snapshot (INSERT all existing rows to changelog)
196 - 10. First sync cycle runs
197 -
198 - ### Same Device, Later Run
199 -
200 - 1. App calls `try_load_key_from_keychain()` → restores session + key
201 - 2. If success, ready to sync
202 - 3. If keychain empty, re-run OAuth flow
203 -
204 - ### Additional Device
205 -
206 - 1. OAuth flow as above
207 - 2. App detects server has key → shows "Enter Encryption Password" dialog
208 - 3. `setup_encryption_existing(password)` decrypts server key → saves to local keychain
209 - 4. Register new device, create initial snapshot, sync
210 -
211 - ---
212 -
213 - ## Adding Sync to a New App
214 -
215 - ### 1. Database Schema
216 -
217 - Add these tables:
218 -
219 - ```sql
220 - CREATE TABLE sync_changelog (
221 - id INTEGER PRIMARY KEY AUTOINCREMENT,
222 - table_name TEXT NOT NULL,
223 - op TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
224 - row_id TEXT NOT NULL,
225 - timestamp INTEGER NOT NULL,
226 - data TEXT, -- JSON, NULL for DELETE
227 - pushed INTEGER DEFAULT 0
228 - );
229 -
230 - CREATE TABLE sync_state (
231 - key TEXT PRIMARY KEY,
232 - value TEXT NOT NULL
233 - );
234 -
235 - -- Seed defaults
236 - INSERT INTO sync_state VALUES ('pull_cursor', '0');
237 - INSERT INTO sync_state VALUES ('applying_remote', '0');
238 - INSERT INTO sync_state VALUES ('initial_snapshot_done', '0');
239 - INSERT INTO sync_state VALUES ('auto_sync_enabled', '1');
240 - INSERT INTO sync_state VALUES ('sync_interval_minutes', '15');
241 - ```
242 -
243 - ### 2. Triggers
244 -
245 - For each syncable table, add three triggers:
246 -
247 - ```sql
248 - CREATE TRIGGER after_insert_my_table AFTER INSERT ON my_table
249 - BEGIN
250 - SELECT CASE
251 - WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
252 - THEN (INSERT INTO sync_changelog (table_name, op, row_id, timestamp, data)
253 - VALUES ('my_table', 'INSERT', NEW.id, strftime('%s','now'),
254 - json_object('col1', NEW.col1, 'col2', NEW.col2)))
255 - END;
256 - END;
257 - -- Repeat for UPDATE and DELETE (DELETE uses OLD.id, data = NULL)
258 - ```
259 -
260 - ### 3. Core Module
261 -
262 - Implement these functions:
263 -
264 - | Function | Purpose |
265 - |----------|---------|
266 - | `get_sync_state(key)` | Read from sync_state |
267 - | `set_sync_state(key, value)` | Write to sync_state |
268 - | `ensure_device_registered(client)` | Cache device_id after first registration |
269 - | `create_initial_snapshot()` | One-time: INSERT all existing rows to changelog |
270 - | `push_changes(client, device_id)` | Batch 500, read/encrypt/send/mark-pushed |
271 - | `pull_changes(client, device_id)` | Loop until no more, decrypt/apply/save-cursor |
272 - | `apply_upsert(table, row_id, data)` | INSERT OR REPLACE with FK order |
273 - | `apply_delete(table, row_id)` | DELETE with FK order |
274 - | `cleanup_changelog()` | Prune pushed entries older than 7 days |
275 -
276 - ### 4. Scheduler
277 -
278 - - Check every 60 seconds if sync is due
279 - - On first run: `create_initial_snapshot()` if needed
280 - - Exponential backoff on failure (2^N minutes, capped at 15)
281 - - Emit status events for UI updates
282 -
283 - ### 5. Commands / UI
284 -
285 - Expose these to the frontend:
286 - - `sync_status()`: configured, authenticated, encryption, device, pending changes
287 - - `sync_start_auth()`: returns auth URL + state + verifier
288 - - `sync_complete_auth(code, state, verifier)`: exchanges for JWT
289 - - `sync_setup_encryption_new/existing(password)`: calls SDK method
290 - - `sync_now()`: triggers immediate cycle
291 - - `sync_disconnect()`: clears credentials and session
292 -
293 - ---
294 -
295 - ## Key Files
296 -
297 - | What | Where |
298 - |------|-------|
299 - | SDK source | `MNW/shared/synckit-client/src/` |
300 - | SDK auth | `MNW/shared/synckit-client/src/client/auth.rs` |
301 - | SDK push/pull | `MNW/shared/synckit-client/src/client/sync.rs` |
302 - | SDK encryption | `MNW/shared/synckit-client/src/crypto.rs` |
303 - | GO sync service | `Apps/goingson/src-tauri/src/sync_service.rs` |
304 - | BB sync service | `Apps/balanced_breakfast/src-tauri/src/sync_service.rs` |
305 - | AF sync service | `Apps/audiofiles/crates/audiofiles-sync/src/service.rs` |
306 - | Server endpoints | `MNW/server/src/routes/synckit.rs` |
307 - | Server DB | `MNW/server/src/db/synckit.rs` |
@@ -1,454 +0,0 @@
1 - //! Authentication and session lifecycle.
2 - //!
3 - //! Password and OAuth login, session restore from a stored token, and the
4 - //! local JWT-expiry check the API methods use to fail fast before a request.
5 -
6 - use bytes::Bytes;
7 - use std::sync::Arc;
8 - use tracing::instrument;
9 - #[cfg(test)]
10 - use uuid::Uuid;
11 -
12 - use crate::{
13 - error::Result,
14 - ids::{AppId, UserId},
15 - types::{AuthRequest, AuthResponse, OAuthTokenResponse},
16 - };
17 -
18 - use super::helpers::{Idempotency, check_response, jwt_exp};
19 - use super::{SecretToken, Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS};
20 -
21 - impl SyncKitClient {
22 - /// Authenticate with the MNW server. Returns (user_id, app_id).
23 - ///
24 - /// `key` is the developer-defined SDK key this session's storage counts
25 - /// against. The dev's backend chooses it, typically one key per
26 - /// workspace/org/end-user.
27 - ///
28 - /// Stores the session internally. If called concurrently from multiple
29 - /// threads, the last write wins, earlier sessions are silently overwritten.
30 - ///
31 - /// # Errors
32 - ///
33 - /// Returns `Server { status: 401, .. }` for wrong credentials.
34 - #[instrument(skip(self, email, password, key))]
35 - pub async fn authenticate(
36 - &self,
37 - email: &str,
38 - password: &str,
39 - key: &str,
40 - ) -> Result<(UserId, AppId)> {
41 - let body = Bytes::from(serde_json::to_vec(&AuthRequest {
42 - email,
43 - password,
44 - api_key: &self.config.api_key,
45 - key,
46 - })?);
47 -
48 - let resp = self
49 - .retry_request(
50 - Idempotency::IdempotentWrite {
51 - on: "credentials; re-auth reissues an equivalent token",
52 - },
53 - || {
54 - let req = self
55 - .http
56 - .post(&self.endpoints.auth)
57 - .header("content-type", "application/json")
58 - .body(body.clone());
59 - async move { check_response(req.send().await?).await }
60 - },
61 - )
62 - .await?;
63 - let auth: AuthResponse = crate::client::helpers::read_json_capped(
64 - resp,
65 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
66 - )
67 - .await?;
68 -
69 - let user_id = auth.user_id;
70 - let app_id = auth.app_id;
71 - let token_exp = jwt_exp(&auth.token);
72 -
73 - *self.session.write() = Some(Session {
74 - token: Arc::new(SecretToken::new(auth.token)),
75 - token_exp,
76 - user_id,
77 - app_id,
78 - });
79 -
80 - tracing::info!("Authenticated as user {user_id} for app {app_id}");
81 - Ok((user_id, app_id))
82 - }
83 -
84 - /// Restore a session from previously stored credentials (e.g. OS keychain).
85 - ///
86 - /// Sets the internal session state without making any HTTP calls.
87 - /// Used on app startup to restore from stored credentials without re-authenticating.
88 - pub fn restore_session(&self, token: &str, user_id: UserId, app_id: AppId) {
89 - let token_exp = jwt_exp(token);
90 - *self.session.write() = Some(Session {
91 - token: Arc::new(SecretToken::new(token.to_string())),
92 - token_exp,
93 - user_id,
94 - app_id,
95 - });
96 - tracing::info!("Session restored for user {user_id}, app {app_id}");
97 - }
98 -
99 - /// Clear the in-memory session and master key.
100 - ///
101 - /// After calling this, the client will need to re-authenticate and set up
102 - /// encryption again. Does not affect OS keychain storage, call
103 - /// `keystore::delete_master_key` separately if needed.
104 - pub fn clear_session(&self) {
105 - *self.session.write() = None;
106 - *self.master_key.write() = None;
107 - tracing::info!("Session and master key cleared");
108 - }
109 -
110 - /// Check whether the current session token has expired (or will expire
111 - /// within a 30-second buffer). Returns `true` if there is no session or
112 - /// if the token's `exp` claim is in the past. Returns `false` if the
113 - /// token cannot be decoded (assumes not expired, the server will reject
114 - /// it with a 401 if it actually is).
115 - pub fn is_token_expired(&self) -> bool {
116 - let guard = self.session.read();
117 - let Some(session) = guard.as_ref() else {
118 - return true;
119 - };
120 - match session.token_exp {
121 - Some(exp) => {
122 - let now = chrono::Utc::now().timestamp();
123 - now >= exp - TOKEN_EXPIRY_BUFFER_SECS
124 - }
125 - None => false,
126 - }
127 - }
128 -
129 - // ── OAuth ──
130 -
131 - /// Build the authorization URL for the OAuth2 PKCE flow.
132 - ///
133 - /// The caller is responsible for generating the PKCE verifier/challenge,
134 - /// starting the localhost callback server, and opening the browser.
135 - ///
136 - /// Sends `scope=sync` explicitly: this is the SyncKit pairing flow and the
137 - /// full sync-API token is what it needs. The server treats an omitted scope
138 - /// as least-privilege userinfo (not sync), so the scope must be sent for the
139 - /// token exchange to yield a sync-capable token.
140 - pub fn build_authorize_url(
141 - &self,
142 - redirect_port: u16,
143 - state: &str,
144 - code_challenge: &str,
145 - ) -> String {
146 - format!(
147 - "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync",
148 - self.config.server_url,
149 - urlencoding::encode(&self.config.api_key),
150 - urlencoding::encode(&format!("http://127.0.0.1:{redirect_port}/")),
151 - urlencoding::encode(state),
152 - urlencoding::encode(code_challenge),
153 - )
154 - }
155 -
156 - /// Exchange an OAuth2 authorization code for a SyncKit JWT.
157 - ///
158 - /// `key` is the developer-defined SDK key for billing attribution, see
159 - /// [`authenticate`](Self::authenticate).
160 - ///
161 - /// Call this after receiving the code from the localhost callback server.
162 - /// On success, stores the session internally (same as `authenticate()`).
163 - #[instrument(skip(self, code, code_verifier, key))]
164 - pub async fn authenticate_with_code(
165 - &self,
166 - code: &str,
167 - code_verifier: &str,
168 - redirect_port: u16,
169 - key: &str,
170 - ) -> Result<(UserId, AppId)> {
171 - let redirect_uri = format!("http://127.0.0.1:{redirect_port}/");
172 -
173 - let form_params = [
174 - ("grant_type", "authorization_code"),
175 - ("code", code),
176 - ("redirect_uri", &redirect_uri),
177 - ("code_verifier", code_verifier),
178 - ("client_id", &self.config.api_key),
179 - ("key", key),
180 - ];
181 -
182 - // OAuth authorization codes are single-use. Retrying after a
183 - // network error would send an already-consumed code, producing a
184 - // permanent 400. Send exactly once and let the caller restart the
185 - // OAuth flow on failure.
186 - let resp = check_response(
187 - self.http
188 - .post(&self.endpoints.oauth_token)
189 - .form(&form_params)
190 - .send()
191 - .await?,
192 - )
193 - .await?;
194 - let token_resp: OAuthTokenResponse = crate::client::helpers::read_json_capped(
195 - resp,
196 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
197 - )
198 - .await?;
199 -
200 - let user_id = token_resp.user_id;
201 - let app_id = token_resp.app_id;
202 - let token_exp = jwt_exp(&token_resp.access_token);
203 -
204 - *self.session.write() = Some(Session {
205 - token: Arc::new(SecretToken::new(token_resp.access_token)),
206 - token_exp,
207 - user_id,
208 - app_id,
209 - });
210 -
211 - tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}");
212 - Ok((user_id, app_id))
213 - }
214 - }
215 -
216 - #[cfg(test)]
217 - mod tests {
218 - use super::*;
219 - use base64::Engine;
220 - use chrono::Utc;
221 -
222 - use crate::error::SyncKitError;
223 -
224 - fn test_config() -> super::super::SyncKitConfig {
225 - super::super::SyncKitConfig {
226 - server_url: "https://example.com".to_string(),
227 - api_key: "test-api-key-123".to_string(),
228 - }
229 - }
230 -
231 - fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
232 - (
233 - crate::ids::AppId::new(
234 - Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
235 - ),
236 - crate::ids::UserId::new(
237 - Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
238 - ),
239 - )
240 - }
241 -
242 - fn fake_jwt(exp: i64) -> String {
243 - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
244 - .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
245 - let payload_json = serde_json::json!({
246 - "sub": "550e8400-e29b-41d4-a716-446655440000",
247 - "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
248 - "exp": exp,
249 - "iat": exp - 3600,
250 - });
251 - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
252 - .encode(payload_json.to_string().as_bytes());
253 - let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
254 - format!("{header}.{payload}.{signature}")
255 - }
256 -
257 - // ── restore_session ──
258 -
259 - #[test]
260 - fn restore_session_makes_client_authenticated() {
261 - let client = SyncKitClient::new(test_config());
262 - let (app_id, user_id) = test_ids();
263 -
264 - client.restore_session("fake-token", user_id, app_id);
265 -
266 - let info = client.session_info().expect("session should exist");
267 - assert_eq!(info.token.as_str(), "fake-token");
268 - assert_eq!(info.user_id, user_id);
269 - assert_eq!(info.app_id, app_id);
270 - }
271 -
272 - #[test]
273 - fn restore_session_overwrites_previous_session() {
274 - let client = SyncKitClient::new(test_config());
275 - let (app_id, user_id) = test_ids();
276 -
277 - client.restore_session("first-token", user_id, app_id);
278 - client.restore_session("second-token", user_id, app_id);
279 -
280 - let info = client.session_info().unwrap();
281 - assert_eq!(info.token.as_str(), "second-token");
282 - }
283 -
284 - // ── build_authorize_url ──
285 -
286 - #[test]
287 - fn build_authorize_url_includes_all_params() {
288 - let client = SyncKitClient::new(test_config());
289 - let url = client.build_authorize_url(8080, "random-state", "challenge123");
290 -
291 - assert!(url.starts_with("https://example.com/oauth/authorize?"));
292 - assert!(url.contains("response_type=code"));
293 - assert!(url.contains("client_id=test-api-key-123"));
294 - assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F"));
295 - assert!(url.contains("state=random-state"));
296 - assert!(url.contains("code_challenge=challenge123"));
297 - assert!(url.contains("code_challenge_method=S256"));
298 - // The pairing flow must request the sync scope explicitly, the server
299 - // treats an omitted scope as least-privilege userinfo, not sync.
300 - assert!(url.contains("scope=sync"));
301 - }
302 -
303 - #[test]
304 - fn build_authorize_url_encodes_special_chars() {
305 - let config = super::super::SyncKitConfig {
306 - server_url: "https://example.com".to_string(),
307 - api_key: "key with spaces&special=chars".to_string(),
308 - };
309 - let client = SyncKitClient::new(config);
310 - let url = client.build_authorize_url(9090, "state/with/slashes", "ch+all=enge");
311 -
312 - assert!(url.contains("key%20with%20spaces%26special%3Dchars"));
313 - assert!(url.contains("state%2Fwith%2Fslashes"));
314 - assert!(url.contains("ch%2Ball%3Denge"));
315 - }
316 -
317 - #[test]
318 - fn build_authorize_url_different_ports() {
319 - let client = SyncKitClient::new(test_config());
320 -
321 - let url_low = client.build_authorize_url(1234, "s", "c");
322 - assert!(url_low.contains("127.0.0.1%3A1234"));
323 -
324 - let url_high = client.build_authorize_url(65535, "s", "c");
325 - assert!(url_high.contains("127.0.0.1%3A65535"));
326 - }
327 -
328 - // ── is_token_expired ──
329 -
330 - #[test]
331 - fn is_token_expired_true_without_session() {
332 - let client = SyncKitClient::new(test_config());
333 - assert!(client.is_token_expired());
334 - }
335 -
336 - #[test]
337 - fn is_token_expired_true_with_expired_token() {
338 - let client = SyncKitClient::new(test_config());
339 - let (app_id, user_id) = test_ids();
340 - let token = fake_jwt(Utc::now().timestamp() - 3600);
341 - client.restore_session(&token, user_id, app_id);
342 - assert!(client.is_token_expired());
343 - }
344 -
345 - #[test]
346 - fn is_token_expired_false_with_fresh_token() {
347 - let client = SyncKitClient::new(test_config());
348 - let (app_id, user_id) = test_ids();
349 - let token = fake_jwt(Utc::now().timestamp() + 3600);
350 - client.restore_session(&token, user_id, app_id);
351 - assert!(!client.is_token_expired());
352 - }
353 -
354 - // ── require_token with expiry ──
355 -
356 - #[test]
357 - fn require_token_returns_token_expired_for_expired_token() {
358 - let client = SyncKitClient::new(test_config());
359 - let (app_id, user_id) = test_ids();
360 - let token = fake_jwt(Utc::now().timestamp() - 3600);
361 - client.restore_session(&token, user_id, app_id);
362 -
363 - let err = client.require_token().unwrap_err();
364 - assert!(matches!(err, SyncKitError::TokenExpired));
365 - }
366 -
367 - #[test]
368 - fn require_token_succeeds_with_fresh_token() {
369 - let client = SyncKitClient::new(test_config());
370 - let (app_id, user_id) = test_ids();
371 - let token = fake_jwt(Utc::now().timestamp() + 3600);
372 - client.restore_session(&token, user_id, app_id);
373 -
374 - assert!(client.require_token().is_ok());
375 - }
376 -
377 - // ── clear_session ──
378 -
379 - #[test]
380 - fn clear_session_clears_master_key() {
381 - let client = SyncKitClient::new(test_config());
382 - let (app_id, user_id) = test_ids();
383 - client.restore_session("token", user_id, app_id);
384 - client.set_master_key_raw([42u8; 32]);
385 -
386 - assert!(client.session_info().is_some());
387 - assert!(client.has_master_key());
388 -
389 - client.clear_session();
390 -
391 - assert!(client.session_info().is_none());
392 - assert!(!client.has_master_key());
393 - }
394 -
395 - // ── OAuth types ──
396 -
397 - #[test]
398 - fn oauth_token_response_deserialization() {
399 - let json = r#"{
400 - "access_token": "jwt-access-token",
401 - "token_type": "Bearer",
402 - "expires_in": 3600,
403 - "user_id": "550e8400-e29b-41d4-a716-446655440000",
404 - "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
405 - }"#;
406 -
407 - let resp: OAuthTokenResponse = serde_json::from_str(json).unwrap();
408 - assert_eq!(resp.access_token, "jwt-access-token");
409 - assert_eq!(resp.token_type, "Bearer");
410 - assert_eq!(resp.expires_in, 3600);
411 - }
412 -
413 - // ── Auth types ──
414 -
415 - #[test]
416 - fn auth_request_serialization() {
417 - let req = AuthRequest {
418 - email: "user@example.com",
419 - password: "secret123",
420 - api_key: "ak_test",
421 - key: "session-key-abc",
422 - };
423 -
424 - let json = serde_json::to_string(&req).unwrap();
425 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
426 - assert_eq!(parsed["email"], "user@example.com");
427 - assert_eq!(parsed["password"], "secret123");
428 - assert_eq!(parsed["api_key"], "ak_test");
429 - assert_eq!(parsed["key"], "session-key-abc");
430 - }
431 -
432 - #[test]
433 - fn auth_response_deserialization() {
434 - let json = r#"{
435 - "token": "jwt.token.here",
436 - "user_id": "550e8400-e29b-41d4-a716-446655440000",
437 - "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
438 - }"#;
439 -
440 - let resp: AuthResponse = serde_json::from_str(json).unwrap();
441 - assert_eq!(resp.token, "jwt.token.here");
442 - assert_eq!(
443 - resp.user_id.as_uuid(),
444 - Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
445 - );
446 - }
447 -
448 - #[test]
449 - fn auth_response_missing_token_fails() {
450 - let json = r#"{"user_id": "550e8400-e29b-41d4-a716-446655440000", "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}"#;
451 - let result = serde_json::from_str::<AuthResponse>(json);
452 - assert!(result.is_err());
453 - }
454 - }
@@ -1,718 +0,0 @@
1 - //! Content-addressed blob upload and download.
2 - //!
3 - //! Blobs are encrypted client-side and stored under the SHA-256 of their
4 - //! plaintext. Uploads go through a presigned URL after a size/quota check;
5 - //! downloads are decrypted and re-hashed on arrival and discarded on any
6 - //! mismatch, so a server that swaps or rolls back a blob cannot slip it past
7 - //! the client.
8 -
9 - use std::path::Path;
10 -
11 - use base64::Engine;
12 - use bytes::{Buf, Bytes, BytesMut};
13 - use sha2::{Digest, Sha256};
14 - use tokio::io::AsyncReadExt;
15 - use tokio_stream::StreamExt;
16 - use tracing::instrument;
17 -
18 - use crate::{
19 - crypto,
20 - error::{Result, SyncKitError},
21 - types::{
22 - BlobConfirmRequest, BlobDownloadUrlRequest, BlobDownloadUrlResponse,
23 - BlobMultipartAbortRequest, BlobMultipartCompleteRequest, BlobMultipartCompletedPart,
24 - BlobMultipartPartsRequest, BlobMultipartPartsResponse, BlobMultipartStartRequest,
25 - BlobMultipartStartResponse, BlobUploadUrlRequest, BlobUploadUrlResponse,
26 - },
27 - };
28 -
29 - use super::SyncKitClient;
30 - use super::helpers::{Idempotency, check_response};
31 -
32 - /// Upper bound on a single decrypted blob held in memory, guarding against a
33 - /// hostile server returning an absurdly large body that would OOM the client.
34 - /// Generous (4 GiB) so legitimate large media still flow.
35 - const MAX_BLOB_BYTES: usize = 4 * 1024 * 1024 * 1024;
36 -
37 - /// What [`SyncKitClient::blob_upload_streaming`] did.
38 - ///
39 - /// The caller needs to tell the two apart: only an upload that sent bytes has
40 - /// anything to confirm.
41 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 - pub enum BlobUploadOutcome {
43 - /// The parts were uploaded and assembled. Call [`SyncKitClient::blob_confirm`]
44 - /// next to record the blob server-side.
45 - Uploaded,
46 - /// The server already held this content address, so no bytes were sent and
47 - /// there is nothing to confirm. Server-asserted, not verified, see
48 - /// [`BlobUploadUrlResponse::already_exists`] for what a wrong `true` costs.
49 - AlreadyPresent,
50 - }
51 -
52 - impl SyncKitClient {
53 - /// Request a presigned upload URL for a blob.
54 - /// Returns (upload_url, already_exists). If `already_exists` is true,
55 - /// the blob is already on the server and no upload is needed.
56 - ///
57 - /// `size_bytes` is the **plaintext** length, the size the caller sees on
58 - /// disk. What [`Self::blob_upload`] actually PUTs is the sealed ciphertext,
59 - /// which is longer by the v3 framing, and the server signs the declared
60 - /// size into the presigned URL as `Content-Length` (a SignedHeader), so
61 - /// declaring the plaintext length makes every PUT fail the signature. This
62 - /// method converts, keeping the wire-format arithmetic inside the SDK
63 - /// instead of asking three consumer apps to know it.
64 - ///
65 - /// `already_exists` is the server's claim, not a verified fact. See
66 - /// [`BlobUploadUrlResponse::already_exists`] for what a false `true` costs
67 - /// (a withheld blob, never a substituted one).
68 - #[instrument(skip(self))]
69 - pub async fn blob_upload_url(
70 - &self,
71 - hash: &str,
72 - size_bytes: i64,
73 - ) -> Result<BlobUploadUrlResponse> {
74 - let plaintext_len = usize::try_from(size_bytes)
75 - .map_err(|_| SyncKitError::InvalidArgument("size_bytes must be non-negative".into()))?;
76 - let token = self.require_token()?;
77 -
78 - let body = Bytes::from(serde_json::to_vec(&BlobUploadUrlRequest {
79 - hash: hash.to_string(),
80 - size_bytes: crypto::blob_encrypted_len(plaintext_len) as i64,
81 - })?);
82 -
83 - self.retry_request_json(Idempotency::ReadOnly, || {
84 - let req = self
85 - .http
86 - .post(&self.endpoints.blobs_upload)
87 - .bearer_auth(&token)
88 - .header("content-type", "application/json")
89 - .body(body.clone());
90 - async move { check_response(req.send().await?).await }
91 - })
92 - .await
93 - }
94 -
95 - /// Upload blob data directly to S3 via a presigned PUT URL.
96 - ///
97 - /// Encrypts the data with the master key before uploading, binding the
98 - /// content `hash` as AEAD associated data so the ciphertext is tied to its
99 - /// content address. The plaintext is never sent to the server, preserving
100 - /// the E2E encryption guarantee.
101 - ///
102 - /// This is the in-memory path: it buffers the whole ciphertext, so the input
103 - /// is capped at [`MAX_BLOB_BYTES`] rather than risking an unbounded
104 - /// allocation. For anything file-backed, prefer
105 - /// [`Self::blob_upload_streaming`], which holds one part at a time and is
106 - /// the only path the server accepts above its one-shot PUT ceiling.
107 - #[instrument(skip(self, presigned_url, data))]
108 - pub async fn blob_upload(&self, hash: &str, presigned_url: &str, data: Vec<u8>) -> Result<()> {
109 - if data.len() > MAX_BLOB_BYTES {
110 - return Err(SyncKitError::InvalidArgument(format!(
111 - "blob is {} bytes, over the {MAX_BLOB_BYTES}-byte in-memory cap; use blob_upload_streaming for a file this size",
112 - data.len()
113 - )));
114 - }
115 - let master_key = self.require_master_key()?;
116 - // v3 chunked format: each chunk sealed and bound to (hash, index, count),
117 - // so the reader verifies and decrypts one chunk at a time.
118 - let encrypted = Bytes::from(crypto::encrypt_blob_chunked(&data, &master_key, hash)?);
119 - // Release the plaintext before the (potentially long, multi-GB) network
120 - // send so the sustained peak during transfer is ~1x the blob, not ~2x.
121 - // The per-attempt `encrypted.clone()` below is a cheap `Bytes` refcount
122 - // bump, not a copy. Never materializing the full ciphertext at all is
123 - // what `blob_upload_streaming` does, for callers holding a file.
124 - drop(data);
125 -
126 - self.retry_request(
127 - Idempotency::IdempotentWrite {
128 - on: "content hash (S3 key is content-addressed)",
129 - },
130 - || {
131 - let req = self
132 - .http_stream
133 - .put(presigned_url)
134 - .header("content-type", "application/octet-stream")
135 - .body(encrypted.clone());
136 - async move { check_response(req.send().await?).await }
137 - },
138 - )
139 - .await?;
140 -
141 - Ok(())
142 - }
143 -
144 - /// Upload a file-backed blob through the multipart session, never holding
145 - /// more than one part in memory.
146 - ///
147 - /// This is the bounded-memory counterpart to [`Self::blob_upload`], and the
148 - /// only path for blobs above the server's one-shot PUT ceiling. It works at
149 - /// any size (a small blob is a single part), so it is the better default
150 - /// whenever the blob is already a file on disk.
151 - ///
152 - /// The v3 chunk geometry is what makes it possible. A presigned PUT needs a
153 - /// signed `Content-Length` up front, and `blob_encrypted_len` gives the
154 - /// exact ciphertext length from the plaintext length alone, so the server
155 - /// signs every part boundary before a single byte has been read, and this
156 - /// method fills those boundaries by sealing 1 MiB chunks as it streams the
157 - /// file. Peak memory is one part plus one chunk, not the whole blob.
158 - ///
159 - /// `hash` must be the SHA-256 of the file's plaintext, as elsewhere. It is
160 - /// recomputed while streaming and the upload is abandoned on mismatch: the
161 - /// caller hashed the file in an earlier pass, so a file edited in between
162 - /// would otherwise be stored under an address that does not describe it.
163 - ///
164 - /// Returns [`BlobUploadOutcome`]: on `Uploaded` the caller calls
165 - /// [`Self::blob_confirm`] next, exactly as with the one-shot path (this
166 - /// method replaces the transport only); on `AlreadyPresent` the server
167 - /// already held the content, nothing was read or sent, and there is nothing
168 - /// to confirm. The dedup check happens before any file read or sealing, so
169 - /// a re-sync of content the server already has costs one round trip.
170 - #[instrument(skip(self, path), fields(path = %path.display()))]
171 - pub async fn blob_upload_streaming(
172 - &self,
173 - hash: &str,
174 - path: &Path,
175 - ) -> Result<BlobUploadOutcome> {
176 - let master_key = self.require_master_key()?;
177 -
178 - let meta = tokio::fs::metadata(path).await.map_err(|e| {
179 - SyncKitError::Internal(format!("stat blob file {}: {e}", path.display()))
180 - })?;
181 - let plaintext_len = usize::try_from(meta.len())
182 - .map_err(|_| SyncKitError::InvalidArgument("blob file is larger than usize".into()))?;
183 - if plaintext_len > MAX_BLOB_BYTES {
184 - return Err(SyncKitError::InvalidArgument(format!(
185 - "blob is {plaintext_len} bytes, over the {MAX_BLOB_BYTES}-byte client cap"
186 - )));
187 - }
188 - // The session is sized in ciphertext, which is knowable from the
189 - // plaintext length alone, that is the whole trick.
190 - let size_bytes = i64::try_from(crypto::blob_encrypted_len(plaintext_len))
191 - .map_err(|_| SyncKitError::InvalidArgument("blob ciphertext exceeds i64".into()))?;
192 -
193 - let token = self.require_token()?;
194 - let start_body = Bytes::from(serde_json::to_vec(&BlobMultipartStartRequest {
195 - hash: hash.to_string(),
196 - size_bytes,
197 - })?);
198 - let start: BlobMultipartStartResponse = self
199 - .retry_request_json(Idempotency::ReadOnly, || {
200 - let req = self
201 - .http
202 - .post(&self.endpoints.blobs_multipart_start)
203 - .bearer_auth(&token)
204 - .header("content-type", "application/json")
205 - .body(start_body.clone());
206 - async move { check_response(req.send().await?).await }
207 - })
208 - .await?;
209 -
210 - if start.already_exists {
211 - return Ok(BlobUploadOutcome::AlreadyPresent);
212 - }
213 -
214 - // Every failure past this point leaves uploaded parts that S3 bills for
215 - // until the session is aborted, so the abort lives in exactly one place:
216 - // here, around the whole transfer. The server-side orphan reaper is the
217 - // backstop for a client that dies outright.
218 - let transfer = self
219 - .stream_blob_parts(hash, &start, path, plaintext_len, size_bytes, &master_key)
220 - .await;
221 - let parts = match transfer {
222 - Ok(parts) => parts,
223 - Err(e) => {
224 - if let Err(abort_err) = self.blob_multipart_abort(hash, &start.upload_id).await {
225 - tracing::warn!(
226 - error = %abort_err,
227 - "failed to abort multipart blob session; the server reaper will release it"
228 - );
229 - }
230 - return Err(e);
231 - }
232 - };
233 -
234 - let complete_body = Bytes::from(serde_json::to_vec(&BlobMultipartCompleteRequest {
235 - hash: hash.to_string(),
236 - upload_id: start.upload_id.clone(),
237 - parts,
238 - })?);
239 - self.retry_request(
240 - Idempotency::IdempotentWrite {
241 - on: "upload_id (completing twice assembles the same object)",
242 - },
243 - || {
244 - let req = self
245 - .http
246 - .post(&self.endpoints.blobs_multipart_complete)
247 - .bearer_auth(&token)
248 - .header("content-type", "application/json")
249 - .body(complete_body.clone());
250 - async move { check_response(req.send().await?).await }
251 - },
252 - )
253 - .await?;
254 -
255 - Ok(BlobUploadOutcome::Uploaded)
256 - }
257 -
258 - /// Seal the file chunk by chunk, cutting the ciphertext at the server's
259 - /// signed part boundaries and PUTting each part. Returns the completed
260 - /// `(part_number, etag)` pairs.
261 - ///
262 - /// Peak memory is one part plus one chunk: the staging buffer never holds
263 - /// more than a part's worth, because a full part is drained and sent the
264 - /// moment it is complete.
265 - async fn stream_blob_parts(
266 - &self,
267 - hash: &str,
268 - start: &BlobMultipartStartResponse,
269 - path: &Path,
270 - plaintext_len: usize,
271 - size_bytes: i64,
272 - master_key: &[u8; 32],
273 - ) -> Result<Vec<BlobMultipartCompletedPart>> {
274 - let part_size = usize::try_from(start.part_size).map_err(|_| {
275 - SyncKitError::Internal(format!(
276 - "server part_size {} exceeds usize",
277 - start.part_size
278 - ))
279 - })?;
280 - if part_size == 0 || start.part_count == 0 {
281 - return Err(SyncKitError::Internal(
282 - "server returned an empty multipart plan".into(),
283 - ));
284 - }
285 -
286 - // The part geometry is server-supplied, so bound it before it drives any
287 - // allocation. Without this a hostile part_count aborts the client on an
288 - // over-large `Vec`/`BytesMut` reservation, and a part_count/part_size that
289 - // does not match the blob defeats the one-part-plus-one-chunk memory bound.
290 - // The plan must describe exactly the ciphertext we are about to upload
291 - // (the server derives it from this same `size_bytes`), so recompute and
292 - // compare rather than trust it.
293 - const MAX_PART_SIZE: usize = 1 << 30; // 1 GiB; real plan parts are well under this
294 - const MAX_PART_COUNT: u32 = 10_000; // S3's hard multipart-part ceiling
295 - if part_size > MAX_PART_SIZE {
296 - return Err(SyncKitError::Internal(format!(
297 - "server part_size {part_size} exceeds the {MAX_PART_SIZE}-byte ceiling"
298 - )));
299 - }
300 - if start.part_count > MAX_PART_COUNT {
301 - return Err(SyncKitError::Internal(format!(
302 - "server part_count {} exceeds the {MAX_PART_COUNT}-part ceiling",
303 - start.part_count
304 - )));
305 - }
306 - let total = usize::try_from(size_bytes).map_err(|_| {
307 - SyncKitError::Internal(format!("blob size {size_bytes} is not a valid length"))
308 - })?;
309 - let expected_parts = total.div_ceil(part_size);
310 - if start.part_count as usize != expected_parts {
311 - return Err(SyncKitError::Internal(format!(
312 - "server multipart plan (part_count {}, part_size {part_size}) does not match the \
313 - {total}-byte blob (expected {expected_parts} parts)",
314 - start.part_count
315 - )));
316 - }
317 -
318 - let mut file = tokio::fs::File::open(path).await.map_err(|e| {
319 - SyncKitError::Internal(format!("open blob file {}: {e}", path.display()))
320 - })?;
321 -
322 - let chunk_count = crypto::blob_chunk_count_for(plaintext_len);
323 - let mut hasher = Sha256::new();
324 - // Plaintext scratch: zeroized on drop so a decrypted chunk does not linger.
325 - let mut plain = zeroize::Zeroizing::new(vec![0u8; crypto::BLOB_CHUNK_SIZE]);
326 - let mut staged = BytesMut::with_capacity(part_size + crypto::BLOB_CHUNK_SIZE);
327 - staged.extend_from_slice(&crypto::blob_header_bytes(plaintext_len));
328 -
329 - let mut completed = Vec::with_capacity(start.part_count as usize);
330 - let mut next_part = 1u32;
331 -
332 - for index in 0..chunk_count {
333 - let offset = index as usize * crypto::BLOB_CHUNK_SIZE;
334 - let want = (plaintext_len - offset).min(crypto::BLOB_CHUNK_SIZE);
335 - file.read_exact(&mut plain[..want]).await.map_err(|e| {
336 - SyncKitError::Internal(format!(
337 - "read blob file {} at offset {offset}: {e}",
338 - path.display()
339 - ))
340 - })?;
341 - hasher.update(&plain[..want]);
342 - staged.extend_from_slice(&crypto::seal_blob_chunk(
343 - &plain[..want],
344 - master_key,
345 - hash,
346 - index,
347 - chunk_count,
348 - )?);
349 -
350 - // Every part but the last is exactly `part_size`; the remainder is
351 - // the final part, so it is never sent from inside this loop.
352 - while staged.len() >= part_size && next_part < start.part_count {
353 - let body = staged.split_to(part_size).freeze();
354 - let etag = self
355 - .upload_blob_part(hash, start, size_bytes, next_part, body)
356 - .await?;
357 - completed.push(BlobMultipartCompletedPart {
358 - part_number: next_part as i32,
359 - etag,
360 - });
361 - next_part += 1;
362 - }
363 - }
364 -
365 - // Verify the content address before the object can be assembled. A
366 - // mismatch here aborts the session, so a file that changed after the
367 - // caller hashed it is never stored under the stale address.
368 - let actual = hex::encode(hasher.finalize());
369 - if actual != hash {
370 - return Err(SyncKitError::IntegrityFailed {
371 - expected: hash.to_string(),
372 - actual,
373 - });
374 - }
375 -
376 - if next_part != start.part_count {
377 - return Err(SyncKitError::Internal(format!(
378 - "sealed stream produced {} parts, server planned {}",
379 - next_part, start.part_count
380 - )));
381 - }
382 - let body = staged.split().freeze();
383 - let etag = self
384 - .upload_blob_part(hash, start, size_bytes, next_part, body)
385 - .await?;
386 - completed.push(BlobMultipartCompletedPart {
387 - part_number: next_part as i32,
388 - etag,
389 - });
390 -
391 - Ok(completed)
392 - }
393 -
394 - /// PUT one part to its presigned URL and return the ETag S3 assigned it.
395 - ///
396 - /// The URL is requested for this part alone, and carries the part's SHA-256
397 - /// bound as `x-amz-checksum-sha256`, so S3 rehashes what it receives and
398 - /// rejects a corrupted part at write time instead of letting it sit until a
399 - /// download fails to decrypt. That is the reason parts are not requested in
400 - /// windows: a digest only exists once the part has been sealed, and only one
401 - /// part is ever in memory. The cost is one small round trip per part.
402 - async fn upload_blob_part(
403 - &self,
404 - hash: &str,
405 - start: &BlobMultipartStartResponse,
406 - size_bytes: i64,
407 - part_number: u32,
408 - body: Bytes,
409 - ) -> Result<String> {
410 - let checksum = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(&body));
411 -
412 - let token = self.require_token()?;
413 - let req_body = Bytes::from(serde_json::to_vec(&BlobMultipartPartsRequest {
414 - hash: hash.to_string(),
415 - upload_id: start.upload_id.clone(),
416 - size_bytes,
417 - first_part: part_number,
418 - count: 1,
419 - checksums: vec![checksum.clone()],
420 - })?);
421 - let window: BlobMultipartPartsResponse = self
422 - .retry_request_json(Idempotency::ReadOnly, || {
423 - let req = self
424 - .http
425 - .post(&self.endpoints.blobs_multipart_parts)
426 - .bearer_auth(&token)
427 - .header("content-type", "application/json")
428 - .body(req_body.clone());
429 - async move { check_response(req.send().await?).await }
430 - })
431 - .await?;
432 -
433 - let part = window.parts.into_iter().next().ok_or_else(|| {
434 - SyncKitError::Internal(format!("server returned no URL for part {part_number}"))
435 - })?;
436 - // The two sides derive the plan independently, so a disagreement means
437 - // one of them is wrong about the geometry. Sending anyway would fail the
438 - // signed Content-Length at S3 with a far less legible error.
439 - if part.part_number != part_number as i32 || part.content_length != body.len() as u64 {
440 - return Err(SyncKitError::Internal(format!(
441 - "part geometry mismatch: server signed part {} for {} bytes, client has part {part_number} of {} bytes",
442 - part.part_number,
443 - part.content_length,
444 - body.len()
445 - )));
446 - }
447 -
448 - let resp = self
449 - .retry_request(
450 - Idempotency::IdempotentWrite {
451 - on: "(upload_id, part_number), re-PUTting a part replaces it",
452 - },
453 - || {
454 - let req = self
455 - .http_stream
456 - .put(&part.url)
457 - .header("content-type", "application/octet-stream")
458 - // Mandatory, not optional: the checksum is a signed
459 - // header, so omitting it fails the signature.
460 - .header("x-amz-checksum-sha256", &checksum)
461 - .body(body.clone());
462 - async move { check_response(req.send().await?).await }
463 - },
464 - )
465 - .await?;
466 -
467 - // S3 identifies each part by the ETag it returns; complete cannot be
468 - // assembled without it.
469 - resp.headers()
470 - .get(reqwest::header::ETAG)
471 - .and_then(|v| v.to_str().ok())
472 - .map(std::string::ToString::to_string)
473 - .ok_or_else(|| {
474 - SyncKitError::Internal(format!("part {part_number} upload returned no ETag"))
475 - })
476 - }
477 -
478 - /// Release an abandoned multipart session and the parts it holds.
479 - async fn blob_multipart_abort(&self, hash: &str, upload_id: &str) -> Result<()> {
480 - let token = self.require_token()?;
481 - let body = Bytes::from(serde_json::to_vec(&BlobMultipartAbortRequest {
482 - hash: hash.to_string(),
483 - upload_id: upload_id.to_string(),
484 - })?);
485 - self.retry_request(
486 - Idempotency::IdempotentWrite {
487 - on: "upload_id (aborting twice is a no-op)",
488 - },
489 - || {
490 - let req = self
491 - .http
492 - .post(&self.endpoints.blobs_multipart_abort)
493 - .bearer_auth(&token)
494 - .header("content-type", "application/json")
495 - .body(body.clone());
496 - async move { check_response(req.send().await?).await }
497 - },
498 - )
499 - .await?;
500 - Ok(())
Lines truncated
@@ -1,278 +0,0 @@
1 - //! Master-key setup and password lifecycle.
2 - //!
3 - //! Establishes the master key on a first or subsequent device by wrapping and
4 - //! unwrapping it against the server-held key envelope, caches it in the OS
5 - //! keychain when available, and re-wraps it on a password change.
6 -
7 - use bytes::Bytes;
8 - use std::sync::Arc;
9 - use tracing::instrument;
10 -
11 - use crate::{
12 - crypto,
13 - error::Result,
14 - keystore,
15 - types::{GetKeyResponse, PutKeyRequest},
16 - };
17 -
18 - use super::helpers::{Idempotency, check_response};
19 - use super::{SecretToken, SyncKitClient};
20 -
21 - impl SyncKitClient {
22 - /// Check if the server has an encrypted master key for this user.
23 - #[instrument(skip(self))]
24 - pub async fn has_server_key(&self) -> Result<bool> {
25 - let (url, token) = self.key_url_and_token()?;
26 -
27 - let result = self
28 - .retry_request(Idempotency::ReadOnly, || {
29 - let req = self.http.get(url).bearer_auth(&token);
30 - async move {
31 - let resp = req.send().await?;
32 - match resp.status().as_u16() {
33 - 200 | 404 => Ok(resp),
34 - status => {
35 - let message = crate::client::helpers::read_text_capped(
36 - resp,
37 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
38 - )
39 - .await;
40 - Err(crate::error::SyncKitError::Server {
41 - status,
42 - message,
43 - retry_after_secs: None,
44 - })
45 - }
46 - }
47 - }
48 - })
49 - .await?;
50 -
51 - Ok(result.status().as_u16() == 200)
52 - }
53 -
54 - /// First device: generate a new master key, encrypt it, push to server, cache in keychain.
55 - #[instrument(skip(self, password))]
56 - pub async fn setup_encryption_new(&self, password: &str) -> Result<()> {
57 - let (app_id, user_id) = self.require_session_ids()?;
58 -
59 - // Wrap the fresh key immediately so it is scrubbed on every early-return
60 - // path below (wrap/put/store can each fail), not left as a bare array on
61 - // the stack until the final move into the lock.
62 - let master_key = crypto::ZeroizeOnDrop(crypto::generate_master_key());
63 - let envelope = crypto::wrap_master_key(&master_key.0, password)?;
64 -
65 - // Push to server (expected_version 0 = first key)
66 - self.put_server_key(&envelope, 0).await?;
67 -
68 - // Cache in OS keychain
69 - keystore::store_key(app_id, user_id, &master_key.0)?;
70 -
71 - // Store in memory
72 - *self.master_key.write() = Some(master_key);
73 -
74 - tracing::info!("New master key generated and stored");
75 - Ok(())
76 - }
77 -
78 - /// Second device: pull encrypted master key from server, decrypt with password, cache.
79 - #[instrument(skip(self, password))]
80 - pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> {
81 - let (app_id, user_id) = self.require_session_ids()?;
82 -
83 - let (envelope_json, _key_version) = self.get_server_key().await?;
84 - // Wrap immediately so a failed keychain store scrubs the key rather than
85 - // dropping a bare array.
86 - let master_key =
87 - crypto::ZeroizeOnDrop(crypto::unwrap_master_key(&envelope_json, password)?);
88 -
89 - // Cache in OS keychain
90 - keystore::store_key(app_id, user_id, &master_key.0)?;
91 -
92 - // Store in memory
93 - *self.master_key.write() = Some(master_key);
94 -
95 - tracing::info!("Master key recovered from server");
96 - Ok(())
97 - }
98 -
99 - /// Subsequent launches: try to load the master key from the OS keychain.
100 - /// Returns true if a key was found.
101 - pub fn try_load_key_from_keychain(&self) -> Result<bool> {
102 - let (app_id, user_id) = self.require_session_ids()?;
103 -
104 - if let Some(key) = keystore::load_key(app_id, user_id)? {
105 - *self.master_key.write() = Some(key);
106 - tracing::info!("Master key loaded from keychain");
107 - Ok(true)
108 - } else {
109 - Ok(false)
110 - }
111 - }
112 -
113 - /// Change the encryption password. Always validates the old password
114 - /// against the server envelope before re-encrypting with the new password.
115 - ///
116 - /// Even when the master key is cached in memory (normal case, user is
117 - /// logged in), the old password is verified by attempting to unwrap the
118 - /// server envelope. This prevents an attacker with session access from
119 - /// changing the password without knowing the current one.
120 - #[instrument(skip(self, old_password, new_password))]
121 - pub async fn change_password(&self, old_password: &str, new_password: &str) -> Result<()> {
122 - // Always fetch the envelope from the server and verify old_password
123 - // can decrypt it, regardless of whether we have a cached key.
124 - let (envelope_json, key_version) = self.get_server_key().await?;
125 - let verified_key = crypto::verify_password_against_envelope(&envelope_json, old_password)?;
126 -
127 - let master_key = crypto::ZeroizeOnDrop(verified_key);
128 -
129 - // If we hold the master key in memory, the envelope the server just handed
130 - // us MUST wrap that same key. A hostile server could otherwise substitute a
131 - // *different* envelope that also unwraps under `old_password` (e.g. one it
132 - // captured), making us re-wrap the wrong key under the new password and
133 - // lock the user out of their own (original-key-encrypted) data. Refuse.
134 - if let Some(cached) = self.master_key.read().as_ref()
135 - && cached.0 != master_key.0
136 - {
137 - return Err(crate::error::SyncKitError::Crypto(
138 - "server key envelope does not match the in-memory master key; refusing to change password".into(),
139 - ));
140 - }
141 -
142 - // Re-wrap with new password (generates fresh random salt)
143 - let new_envelope = crypto::wrap_master_key(&master_key.0, new_password)?;
144 -
145 - // Optimistic lock: reject if another device changed the password
146 - // between our GET and this PUT.
147 - self.put_server_key(&new_envelope, key_version).await?;
148 -
149 - tracing::info!("Encryption password changed");
150 - Ok(())
151 - }
152 -
153 - /// Build the key endpoint URL and extract the bearer token.
154 - pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<SecretToken>)> {
155 - let token = self.require_token()?;
156 - Ok((&self.endpoints.keys, token))
157 - }
158 -
159 - /// Upload the encrypted master key envelope to the server (PUT /api/sync/keys).
160 - ///
161 - /// `expected_version` is the key version the client expects. The server
162 - /// rejects with 409 if the current version doesn't match (another device
163 - /// changed the password). Use 0 for the initial key upload.
164 - pub(super) async fn put_server_key(
165 - &self,
166 - envelope_json: &str,
167 - expected_version: i32,
168 - ) -> Result<()> {
169 - let (url, token) = self.key_url_and_token()?;
170 -
171 - let body = Bytes::from(serde_json::to_vec(&PutKeyRequest {
172 - encrypted_key: envelope_json.to_string(),
173 - expected_version,
174 - })?);
175 -
176 - self.retry_request(
177 - Idempotency::IdempotentWrite {
178 - on: "expected_version optimistic lock",
179 - },
180 - || {
181 - let req = self
182 - .http
183 - .put(url)
184 - .bearer_auth(&token)
185 - .header("content-type", "application/json")
186 - .body(body.clone());
187 - async move { check_response(req.send().await?).await }
188 - },
189 - )
190 - .await?;
191 - Ok(())
192 - }
193 -
194 - /// Download the encrypted master key envelope from the server (GET /api/sync/keys).
195 - /// Returns `(envelope_json, key_version)`.
196 - pub(super) async fn get_server_key(&self) -> Result<(String, i32)> {
197 - let resp = self.get_server_key_full().await?;
198 - Ok((resp.encrypted_key, resp.key_version.unwrap_or(0)))
199 - }
200 -
201 - /// Download the full key state, including any in-progress rotation's
202 - /// `pending_key`. Used by `rotate_key` to resume an interrupted rotation
203 - /// with the already-committed key rather than minting a fresh one.
204 - pub(super) async fn get_server_key_full(&self) -> Result<GetKeyResponse> {
205 - let (url, token) = self.key_url_and_token()?;
206 -
207 - self.retry_request_json(Idempotency::ReadOnly, || {
208 - let req = self.http.get(url).bearer_auth(&token);
209 - async move { check_response(req.send().await?).await }
210 - })
211 - .await
212 - }
213 - }
214 -
215 - #[cfg(test)]
216 - mod tests {
217 - use crate::error::SyncKitError;
218 -
219 - use super::*;
220 -
221 - fn test_config() -> super::super::SyncKitConfig {
222 - super::super::SyncKitConfig {
223 - server_url: "https://example.com".to_string(),
224 - api_key: "test-api-key-123".to_string(),
225 - }
226 - }
227 -
228 - fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
229 - (
230 - crate::ids::AppId::new(
231 - uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
232 - ),
233 - crate::ids::UserId::new(
234 - uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
235 - ),
236 - )
237 - }
238 -
239 - #[test]
240 - fn key_url_and_token_builds_correct_url() {
241 - let client = SyncKitClient::new(test_config());
242 - let (app_id, user_id) = test_ids();
243 - client.restore_session("bearer-token", user_id, app_id);
244 -
245 - let (url, token) = client.key_url_and_token().unwrap();
246 - assert_eq!(url, "https://example.com/api/v1/sync/keys");
247 - assert_eq!(token.as_str(), "bearer-token");
248 - }
249 -
250 - #[test]
251 - fn key_url_and_token_fails_without_session() {
252 - let client = SyncKitClient::new(test_config());
253 - let err = client.key_url_and_token().unwrap_err();
254 - assert!(matches!(err, SyncKitError::NotAuthenticated));
255 - }
256 -
257 - // ── Key types ──
258 -
259 - #[test]
260 - fn put_key_request_serialization() {
261 - let req = PutKeyRequest {
262 - encrypted_key: "envelope-json-here".to_string(),
263 - expected_version: 1,
264 - };
265 -
266 - let json = serde_json::to_string(&req).unwrap();
267 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
268 - assert_eq!(parsed["encrypted_key"], "envelope-json-here");
269 - assert_eq!(parsed["expected_version"], 1);
270 - }
271 -
272 - #[test]
273 - fn get_key_response_deserialization() {
274 - let json = r#"{"encrypted_key": "{\"v\":1,\"salt\":\"...\",\"nonce\":\"...\",\"ciphertext\":\"...\"}"}"#;
275 - let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
276 - assert!(resp.encrypted_key.contains("\"v\":1"));
277 - }
278 - }
@@ -1,481 +0,0 @@
1 - //! Group sync: list the caller's groups, fetch their sealed GCK grant, and
2 - //! push/pull a group's shared changelog under its Group Content Key.
3 - //!
4 - //! The GCK is supplied by the caller, the `SyncStore` resolves it from the grant
5 - //! via the identity key (a later slice); these methods only encrypt/decrypt with
6 - //! it. Group entries bind `(group_id, table, row_id)` as AEAD associated data, so
7 - //! a ciphertext cannot be relocated across groups. Design: wiki
8 - //! synckit-multiscope-design.
9 -
10 - use bytes::Bytes;
11 - use tracing::instrument;
12 - use uuid::Uuid;
13 -
14 - use crate::{
15 - crypto,
16 - error::Result,
17 - identity::{IdentityKeypair, IdentityPublicKey, generate_group_key, seal_gck_to_member},
18 - ids::{DeviceId, GroupId, UserId},
19 - types::{
20 - ChangeEntry, GroupGrant, GroupMember, PullRequest, PullResponse, PulledChange,
21 - PushResponse, SyncGroup, WirePushRequest,
22 - },
23 - };
24 -
25 - use super::SyncKitClient;
26 - use super::helpers::{Idempotency, check_response};
27 -
28 - /// Request body for `POST /groups`. The id is client-generated so the admin's
29 - /// grant can be sealed bound to it before the group exists.
30 - #[derive(serde::Serialize)]
31 - struct CreateGroupBody<'a> {
32 - id: GroupId,
33 - name: &'a str,
34 - admin_sealed_gck: String,
35 - admin_pubkey: String,
36 - }
37 -
38 - /// Request body for `POST /groups/{id}/members`.
39 - #[derive(serde::Serialize)]
40 - struct AddMemberBody<'a> {
41 - member_email: &'a str,
42 - sealed_gck: String,
43 - member_pubkey: &'a str,
44 - #[serde(skip_serializing_if = "Option::is_none")]
45 - role: Option<&'a str>,
46 - }
47 -
48 - impl SyncKitClient {
49 - /// List the groups the authenticated user belongs to within this app.
50 - #[instrument(skip(self))]
51 - pub async fn list_groups(&self) -> Result<Vec<SyncGroup>> {
52 - let token = self.require_token()?;
53 - self.retry_request_json(Idempotency::ReadOnly, || {
54 - let req = self.http.get(self.endpoints.groups()).bearer_auth(&token);
55 - async move { check_response(req.send().await?).await }
56 - })
57 - .await
58 - }
59 -
60 - /// This user's identity public key (base64), derived from their master key.
61 - /// A member shares it with a group admin so the admin can seal the GCK to it
62 - /// (the paste-a-public-key model). Requires the master key to be loaded.
63 - pub fn my_identity_public_key(&self) -> Result<String> {
64 - let master = self.require_master_key()?;
65 - Ok(IdentityKeypair::from_master_key(&master)
66 - .public_key()
67 - .to_base64())
68 - }
69 -
70 - /// Create a group with the caller as its admin and first member. Mints a
71 - /// fresh Group Content Key, seals it to the caller's own identity public key
72 - /// (derived from the master key), and registers the group.
73 - ///
74 - /// The group id is generated client-side: the admin's grant is sealed with the
75 - /// group id bound as associated data, so the id must be known before the seal,
76 - /// before the server round-trip. Generation starts at 1.
77 - #[instrument(skip(self))]
78 - pub async fn create_group(&self, name: &str) -> Result<SyncGroup> {
79 - let token = self.require_token()?;
80 - let master = self.require_master_key()?;
81 - let identity = IdentityKeypair::from_master_key(&master);
82 - let group_id = GroupId::new(Uuid::new_v4());
83 - let gck = generate_group_key();
84 - let sealed = seal_gck_to_member(&gck, &identity.public_key(), &group_id.to_string(), 1)?;
85 -
86 - let body = Bytes::from(serde_json::to_vec(&CreateGroupBody {
87 - id: group_id,
88 - name,
89 - admin_sealed_gck: sealed,
90 - admin_pubkey: identity.public_key().to_base64(),
91 - })?);
92 - let url = self.endpoints.groups().to_string();
93 -
94 - // The client-chosen id makes this safe to retry: a replay hits the PK and
95 - // the group already exists, but the sealed grant is identical.
96 - self.retry_request_json(Idempotency::Keyed, || {
97 - let req = self
98 - .http
99 - .post(&url)
100 - .bearer_auth(&token)
101 - .header("content-type", "application/json")
102 - .body(body.clone());
103 - async move { check_response(req.send().await?).await }
104 - })
105 - .await
106 - }
107 -
108 - /// Add a member to a group by account email, sealing the group's current GCK
109 - /// to the member's identity public key (obtained out of band, the member
110 - /// pastes their public key from [`my_identity_public_key`]). Admin only.
111 - #[instrument(skip(self, member_pubkey_b64))]
112 - pub async fn add_member(
113 - &self,
114 - group_id: GroupId,
115 - member_email: &str,
116 - member_pubkey_b64: &str,
117 - ) -> Result<()> {
118 - let token = self.require_token()?;
119 - // Resolve the group's current GCK (and its generation) from our own grant.
120 - let grant = self.group_grant(group_id).await?;
121 - let master = self.require_master_key()?;
122 - let gck = Self::open_group_grant(&grant, &master, group_id)?;
123 -
124 - let member_pubkey = IdentityPublicKey::from_base64(member_pubkey_b64)?;
125 - let sealed = seal_gck_to_member(
126 - &gck,
127 - &member_pubkey,
128 - &group_id.to_string(),
129 - grant.gck_version,
130 - )?;
131 -
132 - let body = Bytes::from(serde_json::to_vec(&AddMemberBody {
133 - member_email,
134 - sealed_gck: sealed,
135 - member_pubkey: member_pubkey_b64,
136 - role: None,
137 - })?);
138 - let url = self.endpoints.group_members(group_id);
139 -
140 - self.retry_request(Idempotency::Keyed, || {
141 - let req = self
142 - .http
143 - .post(&url)
144 - .bearer_auth(&token)
145 - .header("content-type", "application/json")
146 - .body(body.clone());
147 - async move { check_response(req.send().await?).await }
148 - })
149 - .await?;
150 - Ok(())
151 - }
152 -
153 - /// List a group's members (id, role, joined-at). Admin only (the server gates
154 - /// it); use to show who can be removed.
155 - #[instrument(skip(self))]
156 - pub async fn list_members(&self, group_id: GroupId) -> Result<Vec<GroupMember>> {
157 - let token = self.require_token()?;
158 - let url = self.endpoints.group_members(group_id);
159 - self.retry_request_json(Idempotency::ReadOnly, || {
160 - let req = self.http.get(&url).bearer_auth(&token);
161 - async move { check_response(req.send().await?).await }
162 - })
163 - .await
164 - }
165 -
166 - /// Remove a member from a group by user id. Admin only. The server membership
167 - /// ACL revokes the member's group read/write access immediately.
168 - ///
169 - /// Forward secrecy for writes made *after* removal requires rotating the GCK
170 - /// (re-mint, re-seal to the remaining members, bump the generation), which the
171 - /// server does not yet expose, a later slice. This method performs the
172 - /// membership revocation only; data the member already pulled is in their hands.
173 - #[instrument(skip(self))]
174 - pub async fn remove_member(&self, group_id: GroupId, member: UserId) -> Result<()> {
175 - let token = self.require_token()?;
176 - let url = self.endpoints.group_member(group_id, member);
177 - self.retry_request(Idempotency::Keyed, || {
178 - let req = self.http.delete(&url).bearer_auth(&token);
179 - async move { check_response(req.send().await?).await }
180 - })
181 - .await?;
182 - Ok(())
183 - }
184 -
185 - /// Fetch the caller's own sealed GCK grant for a group. Open it with the
186 - /// member's identity private key to recover the GCK.
187 - #[instrument(skip(self))]
188 - pub async fn group_grant(&self, group_id: GroupId) -> Result<GroupGrant> {
189 - let token = self.require_token()?;
190 - let url = self.endpoints.group_grant(group_id);
191 - self.retry_request_json(Idempotency::ReadOnly, || {
192 - let req = self.http.get(&url).bearer_auth(&token);
193 - async move { check_response(req.send().await?).await }
194 - })
195 - .await
196 - }
197 -
198 - /// Push encrypted changes to a group's shared changelog under its GCK.
199 - /// Returns the server cursor after the push.
200 - #[instrument(skip(self, gck, changes))]
201 - pub async fn group_push(
202 - &self,
203 - group_id: GroupId,
204 - gck: &[u8; 32],
205 - device_id: DeviceId,
206 - changes: Vec<ChangeEntry>,
207 - ) -> Result<i64> {
208 - let token = self.require_token()?;
209 - let group_str = group_id.to_string();
210 - let wire_changes = changes
211 - .into_iter()
212 - .map(|c| Self::encrypt_group_change_with_key(&group_str, c, gck))
213 - .collect::<Result<Vec<_>>>()?;
214 -
215 - let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
216 - device_id,
217 - batch_id: Uuid::new_v4(),
218 - changes: wire_changes,
219 - })?);
220 - let url = self.endpoints.group_push(group_id);
221 -
222 - let push_resp: PushResponse = self
223 - .retry_request_json(Idempotency::Keyed, || {
224 - let req = self
225 - .http
226 - .post(&url)
227 - .bearer_auth(&token)
228 - .header("content-type", "application/json")
229 - .body(body.clone());
230 - async move { check_response(req.send().await?).await }
231 - })
232 - .await?;
233 - Ok(push_resp.cursor)
234 - }
235 -
236 - /// Pull a group's changes since `cursor`, decrypting under its GCK. Returns
237 - /// `(changes, new_cursor, has_more)` with per-row device/seq metadata, ready
238 - /// for conflict resolution. Group pull has no master-key-rotation window (a
239 - /// group's key rotates server-side, re-encrypted in place), so the decrypt is
240 - /// a single-key pass.
241 - #[instrument(skip(self, gck))]
242 - pub async fn group_pull_rich(
243 - &self,
244 - group_id: GroupId,
245 - gck: &[u8; 32],
246 - device_id: DeviceId,
247 - cursor: i64,
248 - ) -> Result<(Vec<PulledChange>, i64, bool)> {
249 - let token = self.require_token()?;
250 - let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
251 - let url = self.endpoints.group_pull(group_id);
252 -
253 - let pull_resp: PullResponse = self
254 - .retry_request_json(Idempotency::ReadOnly, || {
255 - let req = self
256 - .http
257 - .post(&url)
258 - .bearer_auth(&token)
259 - .header("content-type", "application/json")
260 - .body(body.clone());
261 - async move { check_response(req.send().await?).await }
262 - })
263 - .await?;
264 -
265 - let group_str = group_id.to_string();
266 - let changes = pull_resp
267 - .changes
268 - .into_iter()
269 - .map(|c| Self::decrypt_group_change_to_pulled(&group_str, c, gck))
270 - .collect::<Result<Vec<_>>>()?;
271 - Ok((changes, pull_resp.cursor, pull_resp.has_more))
272 - }
273 -
274 - /// Resolve a group's decrypted Group Content Key for `gck_version`, fetching
275 - /// and opening the sealed grant on a cache miss.
276 - ///
277 - /// Fast path: a cached GCK at the requested version is returned without a
278 - /// network call. On a miss (or a version mismatch, the group's key rotated),
279 - /// the sealed grant is fetched, opened with the identity keypair derived from
280 - /// this client's master key, and cached under the grant's actual version. The
281 - /// master key and identity secret never leave the client, the `SyncStore`
282 - /// only ever receives the resolved GCK.
283 - // Consumed by `sync_now`'s scope iteration in slice 4.
284 - #[allow(dead_code)]
285 - pub(crate) async fn group_content_key(
286 - &self,
287 - group_id: GroupId,
288 - gck_version: i32,
289 - ) -> Result<crypto::ZeroizeOnDrop> {
290 - if let Some((v, gck)) = self.gck_cache.read().get(&group_id)
291 - && *v == gck_version
292 - {
293 - return Ok(crypto::ZeroizeOnDrop(**gck));
294 - }
295 -
296 - let grant = self.group_grant(group_id).await?;
297 - let master = self.require_master_key()?;
298 - let gck = Self::open_group_grant(&grant, &master, group_id)?;
299 - let out = crypto::ZeroizeOnDrop(*gck);
300 - self.gck_cache
301 - .write()
302 - .insert(group_id, (grant.gck_version, gck));
303 - Ok(out)
304 - }
305 -
306 - /// Open a sealed grant with the identity keypair derived from `master_key`.
307 - /// Pure (no I/O), so the derive-and-open path is unit-testable.
308 - fn open_group_grant(
309 - grant: &GroupGrant,
310 - master_key: &[u8; 32],
311 - group_id: GroupId,
312 - ) -> Result<crypto::ZeroizeOnDrop> {
313 - let identity = crate::identity::IdentityKeypair::from_master_key(master_key);
314 - let gck = crate::identity::open_gck_grant(
315 - &grant.sealed_gck,
316 - &identity,
317 - &group_id.to_string(),
318 - grant.gck_version,
319 - )?;
320 - Ok(crypto::ZeroizeOnDrop(gck))
321 - }
322 -
323 - /// Drop a group's cached GCK, forcing the next
324 - /// [`group_content_key`](Self::group_content_key) to re-fetch its grant. Call
325 - /// on a decrypt failure (a stale key after a rotation this client missed).
326 - // Consumed by `sync_now`'s scope iteration in slice 4.
327 - #[allow(dead_code)]
328 - pub(crate) fn invalidate_gck(&self, group_id: GroupId) {
329 - self.gck_cache.write().remove(&group_id);
330 - }
331 - }
332 -
333 - #[cfg(test)]
334 - mod tests {
335 - use super::*;
336 - use crate::error::SyncKitError;
337 - use crate::ids::DeviceId;
338 - use crate::types::{ChangeOp, Hlc, PullChangeEntry, WireChangeEntry};
339 - use chrono::Utc;
340 -
341 - fn insert(table: &str, row: &str, data: serde_json::Value) -> ChangeEntry {
342 - ChangeEntry {
343 - table: table.into(),
344 - op: ChangeOp::Insert,
345 - row_id: row.into(),
346 - timestamp: Utc::now(),
347 - hlc: Hlc::zero(DeviceId::nil()),
348 - data: Some(data),
349 - extra: serde_json::Map::default(),
350 - }
351 - }
352 -
353 - fn to_pull(wire: WireChangeEntry, device: DeviceId, seq: i64) -> PullChangeEntry {
354 - PullChangeEntry {
355 - seq,
356 - device_id: device,
357 - table: wire.table,
358 - op: wire.op,
359 - row_id: wire.row_id,
360 - timestamp: wire.timestamp,
361 - data: wire.data,
362 - key_id: None,
363 - }
364 - }
365 -
366 - #[test]
367 - fn group_change_encrypt_decrypt_roundtrip_preserves_data_and_hlc() {
368 - let gck = crypto::generate_master_key();
369 - let device = DeviceId::new(uuid::Uuid::new_v4());
370 - let hlc = Hlc {
371 - wall_ms: 7,
372 - counter: 1,
373 - node: device,
374 - };
375 - let mut e = insert("tasks", "r1", serde_json::json!({ "title": "shared" }));
376 - e.hlc = hlc;
377 -
378 - let wire = SyncKitClient::encrypt_group_change_with_key("grp-1", e, &gck).unwrap();
379 - let pulled =
380 - SyncKitClient::decrypt_group_change_to_pulled("grp-1", to_pull(wire, device, 3), &gck)
381 - .unwrap();
382 -
383 - assert_eq!(pulled.seq, 3);
384 - assert_eq!(pulled.device_id, device);
385 - assert_eq!(
386 - pulled.entry.data.unwrap(),
387 - serde_json::json!({ "title": "shared" })
388 - );
389 - assert_eq!(pulled.entry.hlc, hlc);
390 - }
391 -
392 - #[test]
393 - fn group_ciphertext_cannot_be_opened_under_another_group() {
394 - let gck = crypto::generate_master_key();
395 - let wire = SyncKitClient::encrypt_group_change_with_key(
396 - "grp-A",
397 - insert("t", "r", serde_json::json!(1)),
398 - &gck,
399 - )
400 - .unwrap();
401 - // Same GCK, but a different group id in the AAD: the open fails closed.
402 - let err = SyncKitClient::decrypt_group_change_to_pulled(
403 - "grp-B",
404 - to_pull(wire, DeviceId::nil(), 1),
405 - &gck,
406 - )
407 - .unwrap_err();
408 - assert!(matches!(err, SyncKitError::DecryptionFailed));
409 - }
410 -
411 - #[test]
412 - fn wrong_gck_fails_closed() {
413 - let gck = crypto::generate_master_key();
414 - let other = crypto::generate_master_key();
415 - let wire = SyncKitClient::encrypt_group_change_with_key(
416 - "grp-1",
417 - insert("t", "r", serde_json::json!(1)),
418 - &gck,
419 - )
420 - .unwrap();
421 - let err = SyncKitClient::decrypt_group_change_to_pulled(
422 - "grp-1",
423 - to_pull(wire, DeviceId::nil(), 1),
424 - &other,
425 - )
426 - .unwrap_err();
427 - assert!(matches!(err, SyncKitError::DecryptionFailed));
428 - }
429 -
430 - #[test]
431 - fn create_group_admin_self_grant_is_openable() {
432 - // Mirrors create_group's sealing path: a client-generated group id, the
433 - // GCK sealed to the admin's own derived identity at generation 1. The
434 - // admin's device must be able to open its own grant back to the same GCK,
435 - // or it would be locked out of the group it just created.
436 - let master = crypto::generate_master_key();
437 - let identity = crate::identity::IdentityKeypair::from_master_key(&master);
438 - let group = GroupId::new(uuid::Uuid::new_v4());
439 - let gck = crate::identity::generate_group_key();
440 - let sealed = crate::identity::seal_gck_to_member(
441 - &gck,
442 - &identity.public_key(),
443 - &group.to_string(),
444 - 1,
445 - )
446 - .unwrap();
447 - let grant = GroupGrant {
448 - sealed_gck: sealed,
449 - gck_version: 1,
450 - };
451 - let opened = SyncKitClient::open_group_grant(&grant, &master, group).unwrap();
452 - assert_eq!(*opened, gck);
453 - }
454 -
455 - #[test]
456 - fn open_group_grant_recovers_gck_via_derived_identity() {
457 - // The admin seals the GCK to the member's derived public key; the member's
458 - // client re-derives the identity from its master key and opens the grant.
459 - let group = GroupId::new(uuid::Uuid::new_v4());
460 - let master_key = crypto::generate_master_key();
461 - let member = crate::identity::IdentityKeypair::from_master_key(&master_key);
462 - let gck = crate::identity::generate_group_key();
463 - let sealed =
464 - crate::identity::seal_gck_to_member(&gck, &member.public_key(), &group.to_string(), 4)
465 - .unwrap();
466 - let grant = GroupGrant {
467 - sealed_gck: sealed,
468 - gck_version: 4,
469 - };
470 -
471 - let opened = SyncKitClient::open_group_grant(&grant, &master_key, group).unwrap();
472 - assert_eq!(*opened, gck);
473 -
474 - // A wrong-version grant (AAD mismatch) fails closed.
475 - let bad = GroupGrant {
476 - sealed_gck: grant.sealed_gck.clone(),
477 - gck_version: 5,
478 - };
479 - assert!(SyncKitClient::open_group_grant(&bad, &master_key, group).is_err());
480 - }
481 - }
@@ -1,1455 +0,0 @@
1 - //! Internal HTTP plumbing shared across the client modules.
2 - //!
3 - //! Response-status to [`SyncKitError`] mapping, idempotency-key generation,
4 - //! size-capped body reads (so a hostile server cannot exhaust memory on a
5 - //! control response), wire-version negotiation, and JWT-expiry parsing. This
6 - //! module is `pub(crate)`: none of it is part of the SDK's public API.
7 -
8 - use base64::Engine;
9 -
10 - use crate::{
11 - crypto,
12 - error::{Result, SyncKitError},
13 - types::{ChangeEntry, Hlc, PullChangeEntry, WireChangeEntry},
14 - };
15 -
16 - use super::{BASE_DELAY, MAX_RETRIES, SyncKitClient};
17 -
18 - /// Proof, supplied at the call site, that retrying an operation is sound.
19 - ///
20 - /// Retry replays the request, so it is only safe when the server treats the
21 - /// operation idempotently. Requiring this argument makes that judgement an
22 - /// explicit, reviewable decision, and the judgement has runtime teeth:
23 - /// [`may_retry`](Self::may_retry) gates the retry loop, so an
24 - /// [`Unsafe`](Self::Unsafe) operation physically cannot be auto-replayed.
25 - ///
26 - /// The variants split "safe to replay" into a **read** and a **write that the
27 - /// server dedups**, and the write variant must *name* what makes replay
28 - /// idempotent. That is the forcing function: a mutating create tagged
29 - /// auto-retryable without naming its dedup key does not compile, so the
30 - /// `ota_create_release`/`ota_register_artifact` class, a `CREATE` that silently
31 - /// relied on a server unique constraint nobody had written down, cannot recur
32 - /// unnoticed.
33 - #[derive(Clone, Copy, Debug)]
34 - pub(super) enum Idempotency {
35 - /// A read with no server-side effect (a `GET`, or a `POST` that only reads).
36 - /// Replay is trivially safe.
37 - ReadOnly,
38 - /// A write whose replay collapses to a single effect because the server
39 - /// dedups on `on`, a content-addressed key, an optimistic version, or a
40 - /// `UNIQUE` constraint. `on` documents *why* replay is harmless, at the call
41 - /// site, and naming it is mandatory.
42 - IdempotentWrite {
43 - /// The key/constraint the server dedups on (e.g. `"(app_id, version)
44 - /// unique"`). Documentation, surfaced in review and logs.
45 - on: &'static str,
46 - },
47 - /// Carries a client-generated idempotency key (e.g. push's `batch_id`) so
48 - /// the server collapses duplicate deliveries into one effect.
49 - Keyed,
50 - /// Not idempotent and not idempotency-keyed: replaying it would mint
51 - /// duplicate external state (e.g. a second Stripe Checkout session). The
52 - /// retry helpers attempt it exactly once.
53 - Unsafe,
54 - }
55 -
56 - impl Idempotency {
57 - /// Whether the retry helpers may replay this operation on a transient error.
58 - fn may_retry(self) -> bool {
59 - matches!(
60 - self,
61 - Idempotency::ReadOnly | Idempotency::IdempotentWrite { .. } | Idempotency::Keyed
62 - )
63 - }
64 -
65 - /// The documented dedup basis for an idempotent write (`None` for the other
66 - /// variants), surfaced in the retry log so a replayed request records *why*
67 - /// replay was safe.
68 - fn dedup_basis(self) -> Option<&'static str> {
69 - match self {
70 - Idempotency::IdempotentWrite { on } => Some(on),
71 - _ => None,
72 - }
73 - }
74 - }
75 -
76 - /// Upper bound on a control/JSON/error response body read into memory. Blob
77 - /// bodies stream through [`super::SyncKitClient::blob_download`]'s own 4 GiB cap;
78 - /// every *other* response, JSON control replies, OTA manifests, SSE-open errors,
79 - /// 4xx/5xx error bodies, is small, so a hostile or buggy server streaming a
80 - /// multi-gigabyte body into one of them is a pure OOM lever. 8 MiB is generous
81 - /// for any legitimate control body.
82 - pub(super) const MAX_CONTROL_BODY_BYTES: usize = 8 * 1024 * 1024;
83 -
84 - /// Read a response body into memory with a hard byte cap, the ONE sanctioned
85 - /// way to buffer a non-blob body.
86 - ///
87 - /// `reqwest`'s `Response::json`/`text`/`bytes` read the whole body with no size
88 - /// limit, so a hostile server can stream an arbitrarily large body and OOM the
89 - /// client (worst case: the public, unauthenticated OTA updater check). This
90 - /// drains `bytes_stream()` and aborts the instant the running total exceeds
91 - /// `limit`, and fast-rejects on an honest oversized `Content-Length` before
92 - /// reading a byte. Those raw reader methods are banned by `clippy.toml`
93 - /// (`disallowed-methods`) so a new call site cannot reintroduce an uncapped read.
94 - pub(super) async fn read_body_capped(
95 - resp: reqwest::Response,
96 - limit: usize,
97 - ) -> Result<bytes::Bytes> {
98 - use tokio_stream::StreamExt;
99 - if let Some(len) = resp.content_length()
100 - && len > limit as u64
101 - {
102 - return Err(SyncKitError::Internal(format!(
103 - "response Content-Length {len} exceeds {limit}-byte cap"
104 - )));
105 - }
106 - let mut stream = resp.bytes_stream();
107 - let mut buf = bytes::BytesMut::new();
108 - while let Some(chunk) = stream.next().await {
109 - let chunk = chunk.map_err(SyncKitError::Http)?;
110 - if buf.len() + chunk.len() > limit {
111 - return Err(SyncKitError::Internal(format!(
112 - "response body exceeds {limit}-byte cap"
113 - )));
114 - }
115 - buf.extend_from_slice(&chunk);
116 - }
117 - Ok(buf.freeze())
118 - }
119 -
120 - /// [`read_body_capped`] + JSON deserialize, the capped replacement for
121 - /// `resp.json::<T>()`.
122 - pub(super) async fn read_json_capped<T: serde::de::DeserializeOwned>(
123 - resp: reqwest::Response,
124 - limit: usize,
125 - ) -> Result<T> {
126 - let bytes = read_body_capped(resp, limit).await?;
127 - Ok(serde_json::from_slice(&bytes)?)
128 - }
129 -
130 - /// [`read_body_capped`] + lossy UTF-8, the capped replacement for `resp.text()`
131 - /// on error bodies (which are only logged, so lossy decoding and a `""` fallback
132 - /// on an oversized/failed read are fine).
133 - pub(super) async fn read_text_capped(resp: reqwest::Response, limit: usize) -> String {
134 - match read_body_capped(resp, limit).await {
135 - Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
136 - Err(_) => String::new(),
137 - }
138 - }
139 -
140 - /// Current on-wire HLC-envelope version, written into the `__skver` tag.
141 - const ENVELOPE_VERSION: u64 = 2;
142 -
143 - /// The on-wire HLC-envelope version, parsed from the `__skver` tag.
144 - ///
145 - /// Parsing is the explicit dispatch point: an unknown (future) version is a
146 - /// hard error, never a silent fallback. That closes the X2 hazard, a protocol
147 - /// bump that an older client cannot understand fails loudly instead of being
148 - /// mis-decoded as a bare row and corrupting the clock.
149 - #[derive(Clone, Copy, PartialEq, Eq, Debug)]
150 - enum WireVersion {
151 - /// The current envelope: `{ __skver: 2, __skhlc, data }`.
152 - V2,
153 - }
154 -
155 - impl WireVersion {
156 - fn parse(tag: u64) -> Result<Self> {
157 - match tag {
158 - 2 => Ok(WireVersion::V2),
159 - other => Err(SyncKitError::Crypto(format!(
160 - "unknown sync envelope version {other}; this client is too old to read it"
161 - ))),
162 - }
163 - }
164 - }
165 -
166 - impl SyncKitClient {
167 - /// Retry an async HTTP operation with exponential backoff.
168 - ///
169 - /// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`]
170 - /// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail.
171 - /// Client errors (4xx except 429) are considered permanent and returned immediately.
172 - ///
173 - /// `idempotency` is the caller's proof that replay is safe, see [`Idempotency`].
174 - pub(super) async fn retry_request<F, Fut>(
175 - &self,
176 - idempotency: Idempotency,
177 - mut operation: F,
178 - ) -> Result<reqwest::Response>
179 - where
180 - F: FnMut() -> Fut,
181 - Fut: std::future::Future<Output = Result<reqwest::Response>>,
182 - {
183 - // A non-idempotent operation gets exactly one attempt: zero retries.
184 - let max_attempts = if idempotency.may_retry() {
185 - MAX_RETRIES
186 - } else {
187 - 0
188 - };
189 - let mut last_err = None;
190 -
191 - for attempt in 0..=max_attempts {
192 - match operation().await {
193 - Ok(resp) => return Ok(resp),
194 - Err(err) => {
195 - if !is_transient(&err) {
196 - return Err(err);
197 - }
198 -
199 - if attempt < max_attempts {
200 - let delay = retry_delay(&err, attempt);
201 - tracing::debug!(
202 - attempt = attempt + 1,
203 - max_retries = MAX_RETRIES,
204 - delay_ms = delay.as_millis() as u64,
205 - error = %err,
206 - idempotency_basis = idempotency.dedup_basis(),
207 - "Transient error, retrying after backoff",
208 - );
209 - tokio::time::sleep(delay).await;
210 - }
211 -
212 - last_err = Some(err);
213 - }
214 - }
215 - }
216 -
217 - Err(last_err.expect("loop ran at least once"))
218 - }
219 -
220 - /// Retry an HTTP operation and deserialize the JSON response body inside
221 - /// the retry loop. This ensures a transient body-read failure (truncated
222 - /// response, connection reset mid-body) is retried rather than surfacing
223 - /// as a permanent error after the server already committed the operation.
224 - ///
225 - /// `idempotency` is the caller's proof that replay is safe, see [`Idempotency`].
226 - pub(super) async fn retry_request_json<F, Fut, T>(
227 - &self,
228 - idempotency: Idempotency,
229 - mut operation: F,
230 - ) -> Result<T>
231 - where
232 - F: FnMut() -> Fut,
233 - Fut: std::future::Future<Output = Result<reqwest::Response>>,
234 - T: serde::de::DeserializeOwned,
235 - {
236 - // A non-idempotent operation gets exactly one attempt: zero retries.
237 - let max_attempts = if idempotency.may_retry() {
238 - MAX_RETRIES
239 - } else {
240 - 0
241 - };
242 - let mut last_err = None;
243 -
244 - for attempt in 0..=max_attempts {
245 - match operation().await {
246 - Ok(resp) => match read_json_capped::<T>(resp, MAX_CONTROL_BODY_BYTES).await {
247 - Ok(parsed) => return Ok(parsed),
248 - Err(e) => {
249 - let err = e;
250 - if attempt < max_attempts {
251 - let delay = retry_delay(&err, attempt);
252 - tracing::debug!(
253 - attempt = attempt + 1,
254 - max_retries = MAX_RETRIES,
255 - delay_ms = delay.as_millis() as u64,
256 - error = %err,
257 - "Response body read failed, retrying",
258 - );
259 - tokio::time::sleep(delay).await;
260 - }
261 - last_err = Some(err);
262 - }
263 - },
264 - Err(err) => {
265 - if !is_transient(&err) {
266 - return Err(err);
267 - }
268 -
269 - if attempt < max_attempts {
270 - let delay = retry_delay(&err, attempt);
271 - tracing::debug!(
272 - attempt = attempt + 1,
273 - max_retries = max_attempts,
274 - delay_ms = delay.as_millis() as u64,
275 - error = %err,
276 - "Transient error, retrying after backoff",
277 - );
278 - tokio::time::sleep(delay).await;
279 - }
280 -
281 - last_err = Some(err);
282 - }
283 - }
284 - }
285 -
286 - Err(last_err.expect("loop ran at least once"))
287 - }
288 -
289 - /// Encrypt a change entry for the wire. Every change now seals an HLC
290 - /// envelope (Deletes included), so the master key is always required.
291 - #[cfg(test)]
292 - pub(super) fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> {
293 - let master_key = self.require_master_key()?;
294 - Self::encrypt_change_with_key(entry, &master_key)
295 - }
296 -
297 - /// Decrypt a pulled legacy entry that has no encrypted payload (a pre-HLC
298 - /// Delete). The HLC is synthesized from the entry's `client_timestamp`. No key
299 - /// needed.
300 - #[cfg(test)]
301 - pub(super) fn decrypt_change_no_data(entry: PullChangeEntry) -> Result<ChangeEntry> {
302 - debug_assert!(entry.data.is_none());
303 - Ok(ChangeEntry {
304 - table: entry.table,
305 - op: entry.op,
306 - row_id: entry.row_id,
307 - hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id),
308 - timestamp: entry.timestamp,
309 - data: None,
310 - extra: serde_json::Map::default(),
311 - })
312 - }
313 -
314 - /// Wrap a change's HLC and payload into one JSON envelope. Encrypting the
315 - /// envelope (rather than the bare row payload) is what carries the HLC inside
316 - /// the E2E ciphertext, including for Deletes, which have no row payload. The
317 - /// server stores the ciphertext opaquely and never sees the clock.
318 - ///
319 - /// The `__skver` tag is a positive, explicit version marker. A reader
320 - /// dispatches on it (see [`WireVersion`]) rather than structurally guessing,
321 - /// so a future format bump is rejected loudly instead of silently misread.
322 - fn hlc_envelope(hlc: &Hlc, data: Option<&serde_json::Value>) -> serde_json::Value {
323 - serde_json::json!({ "__skver": ENVELOPE_VERSION, "__skhlc": hlc, "data": data })
324 - }
325 -
326 - /// Split a decrypted payload back into `(hlc, data)`.
327 - ///
328 - /// Dispatch is explicit, not structural:
329 - /// - A `__skver` tag means a versioned envelope; the version is parsed via
330 - /// [`WireVersion::parse`], which **errors loudly** on an unknown future
331 - /// version rather than silently falling back to a bare-row read (which
332 - /// would corrupt the clock, the X2 hazard).
333 - /// - No `__skver` but an embedded `__skhlc` that parses is a gen-1 envelope
334 - /// (predates the version tag).
335 - /// - Anything else is a legacy bare-row payload whose HLC is synthesized
336 - /// from `node` + `timestamp_ms`.
337 - fn split_hlc_envelope(
338 - decrypted: serde_json::Value,
339 - node: crate::ids::DeviceId,
340 - timestamp_ms: i64,
341 - ) -> Result<(Hlc, Option<serde_json::Value>)> {
342 - if let Some(obj) = decrypted.as_object() {
343 - if let Some(tag) = obj.get("__skver") {
344 - // Explicit version present: dispatch, rejecting unknown loudly.
345 - let tag = tag.as_u64().ok_or_else(|| {
346 - SyncKitError::Crypto("envelope __skver tag is not an integer".into())
347 - })?;
348 - return match WireVersion::parse(tag)? {
349 - WireVersion::V2 => {
350 - let hlc = obj
351 - .get("__skhlc")
352 - .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
353 - .ok_or_else(|| {
354 - SyncKitError::Crypto("v2 envelope missing __skhlc".into())
355 - })?;
356 - let data = obj.get("data").cloned().filter(|v| !v.is_null());
357 - Ok((hlc, data))
358 - }
359 - };
360 - }
361 - // gen-1 envelope: embedded HLC, no version tag.
362 - if let Some(hlc) = obj
363 - .get("__skhlc")
364 - .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
365 - {
366 - let data = obj.get("data").cloned().filter(|v| !v.is_null());
367 - return Ok((hlc, data));
368 - }
369 - }
370 - // Legacy bare-row payload: the decrypted value is the row data itself.
371 - Ok((Hlc::from_legacy(timestamp_ms, node), Some(decrypted)))
372 - }
373 -
374 - /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock
375 - /// acquisition. Every change (including Deletes) is encrypted, because the HLC
376 - /// envelope always needs sealing. The ciphertext is bound to its
377 - /// `(table, row_id)` address via AEAD associated data, so a server cannot
378 - /// relocate it to another row/table without the open failing closed.
379 - pub(super) fn encrypt_change_with_key(
380 - entry: ChangeEntry,
381 - master_key: &[u8; 32],
382 - ) -> Result<WireChangeEntry> {
383 - let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
384 - let envelope = Self::hlc_envelope(&entry.hlc, entry.data.as_ref());
385 - let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &ctx)?);
386 -
387 - Ok(WireChangeEntry {
388 - table: entry.table,
389 - op: entry.op,
390 - row_id: entry.row_id,
391 - timestamp: entry.timestamp,
392 - data: encrypted_data,
393 - })
394 - }
395 -
396 - /// Decrypt the data field of a pulled change entry.
397 - #[cfg(test)]
398 - pub(super) fn decrypt_change(&self, entry: PullChangeEntry) -> Result<ChangeEntry> {
399 - if entry.data.is_some() {
400 - let master_key = self.require_master_key()?;
401 - Self::decrypt_change_with_key(entry, &master_key)
402 - } else {
403 - Self::decrypt_change_no_data(entry)
404 - }
405 - }
406 -
407 - /// Decrypt with a pre-loaded key, preserving `device_id` and `seq` in a [`PulledChange`].
408 - ///
409 - /// Used by `pull_rich()` to produce conflict-detection-ready results.
410 - pub(super) fn decrypt_change_to_pulled(
411 - entry: PullChangeEntry,
412 - master_key: &[u8; 32],
413 - ) -> Result<crate::types::PulledChange> {
414 - let device_id = entry.device_id;
415 - let seq = entry.seq;
416 - let decrypted = Self::decrypt_change_with_key(entry, master_key)?;
417 - Ok(crate::types::PulledChange {
418 - entry: decrypted,
419 - device_id,
420 - seq,
421 - })
422 - }
423 -
424 - /// Decrypt a pulled entry during a rotation window, selecting the key by the
425 - /// entry's `key_id`. Generic over the decrypt step so both the plain-pull
426 - /// (`ChangeEntry`) and rich-pull (`PulledChange`) paths share exactly this
427 - /// selection logic, including the unknown-`key_id` fallback that tries the
428 - /// primary key then the pending key. Previously the live pull path
429 - /// reimplemented a fallback-less variant while the tested one sat unused.
430 - pub(super) fn decrypt_with_rotation_keys<T, F>(
431 - entry: PullChangeEntry,
432 - primary_key: &[u8; 32],
433 - primary_key_id: i32,
434 - pending_key: &[u8; 32],
435 - pending_key_id: i32,
436 - decrypt_fn: &F,
437 - ) -> Result<T>
438 - where
439 - F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>,
440 - {
441 - let effective_key_id = entry.key_id.unwrap_or(1);
442 - if effective_key_id == pending_key_id {
443 - decrypt_fn(entry, pending_key)
444 - } else if effective_key_id == primary_key_id || effective_key_id <= 1 {
445 - decrypt_fn(entry, primary_key)
446 - } else {
447 - // Unknown key_id, try primary, then fall back to pending.
448 - match decrypt_fn(entry.clone(), primary_key) {
449 - Ok(result) => Ok(result),
450 - Err(_) => decrypt_fn(entry, pending_key),
451 - }
452 - }
453 - }
454 -
455 - /// Encrypt a change for a **group** changelog, sealed under the group's GCK
456 - /// and bound to `(group_id, table, row_id)` as AEAD associated data. Same HLC
457 - /// envelope as the personal path; the extra `group_id` binding makes a
458 - /// ciphertext non-relocatable across groups (the p1 crypto guarantee).
459 - pub(super) fn encrypt_group_change_with_key(
460 - group_id: &str,
461 - entry: ChangeEntry,
462 - gck: &[u8; 32],
463 - ) -> Result<WireChangeEntry> {
464 - let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
465 - let envelope = Self::hlc_envelope(&entry.hlc, entry.data.as_ref());
466 - let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, gck, &ctx)?);
467 -
468 - Ok(WireChangeEntry {
469 - table: entry.table,
470 - op: entry.op,
471 - row_id: entry.row_id,
472 - timestamp: entry.timestamp,
473 - data: encrypted_data,
474 - })
475 - }
476 -
477 - /// Decrypt a pulled group entry under the group's GCK, preserving `device_id`
478 - /// and `seq` in a [`PulledChange`]. The counterpart to
479 - /// [`encrypt_group_change_with_key`](Self::encrypt_group_change_with_key).
480 - /// Group pull has no master-key-rotation window (a group's key rotates
481 - /// server-side, re-encrypted in place), so this is the whole decrypt path.
482 - pub(super) fn decrypt_group_change_to_pulled(
483 - group_id: &str,
484 - entry: PullChangeEntry,
485 - gck: &[u8; 32],
486 - ) -> Result<crate::types::PulledChange> {
487 - let device_id = entry.device_id;
488 - let seq = entry.seq;
489 - let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
490 - let (hlc, data) = match entry.data {
491 - Some(ref value) => {
492 - let decrypted = crypto::decrypt_json_aad(value, gck, &ctx)?;
493 - Self::split_hlc_envelope(decrypted, device_id, entry.timestamp.timestamp_millis())?
494 - }
495 - None => (
496 - Hlc::from_legacy(entry.timestamp.timestamp_millis(), device_id),
497 - None,
498 - ),
499 - };
500 - Ok(crate::types::PulledChange {
Lines truncated
@@ -1,889 +0,0 @@
1 - //! HTTP transport and high-level API with transparent end-to-end encryption.
2 - //!
3 - //! This module provides [`SyncKitClient`], the primary interface to the MNW
4 - //! SyncKit server. All encryption and decryption happens transparently inside
5 - //! the client, callers work with plaintext [`ChangeEntry`] values and never
6 - //! handle ciphertext directly.
7 - //!
8 - //! ## Method groups
9 - //!
10 - //! - **Authentication**: [`authenticate`](SyncKitClient::authenticate) (email/password),
11 - //! [`authenticate_with_code`](SyncKitClient::authenticate_with_code) (OAuth2 PKCE),
12 - //! [`restore_session`](SyncKitClient::restore_session), [`clear_session`](SyncKitClient::clear_session).
13 - //! - **Encryption setup**: [`setup_encryption_new`](SyncKitClient::setup_encryption_new) (first device),
14 - //! [`setup_encryption_existing`](SyncKitClient::setup_encryption_existing) (subsequent devices),
15 - //! [`try_load_key_from_keychain`](SyncKitClient::try_load_key_from_keychain),
16 - //! [`change_password`](SyncKitClient::change_password).
17 - //! - **Device management**: [`register_device`](SyncKitClient::register_device),
18 - //! [`list_devices`](SyncKitClient::list_devices).
19 - //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull),
20 - //! [`status`](SyncKitClient::status).
21 - //! - **Blob storage**: [`blob_upload_streaming`](SyncKitClient::blob_upload_streaming)
22 - //! (file-backed, bounded memory, the path for large blobs),
23 - //! [`blob_upload_url`](SyncKitClient::blob_upload_url) +
24 - //! [`blob_upload`](SyncKitClient::blob_upload) (in-memory),
25 - //! [`blob_confirm`](SyncKitClient::blob_confirm),
26 - //! [`blob_download_url`](SyncKitClient::blob_download_url),
27 - //! [`blob_download`](SyncKitClient::blob_download).
28 - //!
29 - //! ## Internal state
30 - //!
31 - //! The client holds two `RwLock`-wrapped fields: the authenticated session
32 - //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both
33 - //! start as `None` and are populated by the authentication and encryption
34 - //! setup methods respectively.
35 - //!
36 - //! ## Thread safety
37 - //!
38 - //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public
39 - //! methods take `&self`, acquiring the internal locks only briefly to read
40 - //! or update state. The locks are never held across `.await` points.
41 - //!
42 - //! ## Retry strategy
43 - //!
44 - //! All HTTP operations retry transient failures (network errors, 5xx,
45 - //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors
46 - //! (4xx except 429) are permanent and returned immediately.
47 - //!
48 - //! ## Token handling
49 - //!
50 - //! The client decodes the JWT `exp` claim (without signature verification)
51 - //! and applies a 30-second expiry buffer. If the token is about to expire,
52 - //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller
53 - //! can re-authenticate before the request fails on the server.
54 -
55 - mod auth;
56 - mod blob;
57 - mod groups;
58 - pub use blob::BlobUploadOutcome;
59 - mod encryption;
60 - pub(crate) mod helpers;
61 - mod ota;
62 - mod rotation;
63 - mod subscribe;
64 - pub mod subscription;
65 - mod sync;
66 -
67 - pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease};
68 - pub use subscribe::SyncNotifyStream;
69 -
70 - use parking_lot::RwLock;
71 - use reqwest::Client;
72 - use std::sync::Arc;
73 - use std::time::Duration;
74 - #[cfg(test)]
75 - use uuid::Uuid;
76 -
77 - use crate::{
78 - crypto,
79 - error::{Result, SyncKitError},
80 - ids::{AppId, GroupId, UserId},
81 - };
82 -
83 - /// Maximum number of retry attempts for transient failures.
84 - const MAX_RETRIES: u32 = 3;
85 -
86 - /// Base delay for exponential backoff (1s, 2s, 4s).
87 - const BASE_DELAY: Duration = Duration::from_secs(1);
88 -
89 - /// Seconds before actual expiry to consider the token expired.
90 - /// Avoids sending a request with a token that expires mid-flight.
91 - const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
92 -
93 - /// Inactivity (read) timeout for the streaming HTTP client. Bounds the gap
94 - /// between successive body reads without capping the total transfer, so a stalled
95 - /// (slow-loris) blob/OTA/SSE connection is torn down while a legitimately long,
96 - /// steady transfer is not. Kept above the server's 30s SSE keepalive interval so
97 - /// an idle notification stream is never mistaken for a stall.
98 - const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(90);
99 -
100 - /// Configuration for the SyncKit client.
101 - #[derive(Debug, Clone)]
102 - pub struct SyncKitConfig {
103 - /// Base URL of the MNW server (e.g. "https://makenot.work").
104 - pub server_url: String,
105 - /// App API key (obtained from MNW dashboard).
106 - pub api_key: String,
107 - }
108 -
109 - /// Pre-built endpoint URLs, computed once at client construction.
110 - struct Endpoints {
111 - auth: String,
112 - oauth_token: String,
113 - devices: String,
114 - push: String,
115 - pull: String,
116 - subscribe: String,
117 - status: String,
118 - keys: String,
119 - groups_base: String,
120 - blobs_upload: String,
121 - blobs_confirm: String,
122 - blobs_download: String,
123 - blobs_multipart_start: String,
124 - blobs_multipart_parts: String,
125 - blobs_multipart_complete: String,
126 - blobs_multipart_abort: String,
127 - subscription: String,
128 - subscription_checkout: String,
129 - subscription_quote: String,
130 - subscription_storage_cap: String,
131 - app_pricing: String,
132 - account: String,
133 - /// Base for OTA paths (`{server}/api/v1/sync/ota`). The app-scoped and public
134 - /// OTA URLs carry runtime ids (app, release, slug/target/arch/version) so they
135 - /// cannot be pre-built like the static endpoints above; the `ota_*` builder
136 - /// methods construct them from this base, keeping *all* path construction in
137 - /// this one type instead of re-deriving it with `format!` in `ota.rs`.
138 - ota_base: String,
139 - }
140 -
141 - impl Endpoints {
142 - fn new(base: &str) -> Self {
143 - let base = base.trim_end_matches('/');
144 - Self {
145 - auth: format!("{base}/api/v1/sync/auth"),
146 - oauth_token: format!("{base}/oauth/token"),
147 - devices: format!("{base}/api/v1/sync/devices"),
148 - push: format!("{base}/api/v1/sync/push"),
149 - pull: format!("{base}/api/v1/sync/pull"),
150 - subscribe: format!("{base}/api/v1/sync/subscribe"),
151 - status: format!("{base}/api/v1/sync/status"),
152 - keys: format!("{base}/api/v1/sync/keys"),
153 - blobs_upload: format!("{base}/api/v1/sync/blobs/upload"),
154 - blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"),
155 - blobs_download: format!("{base}/api/v1/sync/blobs/download"),
156 - blobs_multipart_start: format!("{base}/api/v1/sync/blobs/multipart/start"),
157 - blobs_multipart_parts: format!("{base}/api/v1/sync/blobs/multipart/parts"),
158 - blobs_multipart_complete: format!("{base}/api/v1/sync/blobs/multipart/complete"),
159 - blobs_multipart_abort: format!("{base}/api/v1/sync/blobs/multipart/abort"),
160 - subscription: format!("{base}/api/v1/sync/subscription"),
161 - subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"),
162 - subscription_quote: format!("{base}/api/v1/sync/subscription/quote"),
163 - subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"),
164 - app_pricing: format!("{base}/api/v1/sync/app/pricing"),
165 - account: format!("{base}/api/v1/sync/account"),
166 - ota_base: format!("{base}/api/v1/sync/ota"),
167 - groups_base: format!("{base}/api/v1/sync/groups"),
168 - }
169 - }
170 -
171 - /// `GET`/`POST` the group collection: list the caller's groups, or create one.
172 - fn groups(&self) -> &str {
173 - &self.groups_base
174 - }
175 -
176 - /// `GET` here for the caller's own sealed GCK grant for a group.
177 - fn group_grant(&self, group_id: GroupId) -> String {
178 - format!("{}/{group_id}/grant", self.groups_base)
179 - }
180 -
181 - /// `POST` here to add a member to a group (admin only).
182 - fn group_members(&self, group_id: GroupId) -> String {
183 - format!("{}/{group_id}/members", self.groups_base)
184 - }
185 -
186 - /// `DELETE` here to remove a member from a group (admin only).
187 - fn group_member(&self, group_id: GroupId, member: UserId) -> String {
188 - format!("{}/{group_id}/members/{member}", self.groups_base)
189 - }
190 -
191 - /// `POST` encrypted changes to a group's shared changelog.
192 - fn group_push(&self, group_id: GroupId) -> String {
193 - format!("{}/{group_id}/push", self.groups_base)
194 - }
195 -
196 - /// `POST` to pull a group's changes since a cursor.
197 - fn group_pull(&self, group_id: GroupId) -> String {
198 - format!("{}/{group_id}/pull", self.groups_base)
199 - }
200 -
201 - /// `POST` here to create a release, or list releases, for an app.
202 - fn ota_releases(&self, app_id: AppId) -> String {
203 - format!("{}/apps/{app_id}/releases", self.ota_base)
204 - }
205 -
206 - /// `POST` here to register an artifact under a release.
207 - fn ota_artifacts(&self, app_id: AppId, release_id: uuid::Uuid) -> String {
208 - format!(
209 - "{}/apps/{app_id}/releases/{release_id}/artifacts",
210 - self.ota_base
211 - )
212 - }
213 -
214 - /// `POST` here to confirm an uploaded artifact under a release.
215 - fn ota_confirm(&self, app_id: AppId, release_id: uuid::Uuid) -> String {
216 - format!(
217 - "{}/apps/{app_id}/releases/{release_id}/artifacts/confirm",
218 - self.ota_base
219 - )
220 - }
221 -
222 - /// The public, unauthenticated Tauri updater check URL.
223 - fn ota_updater(&self, slug: &str, target: &str, arch: &str, current_version: &str) -> String {
224 - format!("{}/{slug}/{target}/{arch}/{current_version}", self.ota_base)
225 - }
226 - }
227 -
228 - /// A bearer token whose bytes are scrubbed from memory when the last reference
229 - /// is dropped.
230 - ///
231 - /// The SDK holds the session's JWT for the lifetime of the session; wrapping it
232 - /// so its final drop zeroizes the heap keeps the credential from lingering after
233 - /// logout or session replacement. [`Deref`](std::ops::Deref) to `str` and
234 - /// [`Display`](std::fmt::Display) expose the raw value only where the HTTP layer
235 - /// needs it (the `Authorization: Bearer` header); [`Debug`](std::fmt::Debug) is
236 - /// redacted so the token cannot leak into a log line.
237 - pub struct SecretToken(String);
238 -
239 - impl SecretToken {
240 - pub(crate) fn new(token: String) -> Self {
241 - SecretToken(token)
242 - }
243 -
244 - /// The raw token string, for building the `Authorization` header.
245 - pub fn as_str(&self) -> &str {
246 - &self.0
247 - }
248 - }
249 -
250 - impl std::ops::Deref for SecretToken {
251 - type Target = str;
252 - fn deref(&self) -> &str {
253 - &self.0
254 - }
255 - }
256 -
257 - impl std::fmt::Display for SecretToken {
258 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 - f.write_str(&self.0)
260 - }
261 - }
262 -
263 - impl std::fmt::Debug for SecretToken {
264 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 - f.write_str("SecretToken(<redacted>)")
266 - }
267 - }
268 -
269 - impl Drop for SecretToken {
270 - fn drop(&mut self) {
271 - use zeroize::Zeroize;
272 - self.0.zeroize();
273 - }
274 - }
275 -
276 - /// Session state obtained after authentication.
277 - struct Session {
278 - token: Arc<SecretToken>,
279 - /// Cached `exp` claim from the JWT, extracted once at session creation.
280 - token_exp: Option<i64>,
281 - user_id: UserId,
282 - app_id: AppId,
283 - }
284 -
285 - /// Public session info returned by `session_info()`.
286 - pub struct SessionInfo {
287 - /// The JWT bearer token for API requests (shared ref-counted to avoid
288 - /// cloning; zeroized when the last reference drops).
289 - pub token: Arc<SecretToken>,
290 - /// The authenticated user's UUID.
291 - pub user_id: UserId,
292 - /// The SyncKit app UUID this session belongs to.
293 - pub app_id: AppId,
294 - }
295 -
296 - /// Info about a pending key rotation, cached from `GET /keys`.
297 - pub(crate) struct PendingKeyState {
298 - pub key: crypto::ZeroizeOnDrop,
299 - pub key_id: i32,
300 - }
301 -
302 - /// The SyncKit client. Handles authentication, encryption, and HTTP transport.
303 - pub struct SyncKitClient {
304 - config: SyncKitConfig,
305 - /// HTTP client for small JSON control-plane calls. Carries a whole-request
306 - /// timeout, those calls should never run long.
307 - http: Client,
308 - /// HTTP client for large-body and long-lived transfers (blob up/download,
309 - /// OTA artifacts, the SSE notification stream). Deliberately has NO
310 - /// whole-request timeout, a multi-gigabyte blob or an open push stream must
311 - /// not be torn down by a fixed clock, only a connect timeout.
312 - http_stream: Client,
313 - endpoints: Endpoints,
314 - session: RwLock<Option<Session>>,
315 - master_key: RwLock<Option<crypto::ZeroizeOnDrop>>,
316 - /// The key_id associated with the current master_key. Default 1 (pre-rotation).
317 - master_key_id: RwLock<i32>,
318 - /// Pending rotation key, if a rotation is in progress.
319 - pending_key: RwLock<Option<PendingKeyState>>,
320 - /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version,
321 - /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key)
322 - /// from the sealed grant; a version mismatch (rotation) or a decrypt failure
323 - /// forces a re-fetch. Holds one entry per group, the current generation.
324 - gck_cache: RwLock<std::collections::HashMap<GroupId, (i32, crypto::ZeroizeOnDrop)>>,
325 - }
326 -
327 - impl SyncKitClient {
328 - /// Create a new client with the given configuration.
329 - pub fn new(config: SyncKitConfig) -> Self {
330 - // reqwest is built with `rustls-no-provider`; a real consumer app installs the
331 - // process-wide crypto provider (audiofiles installs ring) before it ever builds
332 - // a client. Unit tests have no such app, so install ring here once, or the
333 - // client build below would panic. Never compiled into a consumer build.
334 - #[cfg(test)]
335 - {
336 - static PROVIDER: std::sync::Once = std::sync::Once::new();
337 - PROVIDER.call_once(|| {
338 - let _ = rustls::crypto::ring::default_provider().install_default();
339 - });
340 - }
341 -
342 - let https_only = requires_https(&config.server_url);
343 - let http = Client::builder()
344 - .timeout(Duration::from_secs(30))
345 - .connect_timeout(Duration::from_secs(10))
346 - .pool_max_idle_per_host(5)
347 - .pool_idle_timeout(Duration::from_secs(90))
348 - .https_only(https_only)
349 - .build()
350 - .expect("failed to build HTTP client");
351 -
352 - // No whole-request timeout: this client carries multi-gigabyte blobs and
353 - // the long-lived SSE stream, which a fixed 30s cap would break. It DOES
354 - // carry a read (inactivity) timeout: without one, a server that dribbles
355 - // one byte an hour holds a blob/OTA/SSE connection open forever (slow-
356 - // loris). The cap bounds the gap *between* reads, not the total transfer,
357 - // so a legitimate slow-but-steady transfer is unaffected. 90s sits well
358 - // above the server's 30s SSE keepalive, so an idle notification stream
359 - // (which receives a keepalive comment every 30s) is never torn down.
360 - let http_stream = Client::builder()
361 - .connect_timeout(Duration::from_secs(10))
362 - .read_timeout(STREAM_READ_TIMEOUT)
363 - .pool_max_idle_per_host(5)
364 - .pool_idle_timeout(Duration::from_secs(90))
365 - .https_only(https_only)
366 - .build()
367 - .expect("failed to build streaming HTTP client");
368 -
369 - let endpoints = Endpoints::new(&config.server_url);
370 - Self {
371 - config,
372 - http,
373 - http_stream,
374 - endpoints,
375 - session: RwLock::new(None),
376 - master_key: RwLock::new(None),
377 - master_key_id: RwLock::new(1),
378 - pending_key: RwLock::new(None),
379 - gck_cache: RwLock::new(std::collections::HashMap::new()),
380 - }
381 - }
382 -
383 - /// Create a new client with a custom HTTP client (for testing with custom timeouts).
384 - ///
385 - /// Test-only: gated behind the `testing` feature so consumer builds cannot
386 - /// substitute an unvalidated HTTP client.
387 - #[doc(hidden)]
388 - #[cfg(any(test, feature = "testing"))]
389 - pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self {
390 - let endpoints = Endpoints::new(&config.server_url);
391 - Self {
392 - config,
393 - http: http.clone(),
394 - http_stream: http,
395 - endpoints,
396 - session: RwLock::new(None),
397 - master_key: RwLock::new(None),
398 - master_key_id: RwLock::new(1),
399 - pending_key: RwLock::new(None),
400 - gck_cache: RwLock::new(std::collections::HashMap::new()),
401 - }
402 - }
403 -
404 - /// Returns the client configuration.
405 - pub fn config(&self) -> &SyncKitConfig {
406 - &self.config
407 - }
408 -
409 - /// Returns whether the master encryption key is loaded and ready.
410 - pub fn has_master_key(&self) -> bool {
411 - self.master_key.read().is_some()
412 - }
413 -
414 - /// Returns the current session info, if authenticated.
415 - pub fn session_info(&self) -> Option<SessionInfo> {
416 - let guard = self.session.read();
417 - guard.as_ref().map(|s| SessionInfo {
418 - token: Arc::clone(&s.token),
419 - user_id: s.user_id,
420 - app_id: s.app_id,
421 - })
422 - }
423 -
424 - /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
425 - ///
426 - /// Test-only: gated behind the `testing` feature so a consumer build has no
427 - /// chosen-key injection point that bypasses key derivation.
428 - #[doc(hidden)]
429 - #[cfg(any(test, feature = "testing"))]
430 - pub fn set_master_key_raw(&self, key: [u8; 32]) {
431 - *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
432 - }
433 -
434 - // ── Internal helpers ──
435 -
436 - /// Extract the bearer token from the current session.
437 - ///
438 - /// Returns `NotAuthenticated` if no session exists. Also checks token
439 - /// expiry and returns `TokenExpired` if the JWT `exp` claim is within
440 - /// 30 seconds of the current time.
441 - pub(crate) fn require_token(&self) -> Result<Arc<SecretToken>> {
442 - let guard = self.session.read();
443 - let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
444 -
445 - if let Some(exp) = session.token_exp {
446 - let now = chrono::Utc::now().timestamp();
447 - if now >= exp - TOKEN_EXPIRY_BUFFER_SECS {
448 - return Err(SyncKitError::TokenExpired);
449 - }
450 - }
451 -
452 - Ok(Arc::clone(&session.token))
453 - }
454 -
455 - /// Extract `(app_id, user_id)` from the current session.
456 - ///
457 - /// Returns `NotAuthenticated` if no session exists.
458 - pub(crate) fn require_session_ids(&self) -> Result<(AppId, UserId)> {
459 - let guard = self.session.read();
460 - guard
461 - .as_ref()
462 - .map(|s| (s.app_id, s.user_id))
463 - .ok_or(SyncKitError::NotAuthenticated)
464 - }
465 -
466 - /// Return a copy of the 256-bit master encryption key, wrapped in
467 - /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`.
468 - ///
469 - /// Returns `NoMasterKey` if encryption has not been set up yet.
470 - pub(crate) fn require_master_key(&self) -> Result<crypto::ZeroizeOnDrop> {
471 - let guard = self.master_key.read();
472 - guard
473 - .as_ref()
474 - .map(|k| crypto::ZeroizeOnDrop(**k))
475 - .ok_or(SyncKitError::NoMasterKey)
476 - }
477 - }
478 -
479 - /// Whether the HTTP client for `server_url` must enforce TLS (`https_only`).
480 - ///
481 - /// Production traffic carries the bearer sync token, so plaintext `http` to any
482 - /// non-loopback host is refused (defense-in-depth on top of normal cert
483 - /// validation). Loopback hosts stay exempt so local development and the
484 - /// mock-server test suite can use `http://127.0.0.1`.
485 - fn requires_https(server_url: &str) -> bool {
486 - let lower = server_url.trim().to_ascii_lowercase();
487 - let Some(rest) = lower.strip_prefix("http://") else {
488 - // https (or any non-plaintext scheme): enforcing https_only is a no-op.
489 - return true;
490 - };
491 - // Extract the host *exactly*. A prefix match (`starts_with("127.")`,
492 - // `starts_with("localhost")`) trusts `http://127.0.0.1.attacker.com` and
493 - // `http://localhost.evil.com`, hostnames that resolve to an attacker's IP,
494 - // and would send the bearer token to them in cleartext. Parse the authority,
495 - // strip the port, and require an exact `localhost` or an IP literal that is
496 - // genuinely loopback. `0.0.0.0` is the unspecified/bind-all address, not a
497 - // loopback you connect *to*, so `is_loopback()` correctly rejects it.
498 - let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
499 - // Strip userinfo: everything up to the last `@` is credentials, not the host.
500 - // `http://127.0.0.1:80@evil.com` connects to evil.com, so a naive port-split
Lines truncated
@@ -1,315 +0,0 @@
1 - //! OTA (Over-The-Air) release publishing.
2 - //!
3 - //! These methods drive the app-owner side of the MNW OTA system: create a
4 - //! release, register an artifact (which returns a presigned S3 PUT URL), upload
5 - //! the bytes, and verify the public Tauri updater endpoint now serves it. They
6 - //! reuse the authenticated [`SyncKitClient`] session (JWT + app id), so a
7 - //! publisher authenticates once with [`authenticate`](SyncKitClient::authenticate)
8 - //! and then calls these in sequence.
9 - //!
10 - //! Unlike blobs, OTA artifacts are NOT end-to-end encrypted, they are public
11 - //! downloads served to every installed app, so the bytes are uploaded as-is.
12 - //!
13 - //! Server contract: `server/src/routes/ota.rs`. The Tauri updater manifest
14 - //! ([`OtaManifest`]) field names are load-bearing, Tauri's updater plugin
15 - //! deserializes exactly these five fields.
16 -
17 - use bytes::Bytes;
18 - use serde::{Deserialize, Serialize};
19 - use tracing::instrument;
20 - use uuid::Uuid;
21 -
22 - use super::SyncKitClient;
23 - use super::helpers::{Idempotency, check_response};
24 - use crate::error::Result;
25 -
26 - /// A created OTA release (subset of the server's release response).
27 - #[derive(Debug, Clone, Deserialize)]
28 - pub struct OtaRelease {
29 - /// Release id, used when registering artifacts.
30 - pub id: Uuid,
31 - pub version: String,
32 - pub notes: String,
33 - }
34 -
35 - /// A presigned upload target for an OTA artifact.
36 - #[derive(Debug, Clone, Deserialize)]
37 - pub struct OtaArtifactUpload {
38 - /// Presigned S3 PUT URL, upload the artifact bytes here.
39 - pub upload_url: String,
40 - /// The S3 object key the artifact will live at.
41 - pub s3_key: String,
42 - }
43 -
44 - /// The Tauri-compatible updater manifest returned by the public updater check.
45 - ///
46 - /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's
47 - /// updater plugin reads these five and nothing else.
48 - #[derive(Debug, Clone, Deserialize)]
49 - pub struct OtaManifest {
50 - pub version: String,
51 - pub url: String,
52 - pub signature: String,
53 - pub notes: String,
54 - pub pub_date: String,
55 - }
56 -
57 - #[derive(Serialize)]
58 - struct CreateReleaseBody<'a> {
59 - version: &'a str,
60 - notes: &'a str,
61 - }
62 -
63 - #[derive(Serialize)]
64 - struct RegisterArtifactBody<'a> {
65 - target: &'a str,
66 - arch: &'a str,
67 - file_size: i64,
68 - signature: &'a str,
69 - }
70 -
71 - impl SyncKitClient {
72 - /// Create a new OTA release for the authenticated app.
73 - ///
74 - /// The signature is per-artifact (Tauri signs each platform's file
75 - /// independently), so it is supplied to [`Self::ota_register_artifact`], not
76 - /// here.
77 - #[instrument(skip(self))]
78 - pub async fn ota_create_release(&self, version: &str, notes: &str) -> Result<OtaRelease> {
79 - let token = self.require_token()?;
80 - let (app_id, _user_id) = self.require_session_ids()?;
81 - let url = self.endpoints.ota_releases(app_id);
82 -
83 - let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes })?);
84 -
85 - self.retry_request_json(
86 - Idempotency::IdempotentWrite {
87 - on: "(app_id, version) unique, migration 033",
88 - },
89 - || {
90 - let req = self
91 - .http
92 - .post(&url)
93 - .bearer_auth(&token)
94 - .header("content-type", "application/json")
95 - .body(body.clone());
96 - async move { check_response(req.send().await?).await }
97 - },
98 - )
99 - .await
100 - }
101 -
102 - /// Register an artifact for a release and obtain a presigned upload URL.
103 - ///
104 - /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU
105 - /// (`x86_64`/`aarch64`), `file_size` is the artifact size in bytes, and
106 - /// `signature` is this file's minisign signature, Tauri's updater silently
107 - /// refuses an update whose signature is empty or belongs to another platform,
108 - /// so each artifact must carry its own.
109 - #[instrument(skip(self, signature))]
110 - pub async fn ota_register_artifact(
111 - &self,
112 - release_id: Uuid,
113 - target: &str,
114 - arch: &str,
115 - file_size: i64,
116 - signature: &str,
117 - ) -> Result<OtaArtifactUpload> {
118 - let token = self.require_token()?;
119 - let (app_id, _user_id) = self.require_session_ids()?;
120 - let url = self.endpoints.ota_artifacts(app_id, release_id);
121 -
122 - let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody {
123 - target,
124 - arch,
125 - file_size,
126 - signature,
127 - })?);
128 -
129 - self.retry_request_json(
130 - Idempotency::IdempotentWrite {
131 - on: "(release_id, target, arch) unique, migration 033",
132 - },
133 - || {
134 - let req = self
135 - .http
136 - .post(&url)
137 - .bearer_auth(&token)
138 - .header("content-type", "application/json")
139 - .body(body.clone());
140 - async move { check_response(req.send().await?).await }
141 - },
142 - )
143 - .await
144 - }
145 -
146 - /// Confirm an uploaded artifact: the server verifies the object landed and
147 - /// enqueues its malware scan. Until the scan clears, the artifact stays
148 - /// `pending` and is neither advertised nor downloadable. Call after
149 - /// [`Self::ota_upload_artifact`].
150 - #[instrument(skip(self))]
151 - pub async fn ota_confirm_artifact(
152 - &self,
153 - release_id: Uuid,
154 - target: &str,
155 - arch: &str,
156 - ) -> Result<()> {
157 - let token = self.require_token()?;
158 - let (app_id, _user_id) = self.require_session_ids()?;
159 - let url = self.endpoints.ota_confirm(app_id, release_id);
160 - let body = Bytes::from(serde_json::to_vec(&serde_json::json!({
161 - "target": target,
162 - "arch": arch,
163 - }))?);
164 -
165 - self.retry_request(
166 - Idempotency::IdempotentWrite {
167 - on: "(release_id, target, arch)",
168 - },
169 - || {
170 - let req = self
171 - .http
172 - .post(&url)
173 - .bearer_auth(&token)
174 - .header("content-type", "application/json")
175 - .body(body.clone());
176 - async move { check_response(req.send().await?).await }
177 - },
178 - )
179 - .await?;
180 - Ok(())
181 - }
182 -
183 - /// Upload artifact bytes to S3 via a presigned PUT URL.
184 - ///
185 - /// The bytes are sent as-is (no encryption, OTA artifacts are public).
186 - #[instrument(skip(self, presigned_url, data))]
187 - pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
188 - let data = Bytes::from(data);
189 - self.retry_request(
190 - Idempotency::IdempotentWrite {
191 - on: "S3 key (content-addressed)",
192 - },
193 - || {
194 - let req = self
195 - .http_stream
196 - .put(presigned_url)
197 - .header("content-type", "application/octet-stream")
198 - .body(data.clone());
199 - async move { check_response(req.send().await?).await }
200 - },
201 - )
202 - .await?;
203 - Ok(())
204 - }
205 -
206 - /// Check the public Tauri updater endpoint.
207 - ///
208 - /// Returns `Some(manifest)` when a newer version than `current_version` is
209 - /// available for `slug`/`target`/`arch`, or `None` when the client is up to
210 - /// date (HTTP 204). Use this to verify a freshly published release is live.
211 - #[instrument(skip(self))]
212 - pub async fn ota_updater_check(
213 - &self,
214 - slug: &str,
215 - target: &str,
216 - arch: &str,
217 - current_version: &str,
218 - ) -> Result<Option<OtaManifest>> {
219 - let url = self
220 - .endpoints
221 - .ota_updater(slug, target, arch, current_version);
222 -
223 - let resp = self
224 - .retry_request(Idempotency::ReadOnly, || {
225 - let req = self.http_stream.get(&url);
226 - async move { check_response(req.send().await?).await }
227 - })
228 - .await?;
229 -
230 - if resp.status() == reqwest::StatusCode::NO_CONTENT {
231 - return Ok(None);
232 - }
233 -
234 - Ok(Some(
235 - crate::client::helpers::read_json_capped::<OtaManifest>(
236 - resp,
237 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
238 - )
239 - .await?,
240 - ))
241 - }
242 - }
243 -
244 - #[cfg(test)]
245 - mod tests {
246 - use super::*;
247 -
248 - #[test]
249 - fn create_release_body_serializes_expected_fields() {
250 - let body = CreateReleaseBody {
251 - version: "0.4.1",
252 - notes: "Bug fixes",
253 - };
254 - let v: serde_json::Value = serde_json::to_value(&body).unwrap();
255 - assert_eq!(v["version"], "0.4.1");
256 - assert_eq!(v["notes"], "Bug fixes");
257 - // Signature is per-artifact now, not on the release.
258 - assert!(v.get("signature").is_none());
259 - }
260 -
261 - #[test]
262 - fn register_artifact_body_serializes_expected_fields() {
263 - let body = RegisterArtifactBody {
264 - target: "darwin",
265 - arch: "aarch64",
266 - file_size: 12_345,
267 - signature: "RWS...==",
268 - };
269 - let v: serde_json::Value = serde_json::to_value(&body).unwrap();
270 - assert_eq!(v["target"], "darwin");
271 - assert_eq!(v["arch"], "aarch64");
272 - assert_eq!(v["file_size"], 12_345);
273 - assert_eq!(v["signature"], "RWS...==");
274 - }
275 -
276 - #[test]
277 - fn release_response_deserializes_with_uuid_id() {
278 - let json = r#"{
279 - "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
280 - "version": "0.4.1",
281 - "notes": "",
282 - "pub_date": "2026-06-07T00:00:00Z",
283 - "created_at": "2026-06-07T00:00:00Z"
284 - }"#;
285 - let r: OtaRelease = serde_json::from_str(json).unwrap();
286 - assert_eq!(r.version, "0.4.1");
287 - assert_eq!(
288 - r.id,
289 - Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()
290 - );
291 - }
292 -
293 - #[test]
294 - fn artifact_upload_response_deserializes() {
295 - let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#;
296 - let u: OtaArtifactUpload = serde_json::from_str(json).unwrap();
297 - assert_eq!(u.upload_url, "https://s3.example/put?sig=abc");
298 - assert!(u.s3_key.ends_with("/artifact"));
299 - }
300 -
301 - #[test]
302 - fn manifest_deserializes_tauri_five_fields() {
303 - let json = r#"{
304 - "version": "0.4.1",
305 - "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64",
306 - "signature": "RWS=",
307 - "notes": "Bug fixes",
308 - "pub_date": "2026-06-07T00:00:00+00:00"
309 - }"#;
310 - let m: OtaManifest = serde_json::from_str(json).unwrap();
311 - assert_eq!(m.version, "0.4.1");
312 - assert!(m.url.contains("/download/"));
313 - assert_eq!(m.signature, "RWS=");
314 - }
315 - }
@@ -1,512 +0,0 @@
1 - //! Master-key rotation orchestration.
2 - //!
3 - //! Drives the multi-round server protocol that re-encrypts every device's key
4 - //! envelope under a new master key, completes the rotation, and sweeps up
5 - //! stragglers. The round count is bounded by `MAX_ROTATION_ROUNDS` so a
6 - //! misbehaving server cannot spin the loop indefinitely.
7 -
8 - use bytes::Bytes;
9 - use tracing::instrument;
10 - use uuid::Uuid;
11 -
12 - use crate::{
13 - crypto,
14 - error::Result,
15 - ids::DeviceId,
16 - types::{
17 - BeginRotationRequest, BeginRotationResponse, CompleteRotationRequest, PendingKeyInfo,
18 - RotationBatchEntry, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest,
19 - RotationEntriesResponse,
20 - },
21 - };
22 -
23 - use super::SyncKitClient;
24 - use super::helpers::{Idempotency, check_response};
25 -
26 - /// Hard ceiling on re-encrypt / straggler rounds within a single rotation. Each
27 - /// round re-encrypts one server batch and the set provably shrinks (re-encrypted
28 - /// entries drop out of the server's `key_id != new_key_id` filter), so a real
29 - /// rotation finishes in `total_entries / batch_size` rounds. The cap only fires
30 - /// when the set does NOT shrink, a stalled or hostile server that keeps handing
31 - /// back work, converting an unbounded spin into a bounded error. Generous enough
32 - /// to cover any real sync log at any batch size.
33 - const MAX_ROTATION_ROUNDS: u32 = 100_000;
34 -
35 - impl SyncKitClient {
36 - /// Rotate the master encryption key.
37 - ///
38 - /// Generates a new 256-bit key, re-encrypts all sync log entries in batches,
39 - /// and commits the new key to the server.
40 - ///
41 - /// Genuinely idempotent: if a previous call was interrupted, the server still
42 - /// holds the rotation's `pending_key`. This call detects that and **resumes
43 - /// with the already-committed key** rather than minting a second new key,
44 - /// the latter would orphan any entries already re-encrypted under the first
45 - /// key. Only when there is no pending rotation is a fresh key generated.
46 - ///
47 - /// Requires the encryption password (to wrap the new key) and a device_id
48 - /// (the device performing the rotation). Only one device can rotate at a time.
49 - ///
50 - /// After completion, all devices will receive the new key on their next
51 - /// `GET /keys` call. During rotation, both old and new keys are available
52 - /// so pulls continue to work.
53 - #[instrument(skip(self, password))]
54 - pub async fn rotate_key(&self, device_id: DeviceId, password: &str) -> Result<()> {
55 - // 1. Verify password against the still-active key, and learn whether a
56 - // rotation is already pending for this user.
57 - let key_state = self.get_server_key_full().await?;
58 - let key_version = key_state.key_version.unwrap_or(0);
59 - let old_key = crypto::verify_password_against_envelope(&key_state.encrypted_key, password)?;
60 - let old_key = crypto::ZeroizeOnDrop(old_key);
61 -
62 - // 2. Resume an interrupted rotation by adopting its committed pending key,
63 - // or start fresh. Either way `new_envelope` is the key the server will
64 - // promote on completion, so re-encryption uses the same key it commits.
65 - let (new_master_key, new_envelope) =
66 - Self::rotation_key_material(key_state.pending_key, password)?;
67 - // Wrap immediately so the new key is zeroized on every exit path, an
68 - // error in the re-encrypt or complete loops below would otherwise drop
69 - // the bare array without scrubbing it.
70 - let new_master_key = crypto::ZeroizeOnDrop(new_master_key);
71 -
72 - // 3. Begin (or resume) rotation. `begin_key_rotation` is idempotent for the
73 - // same device: it returns the existing rotation, ignoring `new_envelope`
74 - // on resume, which is why step 2 must adopt the committed key.
75 - let begin_resp = self
76 - .begin_rotation(device_id, &new_envelope, key_version)
77 - .await?;
78 - let rotation_id = begin_resp.rotation_id;
79 - let new_key_id = begin_resp.new_key_id;
80 -
81 - tracing::info!(
82 - rotation_id = %rotation_id,
83 - target_seq = begin_resp.target_seq,
84 - new_key_id = new_key_id,
85 - "Key rotation started",
86 - );
87 -
88 - // 4. Re-encrypt loop (bounded, see reencrypt_until_done).
89 - self.reencrypt_until_done(rotation_id, &old_key, &new_master_key)
90 - .await?;
91 -
92 - // 5. Complete, retry if stragglers arrived from concurrent pushes. Both
93 - // the straggler retries and each re-encrypt pass are capped so a server
94 - // that keeps returning 409 (a stall, or a hostile peer racing pushes)
95 - // cannot spin this loop forever.
96 - let mut straggler_rounds = 0u32;
97 - loop {
98 - match self.complete_rotation(rotation_id).await {
99 - Ok(()) => break,
100 - Err(crate::error::SyncKitError::Server { status: 409, .. }) => {
101 - straggler_rounds += 1;
102 - if straggler_rounds >= MAX_ROTATION_ROUNDS {
103 - return Err(crate::error::SyncKitError::Internal(
104 - "key rotation did not converge: server kept reporting stragglers past the round cap".into(),
105 - ));
106 - }
107 - tracing::debug!(
108 - round = straggler_rounds,
109 - "Stragglers detected, re-encrypting remaining entries"
110 - );
111 - self.reencrypt_until_done(rotation_id, &old_key, &new_master_key)
112 - .await?;
113 - }
114 - Err(e) => return Err(e),
115 - }
116 - }
117 -
118 - // 6. Update local state
119 - let (app_id, user_id) = self.require_session_ids()?;
120 - crate::keystore::store_key(app_id, user_id, &new_master_key)?;
121 - *self.master_key.write() = Some(new_master_key);
122 - *self.master_key_id.write() = new_key_id;
123 - *self.pending_key.write() = None;
124 -
125 - tracing::info!("Key rotation completed");
126 - Ok(())
127 - }
128 -
129 - /// Decide the rotation's new key material: adopt the server's committed
130 - /// `pending_key` when resuming an interrupted rotation, otherwise mint a
131 - /// fresh key. Returns `(new_master_key, new_envelope)` where the envelope is
132 - /// what the server promotes on completion, so the re-encryption key always
133 - /// matches the committed envelope. Pure (no IO) so it is unit-testable.
134 - fn rotation_key_material(
135 - pending: Option<PendingKeyInfo>,
136 - password: &str,
137 - ) -> Result<([u8; 32], String)> {
138 - match pending {
139 - Some(pending) => {
140 - let key = crypto::unwrap_master_key(&pending.encrypted_key, password)?;
141 - Ok((key, pending.encrypted_key))
142 - }
143 - None => {
144 - // Wrap before the fallible `wrap_master_key` so a wrap error
145 - // scrubs the fresh key instead of dropping a bare array.
146 - let key = crypto::ZeroizeOnDrop(crypto::generate_master_key());
147 - let envelope = crypto::wrap_master_key(&key.0, password)?;
148 - Ok((key.0, envelope))
149 - }
150 - }
151 - }
152 -
153 - /// Run [`reencrypt_batch`](Self::reencrypt_batch) until the server reports no
154 - /// entries left, bounded by [`MAX_ROTATION_ROUNDS`]. The batch set shrinks
155 - /// every round under an honest server; the cap is the backstop against a
156 - /// server that never reports it done, so the client fails with an error
157 - /// instead of spinning forever.
158 - async fn reencrypt_until_done(
159 - &self,
160 - rotation_id: Uuid,
161 - old_key: &[u8; 32],
162 - new_key: &[u8; 32],
163 - ) -> Result<()> {
164 - for _ in 0..MAX_ROTATION_ROUNDS {
165 - if self.reencrypt_batch(rotation_id, old_key, new_key).await? {
166 - return Ok(());
167 - }
168 - }
169 - Err(crate::error::SyncKitError::Internal(
170 - "key rotation did not converge: re-encrypt exceeded the round cap without the entry set draining".into(),
171 - ))
172 - }
173 -
174 - /// Pull a batch of entries needing re-encryption, re-encrypt them, and push back.
175 - /// Returns true if there are no more entries to process.
176 - async fn reencrypt_batch(
177 - &self,
178 - rotation_id: Uuid,
179 - old_key: &[u8; 32],
180 - new_key: &[u8; 32],
181 - ) -> Result<bool> {
182 - // Pull entries needing re-encryption (starting from seq 0 each time,
183 - // since the server filters by key_id != new_key_id)
184 - let entries_resp = self.rotation_entries(rotation_id, 0).await?;
185 -
186 - if entries_resp.entries.is_empty() {
187 - return Ok(true);
188 - }
189 -
190 - let has_more = entries_resp.has_more;
191 -
192 - // Re-encrypt each entry
193 - let reencrypted: Vec<RotationBatchEntry> = entries_resp
194 - .entries
195 - .into_iter()
196 - .map(|entry| {
197 - let new_data = match entry.data {
198 - Some(ref encrypted_value) => {
199 - // Decrypt with old key, re-encrypt with new key, binding the
200 - // same (table, row_id) AAD on both ends. A legacy untagged
201 - // entry decrypts with empty AAD and re-emits as a v2 tagged,
202 - // position-bound payload (opportunistic upgrade).
203 - let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
204 - let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &ctx)?;
205 - let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &ctx)?;
206 - Some(reencrypted)
207 - }
208 - None => None, // DELETE entries have no data
209 - };
210 - Ok(RotationBatchEntry {
211 - seq: entry.seq,
212 - data: new_data,
213 - })
214 - })
215 - .collect::<Result<Vec<_>>>()?;
216 -
217 - // Push re-encrypted batch
218 - let count = reencrypted.len();
219 - self.rotation_batch(rotation_id, reencrypted).await?;
220 -
221 - tracing::debug!(count, "Re-encrypted batch submitted");
222 -
223 - Ok(!has_more)
224 - }
225 -
226 - // ── HTTP helpers ──
227 -
228 - async fn begin_rotation(
229 - &self,
230 - device_id: DeviceId,
231 - new_encrypted_key: &str,
232 - expected_key_version: i32,
233 - ) -> Result<BeginRotationResponse> {
234 - let token = self.require_token()?;
235 - let url = format!(
236 - "{}/api/v1/sync/keys/rotate",
237 - self.config.server_url.trim_end_matches('/')
238 - );
239 -
240 - let body = Bytes::from(serde_json::to_vec(&BeginRotationRequest {
241 - device_id,
242 - new_encrypted_key: new_encrypted_key.to_string(),
243 - expected_key_version,
244 - })?);
245 -
246 - self.retry_request_json(
247 - Idempotency::IdempotentWrite {
248 - on: "one rotation per (app_id, user_id), migration 089",
249 - },
250 - || {
251 - let req = self
252 - .http
253 - .post(&url)
254 - .bearer_auth(&token)
255 - .header("content-type", "application/json")
256 - .body(body.clone());
257 - async move { check_response(req.send().await?).await }
258 - },
259 - )
260 - .await
261 - }
262 -
263 - async fn rotation_entries(
264 - &self,
265 - rotation_id: Uuid,
266 - after_seq: i64,
267 - ) -> Result<RotationEntriesResponse> {
268 - let token = self.require_token()?;
269 - let url = format!(
270 - "{}/api/v1/sync/keys/rotate/entries",
271 - self.config.server_url.trim_end_matches('/')
272 - );
273 -
274 - let body = Bytes::from(serde_json::to_vec(&RotationEntriesRequest {
275 - rotation_id,
276 - after_seq,
277 - })?);
278 -
279 - self.retry_request_json(Idempotency::ReadOnly, || {
280 - let req = self
281 - .http
282 - .post(&url)
283 - .bearer_auth(&token)
284 - .header("content-type", "application/json")
285 - .body(body.clone());
286 - async move { check_response(req.send().await?).await }
287 - })
288 - .await
289 - }
290 -
291 - async fn rotation_batch(
292 - &self,
293 - rotation_id: Uuid,
294 - entries: Vec<RotationBatchEntry>,
295 - ) -> Result<RotationBatchResponse> {
296 - let token = self.require_token()?;
297 - let url = format!(
298 - "{}/api/v1/sync/keys/rotate/batch",
299 - self.config.server_url.trim_end_matches('/')
300 - );
301 -
302 - let body = Bytes::from(serde_json::to_vec(&RotationBatchRequest {
303 - rotation_id,
304 - entries,
305 - })?);
306 -
307 - self.retry_request_json(
308 - Idempotency::IdempotentWrite {
309 - on: "seq (re-encrypt overwrites the same row)",
310 - },
311 - || {
312 - let req = self
313 - .http
314 - .post(&url)
315 - .bearer_auth(&token)
316 - .header("content-type", "application/json")
317 - .body(body.clone());
318 - async move { check_response(req.send().await?).await }
319 - },
320 - )
321 - .await
322 - }
323 -
324 - async fn complete_rotation(&self, rotation_id: Uuid) -> Result<()> {
325 - let token = self.require_token()?;
326 - let url = format!(
327 - "{}/api/v1/sync/keys/rotate/complete",
328 - self.config.server_url.trim_end_matches('/')
329 - );
330 -
331 - let body = Bytes::from(serde_json::to_vec(&CompleteRotationRequest {
332 - rotation_id,
333 - })?);
334 -
335 - self.retry_request(Idempotency::IdempotentWrite { on: "rotation_id" }, || {
336 - let req = self
337 - .http
338 - .post(&url)
339 - .bearer_auth(&token)
340 - .header("content-type", "application/json")
341 - .body(body.clone());
342 - async move { check_response(req.send().await?).await }
343 - })
344 - .await?;
345 -
346 - Ok(())
347 - }
348 - }
349 -
350 - #[cfg(test)]
351 - mod tests {
352 - use super::*;
353 - use crate::types::{GetKeyResponse, PullChangeEntry};
354 -
355 - #[test]
356 - fn rotation_resume_reuses_committed_pending_key() {
357 - // Simulate an interrupted rotation: the server holds a pending envelope
358 - // wrapping key K. Resuming must recover K (not mint a fresh key), or
359 - // entries already re-encrypted under K become undecryptable.
360 - let committed_key = crypto::generate_master_key();
361 - let pending_envelope = crypto::wrap_master_key(&committed_key, "pw").unwrap();
362 - let pending = Some(PendingKeyInfo {
363 - encrypted_key: pending_envelope.clone(),
364 - key_id: 2,
365 - });
366 -
367 - let (resumed_key, envelope) = SyncKitClient::rotation_key_material(pending, "pw").unwrap();
368 - assert_eq!(
369 - resumed_key, committed_key,
370 - "resume must adopt the committed key"
371 - );
372 - assert_eq!(
373 - envelope, pending_envelope,
374 - "resume must keep the committed envelope"
375 - );
376 - }
377 -
378 - #[test]
379 - fn rotation_fresh_start_mints_unwrappable_key() {
380 - // No pending rotation: a fresh key is generated and its envelope unwraps
381 - // back to that same key under the password.
382 - let (fresh_key, envelope) = SyncKitClient::rotation_key_material(None, "pw").unwrap();
383 - let recovered = crypto::unwrap_master_key(&envelope, "pw").unwrap();
384 - assert_eq!(recovered, fresh_key);
385 - }
386 -
387 - #[test]
388 - fn reencrypt_preserves_plaintext() {
389 - // Simulate re-encryption: encrypt with old key, decrypt, re-encrypt with new key
390 - let old_key = crypto::generate_master_key();
391 - let new_key = crypto::generate_master_key();
392 - let original = serde_json::json!({"title": "Test task", "priority": 3});
393 -
394 - // Encrypt with old key (simulates existing sync_log entry)
395 - let encrypted_old = crypto::encrypt_json(&original, &old_key).unwrap();
396 -
397 - // Re-encrypt: decrypt with old, encrypt with new
398 - let plaintext = crypto::decrypt_json(&encrypted_old, &old_key).unwrap();
399 - let encrypted_new = crypto::encrypt_json(&plaintext, &new_key).unwrap();
400 -
401 - // Verify: new key can decrypt to original data
402 - let recovered = crypto::decrypt_json(&encrypted_new, &new_key).unwrap();
403 - assert_eq!(recovered, original);
404 -
405 - // Verify: old key cannot decrypt re-encrypted data
406 - assert!(crypto::decrypt_json(&encrypted_new, &old_key).is_err());
407 - }
408 -
409 - #[test]
410 - fn reencrypt_null_data_stays_null() {
411 - // DELETE entries have no data, rotation should preserve this
412 - let old_key = crypto::generate_master_key();
413 - let new_key = crypto::generate_master_key();
414 -
415 - // No data to re-encrypt
416 - let data: Option<serde_json::Value> = None;
417 - let reencrypted = match data {
418 - Some(ref val) => {
419 - let plaintext = crypto::decrypt_json(val, &old_key).unwrap();
420 - Some(crypto::encrypt_json(&plaintext, &new_key).unwrap())
421 - }
422 - None => None,
423 - };
424 -
425 - assert!(reencrypted.is_none());
426 - }
427 -
428 - #[test]
429 - fn rotation_request_types_serialize() {
430 - let req = BeginRotationRequest {
431 - device_id: DeviceId::new(Uuid::new_v4()),
432 - new_encrypted_key: "envelope-json".to_string(),
433 - expected_key_version: 1,
434 - };
435 - let json = serde_json::to_string(&req).unwrap();
436 - assert!(json.contains("expected_key_version"));
437 -
438 - let req = RotationBatchRequest {
439 - rotation_id: Uuid::new_v4(),
440 - entries: vec![
441 - RotationBatchEntry {
442 - seq: 1,
443 - data: Some(serde_json::json!("encrypted")),
444 - },
445 - RotationBatchEntry { seq: 2, data: None },
446 - ],
447 - };
448 - let json = serde_json::to_string(&req).unwrap();
449 - assert!(json.contains("rotation_id"));
450 - assert!(json.contains("\"seq\":1"));
451 - }
452 -
453 - #[test]
454 - fn rotation_response_types_deserialize() {
455 - let json = r#"{"rotation_id": "550e8400-e29b-41d4-a716-446655440000", "target_seq": 100, "new_key_id": 2}"#;
456 - let resp: BeginRotationResponse = serde_json::from_str(json).unwrap();
457 - assert_eq!(resp.target_seq, 100);
458 - assert_eq!(resp.new_key_id, 2);
459 -
460 - let json = r#"{"entries": [{"seq": 1, "table": "tasks", "row_id": "r1", "data": "encrypted"}, {"seq": 2, "table": "tasks", "row_id": "r2", "data": null}], "has_more": true}"#;
461 - let resp: RotationEntriesResponse = serde_json::from_str(json).unwrap();
462 - assert_eq!(resp.entries.len(), 2);
463 - assert!(resp.has_more);
464 - assert!(resp.entries[0].data.is_some());
465 - assert_eq!(resp.entries[0].table, "tasks");
466 - assert_eq!(resp.entries[0].row_id, "r1");
467 - assert!(resp.entries[1].data.is_none());
468 - }
469 -
470 - #[test]
471 - fn pending_key_info_deserializes() {
472 - let json = r#"{
473 - "encrypted_key": "envelope",
474 - "key_version": 2,
475 - "key_id": 1,
476 - "pending_key": {"encrypted_key": "new-envelope", "key_id": 2}
477 - }"#;
478 - let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
479 - assert!(resp.pending_key.is_some());
480 - let pending = resp.pending_key.unwrap();
481 - assert_eq!(pending.key_id, 2);
482 - assert_eq!(pending.encrypted_key, "new-envelope");
483 - }
484 -
485 - #[test]
486 - fn get_key_response_without_pending_key() {
487 - let json = r#"{"encrypted_key": "envelope", "key_version": 1}"#;
488 - let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
489 - assert!(resp.pending_key.is_none());
490 - assert_eq!(resp.key_id, None); // backward compat
491 - }
492 -
493 - #[test]
494 - fn pull_change_entry_with_key_id() {
495 - let device_id = Uuid::new_v4();
496 - let json = format!(
497 - r#"{{"seq": 1, "device_id": "{device_id}", "table": "t", "op": "INSERT", "row_id": "r", "timestamp": "2025-06-01T12:00:00Z", "data": null, "key_id": 2}}"#
498 - );
499 - let entry: PullChangeEntry = serde_json::from_str(&json).unwrap();
500 - assert_eq!(entry.key_id, Some(2));
Lines truncated
@@ -1,316 +0,0 @@
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 - //! The stream auto-reconnects with backoff across transient drops; it only
6 - //! ends (`next_change` returns `None`) on a fatal condition, the server
7 - //! rejecting the reconnect for auth reasons, or a protocol violation.
8 -
9 - use std::sync::Arc;
10 - use std::time::Duration;
11 -
12 - use rand::RngExt;
13 -
14 - use crate::error::{Result, SyncKitError};
15 -
16 - use super::{SecretToken, SyncKitClient};
17 -
18 - /// A stream of SSE "changed" notifications from the SyncKit server.
19 - ///
20 - /// Created by [`SyncKitClient::subscribe`]. Wraps a raw HTTP response byte
21 - /// stream and parses SSE events line by line, yielding `Some(())` for each
22 - /// `event: changed`. When the underlying connection drops, it transparently
23 - /// reconnects (exponential backoff, capped, jittered) rather than ending,
24 - /// SSE `changed` events are contentless pokes, so a reconnect needs no cursor.
25 - /// `next_change` returns `None` only when reconnection is futile: the server
26 - /// rejects the request with `401`/`403` (the caller must re-authenticate), or
27 - /// the stream violates the protocol (invalid UTF-8, oversized block).
28 - pub struct SyncNotifyStream {
29 - buffer: String,
30 - /// `None` between a drop and the next successful reconnect.
31 - response: Option<reqwest::Response>,
32 - http: reqwest::Client,
33 - url: String,
34 - token: Arc<SecretToken>,
35 - reconnect_attempt: u32,
36 - }
37 -
38 - /// Maximum SSE buffer size (1 MB). If the server sends data without a
39 - /// block terminator (`\n\n`) exceeding this limit, we treat it as an error
40 - /// and close the stream to prevent unbounded memory growth.
41 - const MAX_SSE_BUFFER: usize = 1024 * 1024;
42 -
43 - /// Cap on the reconnect backoff delay.
44 - const MAX_RECONNECT_DELAY: Duration = Duration::from_mins(1);
45 -
46 - /// Maximum *consecutive* failed reconnect attempts before the stream gives up.
47 - /// A single success resets the counter, so this only fires when the server has
48 - /// been unreachable or erroring for a sustained stretch (with the 60s backoff
49 - /// cap, several minutes). Without it, a permanently-degraded non-auth server
50 - /// (persistent 500s, connection refused) is retried silently forever, the
51 - /// caller is better told the stream is dead so it can re-subscribe later.
52 - const MAX_RECONNECT_ATTEMPTS: u32 = 10;
53 -
54 - /// Reconnect backoff: 1s, 2s, 4s, ... capped at [`MAX_RECONNECT_DELAY`], with
55 - /// +/-20% jitter so a fleet of clients doesn't reconnect in lockstep.
56 - fn reconnect_delay(attempt: u32) -> Duration {
57 - let base = Duration::from_secs(1)
58 - .saturating_mul(2u32.saturating_pow(attempt.min(6)))
59 - .min(MAX_RECONNECT_DELAY);
60 - let millis = base.as_millis() as u64;
61 - let span = millis / 5;
62 - let delta = rand::rng().random_range(0..=2 * span.max(1));
63 - Duration::from_millis(millis.saturating_sub(span).saturating_add(delta))
64 - }
65 -
66 - impl SyncNotifyStream {
67 - pub(crate) fn new(
68 - http: reqwest::Client,
69 - url: String,
70 - token: Arc<SecretToken>,
71 - response: reqwest::Response,
72 - ) -> Self {
73 - Self {
74 - buffer: String::new(),
75 - response: Some(response),
76 - http,
77 - url,
78 - token,
79 - reconnect_attempt: 0,
80 - }
81 - }
82 -
83 - /// Re-establish the SSE connection after a drop. Loops with backoff on
84 - /// transient failures; returns `false` (fatal) if the server rejects the
85 - /// request for auth reasons (caller re-authenticates) or after
86 - /// [`MAX_RECONNECT_ATTEMPTS`] consecutive failures (caller re-subscribes
87 - /// later) rather than retrying a dead server forever.
88 - async fn reconnect(&mut self) -> bool {
89 - loop {
90 - self.reconnect_attempt += 1;
91 - if self.reconnect_attempt > MAX_RECONNECT_ATTEMPTS {
92 - tracing::warn!(
93 - attempts = self.reconnect_attempt - 1,
94 - "SSE reconnect gave up after too many consecutive failures; stream closing"
95 - );
96 - return false;
97 - }
98 - tokio::time::sleep(reconnect_delay(self.reconnect_attempt)).await;
99 -
100 - match self
101 - .http
102 - .get(&self.url)
103 - .bearer_auth(&*self.token)
104 - .send()
105 - .await
106 - {
107 - Ok(resp) => {
108 - let status = resp.status().as_u16();
109 - if status == 401 || status == 403 {
110 - tracing::warn!(status, "SSE reconnect rejected (auth); stream closing");
111 - return false;
112 - }
113 - if status >= 400 {
114 - tracing::debug!(
115 - status,
116 - attempt = self.reconnect_attempt,
117 - "SSE reconnect got error status; backing off"
118 - );
119 - continue;
120 - }
121 - tracing::debug!(attempt = self.reconnect_attempt, "SSE reconnected");
122 - self.reconnect_attempt = 0;
123 - self.buffer.clear();
124 - self.response = Some(resp);
125 - return true;
126 - }
127 - Err(e) => {
128 - tracing::debug!(
129 - error = %e,
130 - attempt = self.reconnect_attempt,
131 - "SSE reconnect failed; backing off"
132 - );
133 - }
134 - }
135 - }
136 - }
137 -
138 - /// Wait for the next "changed" notification.
139 - ///
140 - /// Transparently reconnects across transient stream drops. Returns `Some(())`
141 - /// when the server signals new changes. Returns `None` only on a fatal
142 - /// condition: the reconnect is rejected for auth, or the stream violates the
143 - /// SSE protocol (invalid UTF-8 / oversized block). Keepalive comments (lines
144 - /// starting with `:`) are silently ignored.
145 - pub async fn next_change(&mut self) -> Option<()> {
146 - loop {
147 - if self.response.is_none() && !self.reconnect().await {
148 - return None;
149 - }
150 -
151 - // Try to extract a complete SSE block from the buffer
152 - if let Some(pos) = self.buffer.find("\n\n") {
153 - let block = self.buffer[..pos].to_string();
154 - self.buffer = self.buffer[pos + 2..].to_string();
155 -
156 - if parse_sse_block_is_changed(&block) {
157 - return Some(());
158 - }
159 - // Not a "changed" event, skip and try next block
160 - continue;
161 - }
162 -
163 - // Need more data from the stream
164 - let chunk = self
165 - .response
166 - .as_mut()
167 - .expect("connected above")
168 - .chunk()
169 - .await;
170 - match chunk {
171 - Ok(Some(chunk)) => {
172 - match std::str::from_utf8(&chunk) {
173 - Ok(text) => self.buffer.push_str(text),
174 - Err(_) => {
175 - tracing::warn!("SSE stream contained invalid UTF-8, closing");
176 - return None;
177 - }
178 - }
179 - if self.buffer.len() > MAX_SSE_BUFFER {
180 - tracing::warn!(
181 - "SSE buffer exceeded {MAX_SSE_BUFFER} bytes, closing stream"
182 - );
183 - return None;
184 - }
185 - }
186 - // Stream ended or errored: drop it and reconnect on the next loop
187 - // instead of ending, this is the auto-reconnect.
188 - Ok(None) => {
189 - tracing::debug!("SSE stream ended; will reconnect");
190 - self.response = None;
191 - }
192 - Err(e) => {
193 - tracing::debug!(error = %e, "SSE stream error; will reconnect");
194 - self.response = None;
195 - }
196 - }
197 - }
198 - }
199 - }
200 -
201 - /// Parse an SSE block and return `true` if it contains `event: changed`.
202 - ///
203 - /// An SSE block is one or more lines separated by `\n`, terminated by `\n\n`.
204 - /// Lines starting with `:` are comments (keepalive). The `event:` field
205 - /// specifies the event type, `data:` the payload (ignored here).
206 - fn parse_sse_block_is_changed(block: &str) -> bool {
207 - for line in block.lines() {
208 - let trimmed = line.trim();
209 - // Skip comments
210 - if trimmed.starts_with(':') {
211 - continue;
212 - }
213 - if let Some(value) = trimmed.strip_prefix("event:")
214 - && value.trim() == "changed"
215 - {
216 - return true;
217 - }
218 - }
219 - false
220 - }
221 -
222 - impl SyncKitClient {
223 - /// Open an SSE connection for real-time push notifications.
224 - ///
225 - /// Returns a [`SyncNotifyStream`] that yields `Some(())` each time the
226 - /// server signals new changes are available. The caller should pull after
227 - /// each notification to get the actual changes.
228 - ///
229 - /// The returned stream auto-reconnects with backoff across transient drops;
230 - /// `next_change()` returns `None` only on a fatal condition (auth rejected on
231 - /// reconnect, or a protocol violation), at which point the caller should
232 - /// re-authenticate and subscribe again.
233 - #[tracing::instrument(skip(self))]
234 - pub async fn subscribe(&self) -> Result<SyncNotifyStream> {
235 - let token = self.require_token()?;
236 - let (app_id, _user_id) = self.require_session_ids()?;
237 - let url = format!("{}?app_id={}", self.endpoints.subscribe, app_id);
238 -
239 - let resp = self
240 - .http_stream
241 - .get(&url)
242 - .bearer_auth(&*token)
243 - .send()
244 - .await
245 - .map_err(SyncKitError::Http)?;
246 -
247 - let status = resp.status().as_u16();
248 - if status >= 400 {
249 - let message = crate::client::helpers::read_text_capped(
250 - resp,
251 - crate::client::helpers::MAX_CONTROL_BODY_BYTES,
252 - )
253 - .await;
254 - return Err(SyncKitError::Server {
255 - status,
256 - message,
257 - retry_after_secs: None,
258 - });
259 - }
260 -
261 - // Hand the stream everything it needs to reconnect itself.
262 - Ok(SyncNotifyStream::new(
263 - self.http_stream.clone(),
264 - url,
265 - token,
266 - resp,
267 - ))
268 - }
269 - }
270 -
271 - #[cfg(test)]
272 - mod tests {
273 - use super::*;
274 -
275 - #[test]
276 - fn parse_changed_event() {
277 - let block = "event: changed\ndata: {}";
278 - assert!(parse_sse_block_is_changed(block));
279 - }
280 -
281 - #[test]
282 - fn parse_ignores_keepalive_comments() {
283 - let block = ": keepalive";
284 - assert!(!parse_sse_block_is_changed(block));
285 - }
286 -
287 - #[test]
288 - fn parse_handles_malformed_lines() {
289 - let block = "garbled nonsense";
290 - assert!(!parse_sse_block_is_changed(block));
291 -
292 - let block2 = "";
293 - assert!(!parse_sse_block_is_changed(block2));
294 -
295 - let block3 = "event:other\ndata: test";
296 - assert!(!parse_sse_block_is_changed(block3));
297 - }
298 -
299 - #[test]
300 - fn parse_changed_with_extra_whitespace() {
301 - let block = "event: changed \ndata: {}";
302 - assert!(parse_sse_block_is_changed(block));
303 - }
304 -
305 - #[test]
306 - fn reconnect_delay_grows_then_caps() {
307 - // Within +/-20% jitter, the delay grows with the attempt and never
308 - // exceeds the cap plus jitter headroom.
309 - let d1 = reconnect_delay(1);
310 - let d6 = reconnect_delay(6);
311 - assert!(d1 >= Duration::from_millis(1600) && d1 <= Duration::from_millis(2400));
312 - assert!(d6 <= MAX_RECONNECT_DELAY + Duration::from_secs(12));
313 - // Deep attempts stay capped (exponent is clamped).
314 - assert!(reconnect_delay(100) <= MAX_RECONNECT_DELAY + Duration::from_secs(12));
315 - }
316 - }
@@ -1,442 +0,0 @@
1 - //! Subscription status, pricing-formula quoting, checkout, and storage-cap
2 - //! management for end-user SyncKit subscriptions.
3 - //!
4 - //! The server uses a formula-driven pricing model: there are no fixed tiers,
5 - //! the user picks any storage cap (within a min/max) and the server quotes a
6 - //! price. Apps typically show a slider, call [`SyncKitClient::quote_price`]
7 - //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`]
8 - //! once the user clicks subscribe.
9 -
10 - use bytes::Bytes;
11 - use serde::{Deserialize, Deserializer, Serialize, Serializer};
12 -
13 - use super::SyncKitClient;
14 - use super::helpers::{Idempotency, check_response};
15 - use crate::error::Result;
16 -
17 - /// A monetary amount in whole cents.
18 - ///
19 - /// All pricing arithmetic flows through this newtype so the saturating
20 - /// discipline lives in one place rather than being re-derived at each call
21 - /// site: the inputs are server-supplied and a hostile or buggy value must
22 - /// never panic (debug overflow) or wrap to a negative price (release). It is
23 - /// `#[serde(transparent)]`, so it is wire- and JSON-identical to the bare `i64`
24 - /// it replaces.
25 - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
26 - #[serde(transparent)]
27 - pub struct Cents(pub i64);
28 -
29 - impl Cents {
30 - /// The underlying whole-cent count.
31 - pub const fn get(self) -> i64 {
32 - self.0
33 - }
34 -
35 - /// Add two amounts, saturating at [`i64::MAX`] rather than overflowing.
36 - #[must_use]
37 - pub fn saturating_add(self, other: Cents) -> Cents {
38 - Cents(self.0.saturating_add(other.0))
39 - }
40 -
41 - /// Scale by an integer factor (e.g. a months multiplier), saturating.
42 - #[must_use]
43 - pub fn saturating_mul(self, factor: i64) -> Cents {
44 - Cents(self.0.saturating_mul(factor))
45 - }
46 -
47 - /// The larger of two amounts (e.g. applying a price floor).
48 - #[must_use]
49 - pub fn max(self, other: Cents) -> Cents {
50 - Cents(self.0.max(other.0))
51 - }
52 - }
53 -
54 - impl std::fmt::Display for Cents {
55 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 - write!(f, "{}", self.0)
57 - }
58 - }
59 -
60 - /// Subscription status as returned by the server.
61 - #[derive(Debug, Clone, Default, Serialize, Deserialize)]
62 - #[non_exhaustive]
63 - pub struct SubscriptionStatus {
64 - /// Whether the user has an active sync subscription for this app.
65 - pub active: bool,
66 - /// Billing interval. `None` if no subscription.
67 - /// Kept under the legacy field name `tier` on the wire for SDK compat.
68 - #[serde(rename = "tier")]
69 - pub interval: Option<BillingInterval>,
70 - /// Subscription status string (e.g. "active", "past_due", "canceled").
71 - pub status: Option<String>,
72 - /// Current blob storage cap, in bytes.
73 - pub storage_limit_bytes: Option<i64>,
74 - /// A queued cap change that applies at the next billing cycle. `None`
75 - /// when no change is pending.
76 - #[serde(default)]
77 - pub pending_storage_limit_bytes: Option<i64>,
78 - /// Blob storage currently in use, in bytes (when the server has the info).
79 - pub storage_used_bytes: Option<i64>,
80 - /// ISO 8601 end of current billing period.
81 - pub current_period_end: Option<String>,
82 - }
83 -
84 - /// Pricing-formula constants for an app. Clients use these to quote a price
85 - /// locally as a slider moves; the server enforces the same formula at
86 - /// checkout so client-computed prices are advisory only.
87 - #[derive(Debug, Clone, Serialize, Deserialize)]
88 - #[non_exhaustive]
89 - pub struct AppPricing {
90 - pub app_name: String,
91 - /// Minimum charge (applies to both monthly and annual).
92 - pub min_charge_cents: Cents,
93 - /// Storage rate per GiB per month, in tenths of a cent. Convert with
94 - /// `(gib * per_gb_tenths + 9) / 10` to get cents.
95 - pub per_gb_tenths_of_cent_per_month: i64,
96 - /// Annual price is monthly × this multiplier.
97 - pub annual_multiplier: i64,
98 - pub min_cap_bytes: i64,
99 - pub max_cap_bytes: i64,
100 - }
101 -
102 - impl AppPricing {
103 - /// Compute the price in cents for a given cap and interval. Mirrors the
104 - /// server-side formula so client-side previews match what the user will
105 - /// actually be charged.
106 - ///
107 - /// All arithmetic saturates: the inputs are server-supplied `i64` values, and
108 - /// a hostile or buggy server returning huge rates must not panic the client
109 - /// (debug overflow) or wrap to a negative price (release). The server is
110 - /// authoritative at checkout, so this is an advisory preview regardless.
111 - pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> Cents {
112 - let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes);
113 - let gib = cap_bytes_to_gib_ceil(cap_bytes);
114 - // The storage rate is tenths-of-a-cent per GiB; convert to whole cents,
115 - // rounding up. Intermediate stays i64 (a rate, not money) until the
116 - // result becomes a `Cents` amount.
117 - let storage_monthly = Cents(
118 - gib.saturating_mul(self.per_gb_tenths_of_cent_per_month)
119 - .saturating_add(9)
120 - / 10,
121 - );
122 - let monthly = storage_monthly.max(self.min_charge_cents);
123 - match interval {
124 - BillingInterval::Monthly => monthly,
125 - BillingInterval::Annual => monthly.saturating_mul(self.annual_multiplier),
126 - }
127 - }
128 - }
129 -
130 - fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 {
131 - const GIB: i64 = 1024 * 1024 * 1024;
132 - cap_bytes.saturating_add(GIB - 1) / GIB
133 - }
134 -
135 - /// Billing interval for SyncKit subscriptions.
136 - ///
137 - /// Serializes to/from the wire string (`"monthly"`/`"annual"`). Deserialization
138 - /// is lenient via [`from_wire`](BillingInterval::from_wire): an unknown tag maps
139 - /// to `Monthly` rather than erroring, so a future server-side interval cannot
140 - /// break an older client's status parse.
141 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
142 - pub enum BillingInterval {
143 - Monthly,
144 - Annual,
145 - }
146 -
147 - impl Serialize for BillingInterval {
148 - fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
149 - serializer.serialize_str(self.as_str())
150 - }
151 - }
152 -
153 - impl<'de> Deserialize<'de> for BillingInterval {
154 - fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
155 - let s = String::deserialize(deserializer)?;
156 - Ok(BillingInterval::from_wire(&s))
157 - }
158 - }
159 -
160 - impl BillingInterval {
161 - pub fn as_str(self) -> &'static str {
162 - match self {
163 - Self::Monthly => "monthly",
164 - Self::Annual => "annual",
165 - }
166 - }
167 -
168 - /// Parse from the server wire string. Falls back to `Monthly` for unknown
169 - /// values rather than erroring, defensive against a future interval tag.
170 - /// Named `from_wire` (not `from_str`) because it is infallible and lenient,
171 - /// unlike `std::str::FromStr`.
172 - pub fn from_wire(s: &str) -> Self {
173 - match s {
174 - "annual" => Self::Annual,
175 - other => {
176 - if !other.is_empty() && other != "monthly" {
177 - tracing::debug!(tag = %other, "unknown billing interval tag; defaulting to monthly");
178 - }
179 - Self::Monthly
180 - }
181 - }
182 - }
183 - }
184 -
185 - /// Response from creating a checkout session.
186 - #[derive(Debug, Deserialize)]
187 - #[non_exhaustive]
188 - pub struct CheckoutResponse {
189 - /// URL to redirect the user to for Stripe Checkout.
190 - pub checkout_url: String,
191 - }
192 -
193 - /// Account identifiers for the authenticated MNW user.
194 - #[derive(Debug, Clone, Serialize, Deserialize)]
195 - #[non_exhaustive]
196 - pub struct AccountInfo {
197 - /// The authenticated user's email address.
198 - pub email: String,
199 - /// The authenticated user's username.
200 - pub username: String,
201 - }
202 -
203 - #[derive(Debug, Serialize, Deserialize)]
204 - #[non_exhaustive]
205 - pub struct PriceQuote {
206 - pub cap_bytes: i64,
207 - pub interval: BillingInterval,
208 - pub price_cents: Cents,
209 - }
210 -
211 - #[derive(Debug, Serialize)]
212 - struct AppPricingRequest<'a> {
213 - api_key: &'a str,
214 - }
215 -
216 - #[derive(Debug, Serialize)]
217 - struct QuoteRequest {
218 - cap_bytes: i64,
219 - interval: &'static str,
220 - }
221 -
222 - #[derive(Debug, Serialize)]
223 - struct CheckoutRequest {
224 - cap_bytes: i64,
225 - interval: &'static str,
226 - }
227 -
228 - #[derive(Debug, Serialize)]
229 - struct CapChangeRequest {
230 - cap_bytes: i64,
231 - }
232 -
233 - impl SyncKitClient {
234 - /// Fetch the pricing-formula constants for this app. No JWT needed; the
235 - /// app's API key is sent in the body. Safe to call before login so the
236 - /// UI can show pricing on the marketing/onboarding view.
237 - #[tracing::instrument(skip(self))]
238 - pub async fn get_app_pricing(&self) -> Result<AppPricing> {
239 - let body = Bytes::from(serde_json::to_vec(&AppPricingRequest {
240 - api_key: &self.config.api_key,
241 - })?);
242 - self.retry_request_json(Idempotency::ReadOnly, || {
243 - let req = self
244 - .http
245 - .post(&self.endpoints.app_pricing)
246 - .header("content-type", "application/json")
247 - .body(body.clone());
248 - async move { check_response(req.send().await?).await }
249 - })
250 - .await
251 - }
252 -
253 - /// Server-side price quote for a (cap, interval). Use this to confirm
254 - /// the number you display matches what Stripe will charge; the result is
255 - /// authoritative.
256 - #[tracing::instrument(skip(self))]
257 - pub async fn quote_price(
258 - &self,
259 - cap_bytes: i64,
260 - interval: BillingInterval,
261 - ) -> Result<PriceQuote> {
262 - let token = self.require_token()?;
263 - let body = Bytes::from(serde_json::to_vec(&QuoteRequest {
264 - cap_bytes,
265 - interval: interval.as_str(),
266 - })?);
267 - self.retry_request_json(Idempotency::ReadOnly, || {
268 - let req = self
269 - .http
270 - .post(&self.endpoints.subscription_quote)
271 - .bearer_auth(token.as_str())
272 - .header("content-type", "application/json")
273 - .body(body.clone());
274 - async move { check_response(req.send().await?).await }
275 - })
276 - .await
277 - }
278 -
279 - /// Fetch the authenticated user's email and username.
280 - ///
281 - /// Used by apps to display "logged in as ..." in their cloud-sync UI.
282 - #[tracing::instrument(skip(self))]
283 - pub async fn get_account_info(&self) -> Result<AccountInfo> {
284 - let token = self.require_token()?;
285 - self.retry_request_json(Idempotency::ReadOnly, || {
286 - let req = self
287 - .http
288 - .get(&self.endpoints.account)
289 - .bearer_auth(token.as_str());
290 - async move { check_response(req.send().await?).await }
291 - })
292 - .await
293 - }
294 -
295 - /// Check the subscription status for this authenticated user + app.
296 - ///
297 - /// Returns `SubscriptionStatus` with `active: true` if sync is allowed,
298 - /// or `active: false` if the user needs to subscribe.
299 - #[tracing::instrument(skip(self))]
300 - pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> {
301 - let token = self.require_token()?;
302 - self.retry_request_json(Idempotency::ReadOnly, || {
303 - let req = self
304 - .http
305 - .get(&self.endpoints.subscription)
306 - .bearer_auth(token.as_str());
307 - async move { check_response(req.send().await?).await }
308 - })
309 - .await
310 - }
311 -
312 - /// Create a Stripe Checkout session for subscribing to cloud sync at the
313 - /// chosen storage cap. Returns a URL that should be opened in the user's
314 - /// browser.
315 - #[tracing::instrument(skip(self))]
316 - pub async fn create_subscription_checkout(
317 - &self,
318 - cap_bytes: i64,
319 - interval: BillingInterval,
320 - ) -> Result<CheckoutResponse> {
321 - let token = self.require_token()?;
322 - let body = Bytes::from(serde_json::to_vec(&CheckoutRequest {
323 - cap_bytes,
324 - interval: interval.as_str(),
325 - })?);
326 - // Not idempotent: a blind auto-retry could mint a second Stripe Checkout
327 - // session. Attempt once; the user re-initiates on a transient failure.
328 - self.retry_request_json(Idempotency::Unsafe, || {
329 - let req = self
330 - .http
331 - .post(&self.endpoints.subscription_checkout)
332 - .bearer_auth(token.as_str())
333 - .header("content-type", "application/json")
334 - .body(body.clone());
335 - async move { check_response(req.send().await?).await }
336 - })
337 - .await
338 - }
339 -
340 - /// Queue a storage-cap change that takes effect at the next billing
341 - /// cycle. Returns the updated subscription status with the new cap in
342 - /// `pending_storage_limit_bytes`.
343 - #[tracing::instrument(skip(self))]
344 - pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> {
345 - let token = self.require_token()?;
346 - let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?);
347 - self.retry_request_json(
348 - Idempotency::IdempotentWrite {
349 - on: "pending cap change (upsert)",
350 - },
351 - || {
352 - let req = self
353 - .http
354 - .post(&self.endpoints.subscription_storage_cap)
355 - .bearer_auth(token.as_str())
356 - .header("content-type", "application/json")
357 - .body(body.clone());
358 - async move { check_response(req.send().await?).await }
359 - },
360 - )
361 - .await
362 - }
363 - }
364 -
365 - #[cfg(test)]
366 - mod tests {
367 - use super::*;
368 -
369 - fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing {
370 - AppPricing {
371 - app_name: "test".into(),
372 - min_charge_cents: Cents(min_charge),
373 - per_gb_tenths_of_cent_per_month: per_gb_tenths,
374 - annual_multiplier: annual_mult,
375 - min_cap_bytes: 0,
376 - max_cap_bytes: i64::MAX,
377 - }
378 - }
379 -
380 - #[test]
381 - fn quote_cents_applies_floor_and_annual_multiplier() {
382 - // 10 GiB at 50 tenths-of-a-cent/GiB/mo = 500 tenths => 50 cents, above the
383 - // 16-cent floor; annual = 10x.
384 - let p = pricing(16, 50, 10);
385 - let ten_gib = 10 * 1024 * 1024 * 1024;
386 - assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), Cents(50));
387 - assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), Cents(500));
388 - // Tiny cap falls back to the minimum charge.
389 - assert_eq!(p.quote_cents(1, BillingInterval::Monthly), Cents(16));
390 - }
391 -
392 - #[test]
393 - fn quote_cents_saturates_on_hostile_server_numbers() {
394 - // A server returning an absurd per-GiB rate and multiplier must not panic
395 - // (debug) or wrap negative (release), it saturates.
396 - let p = pricing(0, i64::MAX, i64::MAX);
397 - let big = p.quote_cents(i64::MAX, BillingInterval::Annual);
398 - assert!(big.get() >= 0, "price must never wrap negative, got {big}");
399 - assert_eq!(big, Cents(i64::MAX));
400 - }
401 -
402 - #[test]
403 - fn billing_interval_from_wire_defaults_to_monthly() {
404 - assert_eq!(
405 - BillingInterval::from_wire("annual"),
406 - BillingInterval::Annual
407 - );
408 - assert_eq!(
409 - BillingInterval::from_wire("monthly"),
410 - BillingInterval::Monthly
411 - );
412 - assert_eq!(
413 - BillingInterval::from_wire("weekly"),
414 - BillingInterval::Monthly
415 - );
416 - assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly);
417 - }
418 -
419 - #[test]
420 - fn billing_interval_serde_roundtrips_as_wire_string() {
421 - // Serializes to the bare wire string and parses back leniently.
422 - assert_eq!(
423 - serde_json::to_string(&BillingInterval::Annual).unwrap(),
424 - "\"annual\""
425 - );
426 - assert_eq!(
427 - serde_json::from_str::<BillingInterval>("\"monthly\"").unwrap(),
428 - BillingInterval::Monthly
429 - );
430 - // Unknown future tag falls back to Monthly, not a parse error.
431 - assert_eq!(
432 - serde_json::from_str::<BillingInterval>("\"weekly\"").unwrap(),
433 - BillingInterval::Monthly
434 - );
435 - }
436 -
437 - #[test]
438 - fn cents_is_wire_transparent_to_i64() {
439 - assert_eq!(serde_json::to_string(&Cents(1234)).unwrap(), "1234");
440 - assert_eq!(serde_json::from_str::<Cents>("1234").unwrap(), Cents(1234));
441 - }
442 - }
@@ -1,506 +0,0 @@
1 - //! Device registration and the core encrypted push/pull.
2 - //!
3 - //! Registers a device, lists the account's devices, and moves change entries
4 - //! to and from the server. Rows are encrypted before push and decrypted after
5 - //! pull, so the transport only ever carries ciphertext; the `*_filtered` and
6 - //! `*_rich` variants narrow the pull or return per-row sync metadata.
7 -
8 - use bytes::Bytes;
9 - use tracing::instrument;
10 - use uuid::Uuid;
11 -
12 - use crate::{
13 - crypto,
14 - error::Result,
15 - ids::DeviceId,
16 - types::{
17 - ChangeEntry, Device, FilteredPullRequest, PullChangeEntry, PullFilter, PullRequest,
18 - PullResponse, PulledChange, PushResponse, RegisterDeviceRequest, SyncStatus,
19 - WirePushRequest,
20 - },
21 - };
22 -
23 - use super::SyncKitClient;
24 - use super::helpers::{Idempotency, check_response};
25 -
26 - impl SyncKitClient {
27 - // ── Devices ──
28 -
29 - /// Register a device for sync.
30 - ///
31 - /// If a device with the same name already exists for this user/app, the
32 - /// server upserts: it updates the existing device's platform and
33 - /// `last_seen_at` rather than creating a duplicate.
34 - #[instrument(skip(self))]
35 - pub async fn register_device(&self, device_name: &str, platform: &str) -> Result<Device> {
36 - let token = self.require_token()?;
37 -
38 - let body = Bytes::from(serde_json::to_vec(&RegisterDeviceRequest {
39 - device_name: device_name.to_string(),
40 - platform: platform.to_string(),
41 - })?);
42 -
43 - self.retry_request_json(
44 - Idempotency::IdempotentWrite {
45 - on: "device registration (server-deduped)",
46 - },
47 - || {
48 - let req = self
49 - .http
50 - .post(&self.endpoints.devices)
51 - .bearer_auth(&token)
52 - .header("content-type", "application/json")
53 - .body(body.clone());
54 - async move { check_response(req.send().await?).await }
55 - },
56 - )
57 - .await
58 - }
59 -
60 - /// List all devices for the current user.
61 - #[instrument(skip(self))]
62 - pub async fn list_devices(&self) -> Result<Vec<Device>> {
63 - let token = self.require_token()?;
64 -
65 - self.retry_request_json(Idempotency::ReadOnly, || {
66 - let req = self.http.get(&self.endpoints.devices).bearer_auth(&token);
67 - async move { check_response(req.send().await?).await }
68 - })
69 - .await
70 - }
71 -
72 - // ── Push / Pull ──
73 -
74 - /// Push changes to the server. Encrypts `data` fields automatically.
75 - /// Returns the server cursor after the push.
76 - ///
77 - /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
78 - #[instrument(skip(self, changes))]
79 - pub async fn push(&self, device_id: DeviceId, changes: Vec<ChangeEntry>) -> Result<i64> {
80 - let token = self.require_token()?;
81 -
82 - // Every change now seals an HLC envelope (Deletes included), so the master
83 - // key is needed whenever there is anything to push.
84 - let key_holder = if changes.is_empty() {
85 - crypto::ZeroizeOnDrop([0u8; 32])
86 - } else {
87 - self.require_master_key()?
88 - };
89 - let master_key: &[u8; 32] = &key_holder;
90 - let wire_changes = changes
91 - .into_iter()
92 - .map(|c| Self::encrypt_change_with_key(c, master_key))
93 - .collect::<Result<Vec<_>>>()?;
94 -
95 - let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
96 - device_id,
97 - batch_id: Uuid::new_v4(),
98 - changes: wire_changes,
99 - })?);
100 -
101 - let push_resp: PushResponse = self
102 - .retry_request_json(Idempotency::Keyed, || {
103 - let req = self
104 - .http
105 - .post(&self.endpoints.push)
106 - .bearer_auth(&token)
107 - .header("content-type", "application/json")
108 - .body(body.clone());
109 - async move { check_response(req.send().await?).await }
110 - })
111 - .await?;
112 - Ok(push_resp.cursor)
113 - }
114 -
115 - /// Pull changes from the server since the given cursor.
116 - /// Decrypts `data` fields automatically.
117 - /// Returns (changes, new_cursor, has_more).
118 - ///
119 - /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
120 - ///
121 - /// **Contract, persist the cursor only after applying.** The returned cursor
122 - /// must be written to durable storage *after* the returned changes are
123 - /// applied, in the same transaction where possible. Persisting the cursor
124 - /// first and crashing before apply silently drops a batch; the SDK does not
125 - /// dedup re-delivered changes, so apply must also be idempotent per
126 - /// `(table, row_id, hlc)`.
127 - #[instrument(skip(self))]
128 - pub async fn pull(
129 - &self,
130 - device_id: DeviceId,
131 - cursor: i64,
132 - ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
133 - let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
134 - self.pull_inner(body, Self::decrypt_change_with_key).await
135 - }
136 -
137 - /// Pull changes from the server with optional table and timestamp filters.
138 - /// Decrypts `data` fields automatically.
139 - /// Returns (changes, new_cursor, has_more).
140 - ///
141 - /// Identical to [`pull`](SyncKitClient::pull) when the filter is empty/default.
142 - #[instrument(skip(self, filter))]
143 - pub async fn pull_filtered(
144 - &self,
145 - device_id: DeviceId,
146 - cursor: i64,
147 - filter: PullFilter,
148 - ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
149 - let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
150 - device_id,
151 - cursor,
152 - tables: filter.tables,
153 - since: filter.since,
154 - })?);
155 - self.pull_inner(body, Self::decrypt_change_with_key).await
156 - }
157 -
158 - /// Pull changes from the server, preserving `device_id` and `seq` metadata.
159 - ///
160 - /// Same HTTP call and decryption as [`pull`](SyncKitClient::pull), but returns
161 - /// [`PulledChange`] wrappers that retain server metadata needed for conflict
162 - /// detection. Returns (changes, new_cursor, has_more).
163 - #[instrument(skip(self))]
164 - pub async fn pull_rich(
165 - &self,
166 - device_id: DeviceId,
167 - cursor: i64,
168 - ) -> Result<(Vec<PulledChange>, i64, bool)> {
169 - let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
170 - self.pull_inner(body, Self::decrypt_change_to_pulled).await
171 - }
172 -
173 - /// Pull changes with filters, preserving `device_id` and `seq` metadata.
174 - ///
175 - /// Same as [`pull_rich`](SyncKitClient::pull_rich) but with table/timestamp
176 - /// filtering support. Returns (changes, new_cursor, has_more).
177 - #[instrument(skip(self, filter))]
178 - pub async fn pull_filtered_rich(
179 - &self,
180 - device_id: DeviceId,
181 - cursor: i64,
182 - filter: PullFilter,
183 - ) -> Result<(Vec<PulledChange>, i64, bool)> {
184 - let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
185 - device_id,
186 - cursor,
187 - tables: filter.tables,
188 - since: filter.since,
189 - })?);
190 - self.pull_inner(body, Self::decrypt_change_to_pulled).await
191 - }
192 -
193 - /// Shared pull implementation: sends the request, extracts the master key,
194 - /// and decrypts each change using the provided function.
195 - ///
196 - /// If a pending rotation key is cached on the client, entries are decrypted
197 - /// with key selection based on each entry's `key_id` field.
198 - async fn pull_inner<T, F>(&self, body: Bytes, decrypt_fn: F) -> Result<(Vec<T>, i64, bool)>
199 - where
200 - F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>,
201 - {
202 - let token = self.require_token()?;
203 -
204 - let pull_resp: PullResponse = self
205 - .retry_request_json(Idempotency::ReadOnly, || {
206 - let req = self
207 - .http
208 - .post(&self.endpoints.pull)
209 - .bearer_auth(&token)
210 - .header("content-type", "application/json")
211 - .body(body.clone());
212 - async move { check_response(req.send().await?).await }
213 - })
214 - .await?;
215 -
216 - // Extract key once for the entire batch (only needed if any entry has data)
217 - let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
218 - let key_holder = if has_data {
219 - self.require_master_key()?
220 - } else {
221 - crypto::ZeroizeOnDrop([0u8; 32])
222 - };
223 - let master_key: &[u8; 32] = &key_holder;
224 -
225 - // Check for pending rotation key (multi-key decryption)
226 - let pending_guard = self.pending_key.read();
227 - let has_pending = pending_guard.is_some() && has_data;
228 -
229 - let changes = if has_pending {
230 - let pending = pending_guard.as_ref().unwrap();
231 - let primary_key_id = *self.master_key_id.read();
232 - pull_resp
233 - .changes
234 - .into_iter()
235 - .map(|c| {
236 - Self::decrypt_with_rotation_keys(
237 - c,
238 - master_key,
239 - primary_key_id,
240 - &pending.key,
241 - pending.key_id,
242 - &decrypt_fn,
243 - )
244 - })
245 - .collect::<Result<Vec<_>>>()?
246 - } else {
247 - drop(pending_guard);
248 - pull_resp
249 - .changes
250 - .into_iter()
251 - .map(|c| decrypt_fn(c, master_key))
252 - .collect::<Result<Vec<_>>>()?
253 - };
254 -
255 - Ok((changes, pull_resp.cursor, pull_resp.has_more))
256 - }
257 -
258 - /// Get sync status (total changes, latest cursor).
259 - #[instrument(skip(self))]
260 - pub async fn status(&self) -> Result<SyncStatus> {
261 - let token = self.require_token()?;
262 -
263 - self.retry_request_json(Idempotency::ReadOnly, || {
264 - let req = self.http.get(&self.endpoints.status).bearer_auth(&token);
265 - async move { check_response(req.send().await?).await }
266 - })
267 - .await
268 - }
269 - }
270 -
271 - #[cfg(test)]
272 - mod tests {
273 - use chrono::Utc;
274 - use uuid::Uuid;
275 -
276 - use crate::ids::{AppId, DeviceId, UserId};
277 - use crate::types::*;
278 -
279 - // ── Type serialization / deserialization ──
280 -
281 - #[test]
282 - fn change_entry_serialization_roundtrip() {
283 - let entry = ChangeEntry {
284 - table: "tasks".to_string(),
285 - op: ChangeOp::Insert,
286 - row_id: Uuid::new_v4().to_string(),
287 - timestamp: Utc::now(),
288 - hlc: Hlc::zero(DeviceId::nil()),
289 - data: Some(serde_json::json!({"title": "Test task", "done": false})),
290 - extra: serde_json::Map::default(),
291 - };
292 -
293 - let json = serde_json::to_string(&entry).unwrap();
294 - let deserialized: ChangeEntry = serde_json::from_str(&json).unwrap();
295 -
296 - assert_eq!(deserialized.table, entry.table);
297 - assert_eq!(deserialized.op, entry.op);
298 - assert_eq!(deserialized.row_id, entry.row_id);
299 - assert_eq!(deserialized.data, entry.data);
300 - }
301 -
302 - #[test]
303 - fn change_entry_with_none_data_omits_field() {
304 - let entry = ChangeEntry {
305 - table: "tasks".to_string(),
306 - op: ChangeOp::Delete,
307 - row_id: "abc-123".to_string(),
308 - timestamp: Utc::now(),
309 - hlc: Hlc::zero(DeviceId::nil()),
310 - data: None,
311 - extra: serde_json::Map::default(),
312 - };
313 -
314 - let json = serde_json::to_string(&entry).unwrap();
315 - assert!(!json.contains("\"data\""));
316 - }
317 -
318 - #[test]
319 - fn change_entry_deserialization_with_missing_data() {
320 - let json = r#"{
321 - "table": "events",
322 - "op": "DELETE",
323 - "row_id": "evt-1",
324 - "timestamp": "2025-01-15T10:00:00Z"
325 - }"#;
326 -
327 - let entry: ChangeEntry = serde_json::from_str(json).unwrap();
328 - assert_eq!(entry.table, "events");
329 - assert_eq!(entry.op, ChangeOp::Delete);
330 - assert!(entry.data.is_none());
331 - }
332 -
333 - #[test]
334 - fn device_serialization_roundtrip() {
335 - let device = Device {
336 - id: DeviceId::new(Uuid::new_v4()),
337 - app_id: AppId::new(Uuid::new_v4()),
338 - user_id: UserId::new(Uuid::new_v4()),
339 - device_name: "MacBook Pro".to_string(),
340 - platform: "macos".to_string(),
341 - last_seen_at: Utc::now(),
342 - created_at: Utc::now(),
343 - };
344 -
345 - let json = serde_json::to_string(&device).unwrap();
346 - let deserialized: Device = serde_json::from_str(&json).unwrap();
347 -
348 - assert_eq!(deserialized.id, device.id);
349 - assert_eq!(deserialized.device_name, device.device_name);
350 - assert_eq!(deserialized.platform, device.platform);
351 - }
352 -
353 - #[test]
354 - fn sync_status_deserialization() {
355 - let json = r#"{"total_changes": 42, "latest_cursor": 100}"#;
356 - let status: SyncStatus = serde_json::from_str(json).unwrap();
357 - assert_eq!(status.total_changes, 42);
358 - assert_eq!(status.latest_cursor, Some(100));
359 - }
360 -
361 - #[test]
362 - fn sync_status_with_null_cursor() {
363 - let json = r#"{"total_changes": 0, "latest_cursor": null}"#;
364 - let status: SyncStatus = serde_json::from_str(json).unwrap();
365 - assert_eq!(status.total_changes, 0);
366 - assert_eq!(status.latest_cursor, None);
367 - }
368 -
369 - #[test]
370 - fn register_device_request_serialization() {
371 - let req = RegisterDeviceRequest {
372 - device_name: "iPhone 15".to_string(),
373 - platform: "ios".to_string(),
374 - };
375 -
376 - let json = serde_json::to_string(&req).unwrap();
377 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
378 - assert_eq!(parsed["device_name"], "iPhone 15");
379 - assert_eq!(parsed["platform"], "ios");
380 - }
381 -
382 - // ── Wire types ──
383 -
384 - #[test]
385 - fn wire_push_request_serialization() {
386 - let device_id = DeviceId::new(Uuid::new_v4());
387 - let req = WirePushRequest {
388 - device_id,
389 - batch_id: Uuid::new_v4(),
390 - changes: vec![WireChangeEntry {
391 - table: "tasks".to_string(),
392 - op: ChangeOp::Insert,
393 - row_id: "r1".to_string(),
394 - timestamp: Utc::now(),
395 - data: Some(serde_json::json!("encrypted-blob")),
396 - }],
397 - };
398 -
399 - let json = serde_json::to_string(&req).unwrap();
400 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
401 - assert_eq!(parsed["device_id"], device_id.to_string());
402 - assert_eq!(parsed["changes"].as_array().unwrap().len(), 1);
403 - }
404 -
405 - #[test]
406 - fn pull_request_serialization() {
407 - let device_id = DeviceId::new(Uuid::new_v4());
408 - let req = PullRequest {
409 - device_id,
410 - cursor: 42,
411 - };
412 -
413 - let json = serde_json::to_string(&req).unwrap();
414 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
415 - assert_eq!(parsed["device_id"], device_id.to_string());
416 - assert_eq!(parsed["cursor"], 42);
417 - }
418 -
419 - #[test]
420 - fn pull_response_deserialization() {
421 - let device_id = Uuid::new_v4();
422 - let json = format!(
423 - r#"{{
424 - "changes": [
425 - {{
426 - "seq": 1,
427 - "device_id": "{device_id}",
428 - "table": "tasks",
429 - "op": "INSERT",
430 - "row_id": "r1",
431 - "timestamp": "2025-06-01T12:00:00Z",
432 - "data": "encrypted"
433 - }}
434 - ],
435 - "cursor": 5,
436 - "has_more": true
437 - }}"#
438 - );
439 -
440 - let resp: PullResponse = serde_json::from_str(&json).unwrap();
441 - assert_eq!(resp.changes.len(), 1);
442 - assert_eq!(resp.cursor, 5);
443 - assert!(resp.has_more);
444 - assert_eq!(resp.changes[0].seq, 1);
445 - assert_eq!(resp.changes[0].table, "tasks");
446 - }
447 -
448 - #[test]
449 - fn pull_response_empty_changes() {
450 - let json = r#"{"changes": [], "cursor": 0, "has_more": false}"#;
451 - let resp: PullResponse = serde_json::from_str(json).unwrap();
452 - assert!(resp.changes.is_empty());
453 - assert_eq!(resp.cursor, 0);
454 - assert!(!resp.has_more);
455 - }
456 -
457 - #[test]
458 - fn push_response_deserialization() {
459 - let json = r#"{"cursor": 99}"#;
460 - let resp: PushResponse = serde_json::from_str(json).unwrap();
461 - assert_eq!(resp.cursor, 99);
462 - }
463 -
464 - // ── ChangeOp display and parsing ──
465 -
466 - #[test]
467 - fn change_op_display() {
468 - assert_eq!(ChangeOp::Insert.to_string(), "INSERT");
469 - assert_eq!(ChangeOp::Update.to_string(), "UPDATE");
470 - assert_eq!(ChangeOp::Delete.to_string(), "DELETE");
471 - }
472 -
473 - #[test]
474 - fn change_op_from_str_valid() {
475 - assert_eq!(ChangeOp::from_str_opt("INSERT"), Some(ChangeOp::Insert));
476 - assert_eq!(ChangeOp::from_str_opt("UPDATE"), Some(ChangeOp::Update));
477 - assert_eq!(ChangeOp::from_str_opt("DELETE"), Some(ChangeOp::Delete));
478 - }
479 -
480 - #[test]
481 - fn change_op_from_str_invalid() {
482 - assert_eq!(ChangeOp::from_str_opt("insert"), None);
483 - assert_eq!(ChangeOp::from_str_opt("UPSERT"), None);
484 - assert_eq!(ChangeOp::from_str_opt(""), None);
485 - }
486 -
487 - // ── Malformed response types ──
488 -
489 - #[test]
490 - fn pull_response_missing_changes_fails() {
491 - let json = r#"{"cursor": 0, "has_more": false}"#;
492 - assert!(serde_json::from_str::<PullResponse>(json).is_err());
493 - }
494 -
495 - #[test]
496 - fn pull_response_missing_cursor_fails() {
497 - let json = r#"{"changes": [], "has_more": false}"#;
498 - assert!(serde_json::from_str::<PullResponse>(json).is_err());
499 - }
500 -
Lines truncated