Skip to main content

max / synckit

19.1 KB · 587 lines History Blame Raw
1 //! Blob upload, download, and confirm over the one-shot PUT path, plus the size
2 //! and key edge cases. The streaming path lives in [`blob_multipart`](super::blob_multipart).
3
4 use crate::common::*;
5
6 const UPLOAD_URL_PATH: &str = "/api/v1/sync/blobs/upload";
7 const CONFIRM_PATH: &str = "/api/v1/sync/blobs/confirm";
8
9 // ── Blob operations ──
10
11 #[tokio::test]
12 async fn blob_upload_url_success() {
13 let kit = MockKit::start().await;
14 kit.post(UPLOAD_URL_PATH)
15 .json(json!({
16 "upload_url": "https://s3.example.com/put",
17 "already_exists": false,
18 }))
19 .await;
20
21 let resp = kit
22 .authed()
23 .blob_upload_url("sha256-abc", 1024)
24 .await
25 .unwrap();
26 assert_eq!(resp.upload_url, "https://s3.example.com/put");
27 assert!(!resp.already_exists);
28 }
29
30 #[tokio::test]
31 async fn blob_upload_url_declares_the_length_the_put_will_carry() {
32 // The server signs the declared size into the presigned URL as
33 // Content-Length, a SignedHeader, so declaring anything other than the
34 // exact ciphertext length makes the PUT fail SigV4. The caller passes the
35 // plaintext size it sees on disk; the SDK converts. This test pins the two
36 // halves together, which is the only place the mismatch would show up:
37 // wiremock does not verify signatures, and the server's own tests use an
38 // in-memory backend that does not sign at all.
39 let kit = MockKit::start().await;
40
41 let upload_path = "/s3/sized-upload";
42 kit.post(UPLOAD_URL_PATH)
43 .json(json!({
44 "upload_url": kit.url(upload_path),
45 "already_exists": false,
46 }))
47 .await;
48 kit.put(upload_path).empty().await;
49
50 let (client, _key) = kit.keyed();
51
52 // Spans two chunks, so the framing overhead is more than a single chunk's.
53 let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 500))
54 .map(|i| i as u8)
55 .collect();
56 let hash = hex::encode(sha2::Sha256::digest(&plaintext));
57
58 let resp = client
59 .blob_upload_url(&hash, plaintext.len() as i64)
60 .await
61 .unwrap();
62 client
63 .blob_upload(&hash, &resp.upload_url, plaintext.clone())
64 .await
65 .unwrap();
66
67 let declared = kit.body(UPLOAD_URL_PATH).await;
68 let put_len = kit.raw_body(upload_path).await.len();
69
70 assert_eq!(
71 declared["size_bytes"].as_u64().unwrap(),
72 put_len as u64,
73 "the declared size must equal the bytes actually PUT, or the signature fails"
74 );
75 assert!(
76 put_len > plaintext.len(),
77 "the PUT carries ciphertext, which is longer than the plaintext"
78 );
79 }
80
81 #[tokio::test]
82 async fn blob_upload_encrypts_data() {
83 let kit = MockKit::start().await;
84
85 let upload_path = "/s3/upload";
86 kit.put(upload_path).empty().await;
87
88 let (client, _key) = kit.keyed();
89
90 let plaintext = b"hello blob data";
91 client
92 .blob_upload("sha256-test", &kit.url(upload_path), plaintext.to_vec())
93 .await
94 .unwrap();
95
96 // Verify uploaded body is encrypted (not plaintext)
97 let uploaded = kit.raw_body(upload_path).await;
98 assert!(
99 !uploaded.windows(plaintext.len()).any(|w| w == plaintext),
100 "Plaintext should not appear in uploaded body"
101 );
102 // Encrypted blob should be larger due to nonce + tag overhead
103 assert!(uploaded.len() > plaintext.len());
104 }
105
106 #[tokio::test]
107 async fn blob_download_decrypts_data() {
108 let kit = MockKit::start().await;
109 let (client, key) = kit.keyed();
110
111 // Encrypt data to simulate what S3 would return. A legacy (untagged) blob
112 // still decrypts through the AAD-aware reader and must pass the hash check.
113 let plaintext = b"decrypted blob content";
114 let hash = hex::encode(sha2::Sha256::digest(plaintext));
115 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
116
117 let download_path = "/s3/download";
118 kit.get(download_path).bytes(encrypted).await;
119
120 let result = client
121 .blob_download(&hash, &kit.url(download_path))
122 .await
123 .unwrap();
124 assert_eq!(result, plaintext);
125 }
126
127 #[tokio::test]
128 async fn blob_upload_retries_on_503() {
129 let kit = MockKit::start().await;
130
131 let upload_path = "/s3/retry-upload";
132 kit.put(upload_path).code(503).once().empty().await;
133 kit.put(upload_path).empty().await;
134
135 let (client, _key) = kit.keyed();
136 let result = client
137 .blob_upload("sha256-x", &kit.url(upload_path), b"data".to_vec())
138 .await;
139 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
140 }
141
142 // ── Blob confirm ──
143
144 #[tokio::test]
145 async fn blob_confirm_success() {
146 let kit = MockKit::start().await;
147 kit.post(CONFIRM_PATH).empty().await;
148
149 kit.authed().blob_confirm("sha256-abc", 1024).await.unwrap();
150 }
151
152 /// The size guard on `blob_confirm` admits an empty blob and refuses a negative
153 /// length.
154 ///
155 /// Zero is a real size: `streaming_upload_handles_an_empty_file` uploads one, so
156 /// a guard that rejected it would make the empty blob unconfirmable and leave it
157 /// unrecorded server-side. Negative is the only value that is not a length at
158 /// all, and catching it here is what keeps it out of the request body.
159 ///
160 /// Both cases are needed. The `< 0` that separates them is one character from
161 /// `<= 0`, which loses the empty blob, and from `== 0`, which loses the empty
162 /// blob and lets a negative length through.
163 #[tokio::test]
164 async fn blob_confirm_admits_an_empty_blob_and_refuses_a_negative_size() {
165 let kit = MockKit::start().await;
166 kit.post(CONFIRM_PATH).empty().await;
167
168 kit.authed()
169 .blob_confirm("sha256-empty", 0)
170 .await
171 .expect("an empty blob has a size, and it is zero");
172 assert_eq!(kit.hits(CONFIRM_PATH).await, 1);
173
174 let err = kit
175 .authed()
176 .blob_confirm("sha256-negative", -1)
177 .await
178 .unwrap_err();
179 assert!(
180 matches!(err, SyncKitError::InvalidArgument(_)),
181 "a negative length must be refused before it reaches the wire, got {err:?}"
182 );
183 assert_eq!(
184 kit.hits(CONFIRM_PATH).await,
185 1,
186 "the refused call must not have been sent"
187 );
188 }
189
190 // ── Blob download URL ──
191
192 #[tokio::test]
193 async fn blob_download_url_success() {
194 let kit = MockKit::start().await;
195 kit.post("/api/v1/sync/blobs/download")
196 .json(json!({
197 "download_url": "https://s3.example.com/get",
198 }))
199 .await;
200
201 let url = kit.authed().blob_download_url("sha256-abc").await.unwrap();
202 assert_eq!(url, "https://s3.example.com/get");
203 }
204
205 // ── Blob edge cases ──
206
207 #[tokio::test]
208 async fn blob_upload_zero_byte_data() {
209 let kit = MockKit::start().await;
210
211 let upload_path = "/s3/zero-byte";
212 kit.put(upload_path).empty().await;
213
214 let (client, _key) = kit.keyed();
215 let result = client
216 .blob_upload("sha256-empty", &kit.url(upload_path), vec![])
217 .await;
218 assert!(result.is_ok(), "Zero-byte blob upload should succeed");
219
220 // Verify the uploaded data is the v3 chunked framing over an empty blob.
221 assert_eq!(
222 kit.raw_body(upload_path).await.len(),
223 synckit_client::crypto::chunked_blob_overhead(0),
224 "Empty plaintext should produce exactly the chunked overhead bytes"
225 );
226 }
227
228 #[tokio::test]
229 async fn blob_upload_download_roundtrip() {
230 let kit = MockKit::start().await;
231 let (client, _key) = kit.keyed();
232
233 let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01";
234 let hash = hex::encode(sha2::Sha256::digest(plaintext));
235
236 // Upload
237 let upload_path = "/s3/roundtrip-upload";
238 kit.put(upload_path).empty().await;
239
240 client
241 .blob_upload(&hash, &kit.url(upload_path), plaintext.to_vec())
242 .await
243 .unwrap();
244
245 // Serve back exactly what was uploaded
246 let download_path = "/s3/roundtrip-download";
247 kit.get(download_path)
248 .bytes(kit.raw_body(upload_path).await)
249 .await;
250
251 let downloaded = client
252 .blob_download(&hash, &kit.url(download_path))
253 .await
254 .unwrap();
255
256 assert_eq!(downloaded, plaintext, "Blob roundtrip must preserve data");
257 }
258
259 // ── Blob operations require auth ──
260
261 #[tokio::test]
262 async fn blob_upload_url_without_auth_fails() {
263 let kit = MockKit::start().await;
264
265 let result = kit.client().blob_upload_url("hash", 100).await;
266 match result {
267 Err(SyncKitError::NotAuthenticated) => {} // expected
268 Err(other) => panic!("Expected NotAuthenticated, got: {other:?}"),
269 Ok(_) => panic!("Expected NotAuthenticated error, got Ok"),
270 }
271 }
272
273 #[tokio::test]
274 async fn blob_confirm_without_auth_fails() {
275 let kit = MockKit::start().await;
276 let err = kit.client().blob_confirm("hash", 100).await.unwrap_err();
277 assert!(matches!(err, SyncKitError::NotAuthenticated));
278 }
279
280 #[tokio::test]
281 async fn blob_download_url_without_auth_fails() {
282 let kit = MockKit::start().await;
283 let err = kit.client().blob_download_url("hash").await.unwrap_err();
284 assert!(matches!(err, SyncKitError::NotAuthenticated));
285 }
286
287 // ── Blob download with wrong key ──
288
289 #[tokio::test]
290 async fn blob_download_with_wrong_key_fails() {
291 let kit = MockKit::start().await;
292
293 let key1 = synckit_client::crypto::generate_master_key();
294
295 // Encrypt with key1
296 let plaintext = b"encrypted with key1";
297 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key1).unwrap();
298
299 let download_path = "/s3/wrong-key";
300 kit.get(download_path).bytes(encrypted).await;
301
302 // The client holds a different key.
303 let (client, key2) = kit.keyed();
304 assert_ne!(
305 key1, key2,
306 "the two keys must differ for this to test anything"
307 );
308
309 let result = client
310 .blob_download("sha256-x", &kit.url(download_path))
311 .await;
312
313 assert!(
314 result.is_err(),
315 "Download with wrong key should fail: {result:?}"
316 );
317 assert!(matches!(
318 result.unwrap_err(),
319 SyncKitError::DecryptionFailed
320 ));
321 }
322
323 // ── Blob edge cases ──
324
325 #[tokio::test]
326 async fn blob_confirm_retries_on_503() {
327 let kit = MockKit::start().await;
328 kit.post(CONFIRM_PATH)
329 .code(503)
330 .once()
331 .text("Service Unavailable")
332 .await;
333 kit.post(CONFIRM_PATH).empty().await;
334
335 let result = kit.authed().blob_confirm("sha256-retry", 512).await;
336 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
337 }
338
339 #[tokio::test]
340 async fn blob_download_retries_on_503() {
341 let kit = MockKit::start().await;
342 let (client, key) = kit.keyed();
343
344 let plaintext = b"retry download test";
345 let hash = hex::encode(sha2::Sha256::digest(plaintext));
346 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
347
348 let download_path = "/s3/retry-download";
349 kit.get(download_path).code(503).once().empty().await;
350 kit.get(download_path).bytes(encrypted).await;
351
352 let result = client
353 .blob_download(&hash, &kit.url(download_path))
354 .await
355 .unwrap();
356 assert_eq!(result, plaintext);
357 }
358
359 #[tokio::test]
360 async fn blob_upload_1mb_with_correct_overhead() {
361 let kit = MockKit::start().await;
362
363 let upload_path = "/s3/1mb-upload";
364 kit.put(upload_path).empty().await;
365
366 let (client, _key) = kit.keyed();
367
368 let plaintext: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect();
369 client
370 .blob_upload("sha256-1mb", &kit.url(upload_path), plaintext.clone())
371 .await
372 .unwrap();
373
374 assert_eq!(
375 kit.raw_body(upload_path).await.len(),
376 plaintext.len() + synckit_client::crypto::chunked_blob_overhead(plaintext.len()),
377 "1MB upload should add exactly the v3 chunked overhead"
378 );
379 }
380
381 // ── The v3 framing boundary on the download path ──
382 //
383 // `blob_download` decides three things from lengths alone: that four bytes are
384 // in hand before it reads the format tag, that the 4-byte tag plus the 13-byte
385 // header (17 bytes) are in hand before it parses the header, and that a chunk is
386 // complete before it decrypts. Each is a comparison against a literal, and a
387 // wrong one is invisible to a test that only serves whole, well-formed blobs:
388 // every such body is far past all three boundaries, so the comparisons agree.
389 // The bodies below sit exactly on them.
390
391 /// The message `blob_download` refused `body` with, served at `path`.
392 async fn download_refusal(
393 kit: &MockKit,
394 client: &SyncKitClient,
395 path: &str,
396 hash: &str,
397 body: Vec<u8>,
398 ) -> String {
399 kit.get(path).bytes(body).await;
400 match client.blob_download(hash, &kit.url(path)).await {
401 Err(SyncKitError::Crypto(message)) => message,
402 Err(other) => panic!("expected a Crypto refusal at {path}, got {other:?}"),
403 Ok(_) => panic!("{path} must not be accepted as a blob"),
404 }
405 }
406
407 #[tokio::test]
408 async fn a_v3_body_that_stops_short_of_its_header_is_refused_as_a_missing_header() {
409 let kit = MockKit::start().await;
410 let (client, _key) = kit.keyed();
411 let hash = hex::encode(sha2::Sha256::digest(b"never served"));
412 let header = synckit_client::crypto::blob_header_bytes(5_000);
413 assert_eq!(
414 header.len(),
415 17,
416 "4-byte format tag plus the 13-byte header"
417 );
418
419 // Exactly the format tag: enough to know the format, nothing to parse. The
420 // reader must hold on for the header rather than take the four bytes as one.
421 let tag_only =
422 download_refusal(&kit, &client, "/s3/tag-only", &hash, header[..4].to_vec()).await;
423 assert_eq!(
424 tag_only, "v3 blob ended before its header",
425 "four bytes is the tag and no more"
426 );
427
428 // Between the two boundaries: past the tag, short of the header.
429 let partial = download_refusal(
430 &kit,
431 &client,
432 "/s3/partial-header",
433 &hash,
434 header[..10].to_vec(),
435 )
436 .await;
437 assert_eq!(
438 partial, "v3 blob ended before its header",
439 "ten bytes is still short of the 17-byte header"
440 );
441 }
442
443 #[tokio::test]
444 async fn a_v3_body_of_exactly_its_header_is_parsed_and_then_found_to_have_no_chunks() {
445 // Dead on the boundary: the header is complete, so it must be parsed, and
446 // the refusal must be about the missing chunks rather than the header. A
447 // reader that waits for one more byte before parsing gives the other
448 // message, and no whole-blob fixture can tell the two apart.
449 let kit = MockKit::start().await;
450 let (client, _key) = kit.keyed();
451 let hash = hex::encode(sha2::Sha256::digest(b"never served"));
452 let header = synckit_client::crypto::blob_header_bytes(5_000);
453
454 let message = download_refusal(&kit, &client, "/s3/header-only", &hash, header).await;
455 assert_eq!(
456 message, "v3 blob ended mid-chunk or had trailing bytes",
457 "a complete header with no chunk behind it is a truncated blob, not a missing header"
458 );
459 }
460
461 #[tokio::test]
462 async fn a_v3_blob_one_byte_short_or_one_byte_long_is_refused() {
463 let kit = MockKit::start().await;
464 let (client, key) = kit.keyed();
465
466 // Two chunks plus a remainder, so the last chunk is a short one and the
467 // truncation lands inside it.
468 let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 4_321))
469 .map(|i| i as u8)
470 .collect();
471 let hash = hex::encode(sha2::Sha256::digest(&plaintext));
472 let blob = synckit_client::crypto::encrypt_blob_chunked(&plaintext, &key, &hash).unwrap();
473
474 // The control: intact, these exact bytes decrypt. Without it the two
475 // refusals below would also pass if the reader refused everything.
476 kit.get("/s3/intact").bytes(blob.clone()).await;
477 assert_eq!(
478 client
479 .blob_download(&hash, &kit.url("/s3/intact"))
480 .await
481 .unwrap(),
482 plaintext,
483 "the intact blob must round-trip"
484 );
485
486 let short = download_refusal(
487 &kit,
488 &client,
489 "/s3/one-short",
490 &hash,
491 blob[..blob.len() - 1].to_vec(),
492 )
493 .await;
494 assert_eq!(
495 short, "v3 blob ended mid-chunk or had trailing bytes",
496 "a chunk one byte short is incomplete and must never be decrypted"
497 );
498
499 // One byte past the end: every chunk is complete and the plaintext hashes
500 // correctly, so nothing but the leftover byte is wrong. A reader that only
501 // counted chunks would accept this.
502 let mut long = blob.clone();
503 long.push(0);
504 let long = download_refusal(&kit, &client, "/s3/one-long", &hash, long).await;
505 assert_eq!(
506 long, "v3 blob ended mid-chunk or had trailing bytes",
507 "a trailing byte is not part of any chunk and must be refused"
508 );
509 }
510
511 // ── The in-memory size cap, at its own boundary ──
512 //
513 // Both guards are `>` against a 4 GiB ceiling, and `>`, `>=` and `==` agree at
514 // every size below it. Nothing in the suite could tell them apart without
515 // holding four gibibytes in memory, so the cap is lowered instead and the real
516 // guard is driven from both sides of wherever it now sits. Off-by-one in one
517 // direction refuses legitimate media; in the other it admits the unbounded
518 // allocation the cap exists to prevent.
519
520 #[tokio::test]
521 async fn the_in_memory_upload_cap_admits_a_blob_of_exactly_the_cap_and_refuses_one_byte_more() {
522 let kit = MockKit::start().await;
523 let (client, _key) = kit.keyed();
524 client.set_max_blob_bytes(64);
525
526 let upload_path = "/s3/cap-upload";
527 kit.put(upload_path).empty().await;
528 let hash = "c".repeat(64);
529
530 client
531 .blob_upload(&hash, &kit.url(upload_path), vec![7u8; 64])
532 .await
533 .expect("a blob of exactly the cap is under it and must be sent");
534 assert_eq!(kit.hits(upload_path).await, 1);
535
536 let err = client
537 .blob_upload(&hash, &kit.url(upload_path), vec![7u8; 65])
538 .await
539 .unwrap_err();
540 match err {
541 SyncKitError::InvalidArgument(m) => {
542 assert!(m.contains("in-memory cap"), "wrong rejection: {m}");
543 }
544 other => panic!("one byte over the cap must be refused, got {other:?}"),
545 }
546 assert_eq!(
547 kit.hits(upload_path).await,
548 1,
549 "the refused blob must not have reached the wire"
550 );
551 }
552
553 #[tokio::test]
554 async fn the_download_cap_admits_a_body_of_exactly_the_cap_and_refuses_it_one_byte_lower() {
555 // The same body twice, with the cap moved by one byte, so the only thing
556 // the two runs can be telling apart is where the boundary sits.
557 let kit = MockKit::start().await;
558 let (client, key) = kit.keyed();
559
560 let plaintext = b"a body served against a lowered ceiling";
561 let hash = hex::encode(sha2::Sha256::digest(plaintext));
562 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
563 let len = encrypted.len();
564
565 let path = "/s3/cap-download";
566 kit.get(path).bytes(encrypted).await;
567
568 client.set_max_blob_bytes(len);
569 let got = client
570 .blob_download(&hash, &kit.url(path))
571 .await
572 .expect("a body of exactly the cap is under it and must decrypt");
573 assert_eq!(got, plaintext);
574
575 client.set_max_blob_bytes(len - 1);
576 let err = client
577 .blob_download(&hash, &kit.url(path))
578 .await
579 .unwrap_err();
580 match err {
581 SyncKitError::Internal(m) => {
582 assert!(m.contains("exceeds"), "wrong rejection: {m}");
583 }
584 other => panic!("a body over the cap must be refused, got {other:?}"),
585 }
586 }
587