//! Blob upload, download, and confirm over the one-shot PUT path, plus the size //! and key edge cases. The streaming path lives in [`blob_multipart`](super::blob_multipart). use crate::common::*; const UPLOAD_URL_PATH: &str = "/api/v1/sync/blobs/upload"; const CONFIRM_PATH: &str = "/api/v1/sync/blobs/confirm"; // ── Blob operations ── #[tokio::test] async fn blob_upload_url_success() { let kit = MockKit::start().await; kit.post(UPLOAD_URL_PATH) .json(json!({ "upload_url": "https://s3.example.com/put", "already_exists": false, })) .await; let resp = kit .authed() .blob_upload_url("sha256-abc", 1024) .await .unwrap(); assert_eq!(resp.upload_url, "https://s3.example.com/put"); assert!(!resp.already_exists); } #[tokio::test] async fn blob_upload_url_declares_the_length_the_put_will_carry() { // The server signs the declared size into the presigned URL as // Content-Length, a SignedHeader, so declaring anything other than the // exact ciphertext length makes the PUT fail SigV4. The caller passes the // plaintext size it sees on disk; the SDK converts. This test pins the two // halves together, which is the only place the mismatch would show up: // wiremock does not verify signatures, and the server's own tests use an // in-memory backend that does not sign at all. let kit = MockKit::start().await; let upload_path = "/s3/sized-upload"; kit.post(UPLOAD_URL_PATH) .json(json!({ "upload_url": kit.url(upload_path), "already_exists": false, })) .await; kit.put(upload_path).empty().await; let (client, _key) = kit.keyed(); // Spans two chunks, so the framing overhead is more than a single chunk's. let plaintext: Vec = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 500)) .map(|i| i as u8) .collect(); let hash = hex::encode(sha2::Sha256::digest(&plaintext)); let resp = client .blob_upload_url(&hash, plaintext.len() as i64) .await .unwrap(); client .blob_upload(&hash, &resp.upload_url, plaintext.clone()) .await .unwrap(); let declared = kit.body(UPLOAD_URL_PATH).await; let put_len = kit.raw_body(upload_path).await.len(); assert_eq!( declared["size_bytes"].as_u64().unwrap(), put_len as u64, "the declared size must equal the bytes actually PUT, or the signature fails" ); assert!( put_len > plaintext.len(), "the PUT carries ciphertext, which is longer than the plaintext" ); } #[tokio::test] async fn blob_upload_encrypts_data() { let kit = MockKit::start().await; let upload_path = "/s3/upload"; kit.put(upload_path).empty().await; let (client, _key) = kit.keyed(); let plaintext = b"hello blob data"; client .blob_upload("sha256-test", &kit.url(upload_path), plaintext.to_vec()) .await .unwrap(); // Verify uploaded body is encrypted (not plaintext) let uploaded = kit.raw_body(upload_path).await; assert!( !uploaded.windows(plaintext.len()).any(|w| w == plaintext), "Plaintext should not appear in uploaded body" ); // Encrypted blob should be larger due to nonce + tag overhead assert!(uploaded.len() > plaintext.len()); } #[tokio::test] async fn blob_download_decrypts_data() { let kit = MockKit::start().await; let (client, key) = kit.keyed(); // Encrypt data to simulate what S3 would return. A legacy (untagged) blob // still decrypts through the AAD-aware reader and must pass the hash check. let plaintext = b"decrypted blob content"; let hash = hex::encode(sha2::Sha256::digest(plaintext)); let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); let download_path = "/s3/download"; kit.get(download_path).bytes(encrypted).await; let result = client .blob_download(&hash, &kit.url(download_path)) .await .unwrap(); assert_eq!(result, plaintext); } #[tokio::test] async fn blob_upload_retries_on_503() { let kit = MockKit::start().await; let upload_path = "/s3/retry-upload"; kit.put(upload_path).code(503).once().empty().await; kit.put(upload_path).empty().await; let (client, _key) = kit.keyed(); let result = client .blob_upload("sha256-x", &kit.url(upload_path), b"data".to_vec()) .await; assert!(result.is_ok(), "Should succeed after retry: {result:?}"); } // ── Blob confirm ── #[tokio::test] async fn blob_confirm_success() { let kit = MockKit::start().await; kit.post(CONFIRM_PATH).empty().await; kit.authed().blob_confirm("sha256-abc", 1024).await.unwrap(); } /// The size guard on `blob_confirm` admits an empty blob and refuses a negative /// length. /// /// Zero is a real size: `streaming_upload_handles_an_empty_file` uploads one, so /// a guard that rejected it would make the empty blob unconfirmable and leave it /// unrecorded server-side. Negative is the only value that is not a length at /// all, and catching it here is what keeps it out of the request body. /// /// Both cases are needed. The `< 0` that separates them is one character from /// `<= 0`, which loses the empty blob, and from `== 0`, which loses the empty /// blob and lets a negative length through. #[tokio::test] async fn blob_confirm_admits_an_empty_blob_and_refuses_a_negative_size() { let kit = MockKit::start().await; kit.post(CONFIRM_PATH).empty().await; kit.authed() .blob_confirm("sha256-empty", 0) .await .expect("an empty blob has a size, and it is zero"); assert_eq!(kit.hits(CONFIRM_PATH).await, 1); let err = kit .authed() .blob_confirm("sha256-negative", -1) .await .unwrap_err(); assert!( matches!(err, SyncKitError::InvalidArgument(_)), "a negative length must be refused before it reaches the wire, got {err:?}" ); assert_eq!( kit.hits(CONFIRM_PATH).await, 1, "the refused call must not have been sent" ); } // ── Blob download URL ── #[tokio::test] async fn blob_download_url_success() { let kit = MockKit::start().await; kit.post("/api/v1/sync/blobs/download") .json(json!({ "download_url": "https://s3.example.com/get", })) .await; let url = kit.authed().blob_download_url("sha256-abc").await.unwrap(); assert_eq!(url, "https://s3.example.com/get"); } // ── Blob edge cases ── #[tokio::test] async fn blob_upload_zero_byte_data() { let kit = MockKit::start().await; let upload_path = "/s3/zero-byte"; kit.put(upload_path).empty().await; let (client, _key) = kit.keyed(); let result = client .blob_upload("sha256-empty", &kit.url(upload_path), vec![]) .await; assert!(result.is_ok(), "Zero-byte blob upload should succeed"); // Verify the uploaded data is the v3 chunked framing over an empty blob. assert_eq!( kit.raw_body(upload_path).await.len(), synckit_client::crypto::chunked_blob_overhead(0), "Empty plaintext should produce exactly the chunked overhead bytes" ); } #[tokio::test] async fn blob_upload_download_roundtrip() { let kit = MockKit::start().await; let (client, _key) = kit.keyed(); let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01"; let hash = hex::encode(sha2::Sha256::digest(plaintext)); // Upload let upload_path = "/s3/roundtrip-upload"; kit.put(upload_path).empty().await; client .blob_upload(&hash, &kit.url(upload_path), plaintext.to_vec()) .await .unwrap(); // Serve back exactly what was uploaded let download_path = "/s3/roundtrip-download"; kit.get(download_path) .bytes(kit.raw_body(upload_path).await) .await; let downloaded = client .blob_download(&hash, &kit.url(download_path)) .await .unwrap(); assert_eq!(downloaded, plaintext, "Blob roundtrip must preserve data"); } // ── Blob operations require auth ── #[tokio::test] async fn blob_upload_url_without_auth_fails() { let kit = MockKit::start().await; let result = kit.client().blob_upload_url("hash", 100).await; match result { Err(SyncKitError::NotAuthenticated) => {} // expected Err(other) => panic!("Expected NotAuthenticated, got: {other:?}"), Ok(_) => panic!("Expected NotAuthenticated error, got Ok"), } } #[tokio::test] async fn blob_confirm_without_auth_fails() { let kit = MockKit::start().await; let err = kit.client().blob_confirm("hash", 100).await.unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[tokio::test] async fn blob_download_url_without_auth_fails() { let kit = MockKit::start().await; let err = kit.client().blob_download_url("hash").await.unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } // ── Blob download with wrong key ── #[tokio::test] async fn blob_download_with_wrong_key_fails() { let kit = MockKit::start().await; let key1 = synckit_client::crypto::generate_master_key(); // Encrypt with key1 let plaintext = b"encrypted with key1"; let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key1).unwrap(); let download_path = "/s3/wrong-key"; kit.get(download_path).bytes(encrypted).await; // The client holds a different key. let (client, key2) = kit.keyed(); assert_ne!( key1, key2, "the two keys must differ for this to test anything" ); let result = client .blob_download("sha256-x", &kit.url(download_path)) .await; assert!( result.is_err(), "Download with wrong key should fail: {result:?}" ); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } // ── Blob edge cases ── #[tokio::test] async fn blob_confirm_retries_on_503() { let kit = MockKit::start().await; kit.post(CONFIRM_PATH) .code(503) .once() .text("Service Unavailable") .await; kit.post(CONFIRM_PATH).empty().await; let result = kit.authed().blob_confirm("sha256-retry", 512).await; assert!(result.is_ok(), "Should succeed after retry: {result:?}"); } #[tokio::test] async fn blob_download_retries_on_503() { let kit = MockKit::start().await; let (client, key) = kit.keyed(); let plaintext = b"retry download test"; let hash = hex::encode(sha2::Sha256::digest(plaintext)); let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); let download_path = "/s3/retry-download"; kit.get(download_path).code(503).once().empty().await; kit.get(download_path).bytes(encrypted).await; let result = client .blob_download(&hash, &kit.url(download_path)) .await .unwrap(); assert_eq!(result, plaintext); } #[tokio::test] async fn blob_upload_1mb_with_correct_overhead() { let kit = MockKit::start().await; let upload_path = "/s3/1mb-upload"; kit.put(upload_path).empty().await; let (client, _key) = kit.keyed(); let plaintext: Vec = (0..1_048_576u32).map(|i| (i % 256) as u8).collect(); client .blob_upload("sha256-1mb", &kit.url(upload_path), plaintext.clone()) .await .unwrap(); assert_eq!( kit.raw_body(upload_path).await.len(), plaintext.len() + synckit_client::crypto::chunked_blob_overhead(plaintext.len()), "1MB upload should add exactly the v3 chunked overhead" ); } // ── The v3 framing boundary on the download path ── // // `blob_download` decides three things from lengths alone: that four bytes are // in hand before it reads the format tag, that the 4-byte tag plus the 13-byte // header (17 bytes) are in hand before it parses the header, and that a chunk is // complete before it decrypts. Each is a comparison against a literal, and a // wrong one is invisible to a test that only serves whole, well-formed blobs: // every such body is far past all three boundaries, so the comparisons agree. // The bodies below sit exactly on them. /// The message `blob_download` refused `body` with, served at `path`. async fn download_refusal( kit: &MockKit, client: &SyncKitClient, path: &str, hash: &str, body: Vec, ) -> String { kit.get(path).bytes(body).await; match client.blob_download(hash, &kit.url(path)).await { Err(SyncKitError::Crypto(message)) => message, Err(other) => panic!("expected a Crypto refusal at {path}, got {other:?}"), Ok(_) => panic!("{path} must not be accepted as a blob"), } } #[tokio::test] async fn a_v3_body_that_stops_short_of_its_header_is_refused_as_a_missing_header() { let kit = MockKit::start().await; let (client, _key) = kit.keyed(); let hash = hex::encode(sha2::Sha256::digest(b"never served")); let header = synckit_client::crypto::blob_header_bytes(5_000); assert_eq!( header.len(), 17, "4-byte format tag plus the 13-byte header" ); // Exactly the format tag: enough to know the format, nothing to parse. The // reader must hold on for the header rather than take the four bytes as one. let tag_only = download_refusal(&kit, &client, "/s3/tag-only", &hash, header[..4].to_vec()).await; assert_eq!( tag_only, "v3 blob ended before its header", "four bytes is the tag and no more" ); // Between the two boundaries: past the tag, short of the header. let partial = download_refusal( &kit, &client, "/s3/partial-header", &hash, header[..10].to_vec(), ) .await; assert_eq!( partial, "v3 blob ended before its header", "ten bytes is still short of the 17-byte header" ); } #[tokio::test] async fn a_v3_body_of_exactly_its_header_is_parsed_and_then_found_to_have_no_chunks() { // Dead on the boundary: the header is complete, so it must be parsed, and // the refusal must be about the missing chunks rather than the header. A // reader that waits for one more byte before parsing gives the other // message, and no whole-blob fixture can tell the two apart. let kit = MockKit::start().await; let (client, _key) = kit.keyed(); let hash = hex::encode(sha2::Sha256::digest(b"never served")); let header = synckit_client::crypto::blob_header_bytes(5_000); let message = download_refusal(&kit, &client, "/s3/header-only", &hash, header).await; assert_eq!( message, "v3 blob ended mid-chunk or had trailing bytes", "a complete header with no chunk behind it is a truncated blob, not a missing header" ); } #[tokio::test] async fn a_v3_blob_one_byte_short_or_one_byte_long_is_refused() { let kit = MockKit::start().await; let (client, key) = kit.keyed(); // Two chunks plus a remainder, so the last chunk is a short one and the // truncation lands inside it. let plaintext: Vec = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 4_321)) .map(|i| i as u8) .collect(); let hash = hex::encode(sha2::Sha256::digest(&plaintext)); let blob = synckit_client::crypto::encrypt_blob_chunked(&plaintext, &key, &hash).unwrap(); // The control: intact, these exact bytes decrypt. Without it the two // refusals below would also pass if the reader refused everything. kit.get("/s3/intact").bytes(blob.clone()).await; assert_eq!( client .blob_download(&hash, &kit.url("/s3/intact")) .await .unwrap(), plaintext, "the intact blob must round-trip" ); let short = download_refusal( &kit, &client, "/s3/one-short", &hash, blob[..blob.len() - 1].to_vec(), ) .await; assert_eq!( short, "v3 blob ended mid-chunk or had trailing bytes", "a chunk one byte short is incomplete and must never be decrypted" ); // One byte past the end: every chunk is complete and the plaintext hashes // correctly, so nothing but the leftover byte is wrong. A reader that only // counted chunks would accept this. let mut long = blob.clone(); long.push(0); let long = download_refusal(&kit, &client, "/s3/one-long", &hash, long).await; assert_eq!( long, "v3 blob ended mid-chunk or had trailing bytes", "a trailing byte is not part of any chunk and must be refused" ); } // ── The in-memory size cap, at its own boundary ── // // Both guards are `>` against a 4 GiB ceiling, and `>`, `>=` and `==` agree at // every size below it. Nothing in the suite could tell them apart without // holding four gibibytes in memory, so the cap is lowered instead and the real // guard is driven from both sides of wherever it now sits. Off-by-one in one // direction refuses legitimate media; in the other it admits the unbounded // allocation the cap exists to prevent. #[tokio::test] async fn the_in_memory_upload_cap_admits_a_blob_of_exactly_the_cap_and_refuses_one_byte_more() { let kit = MockKit::start().await; let (client, _key) = kit.keyed(); client.set_max_blob_bytes(64); let upload_path = "/s3/cap-upload"; kit.put(upload_path).empty().await; let hash = "c".repeat(64); client .blob_upload(&hash, &kit.url(upload_path), vec![7u8; 64]) .await .expect("a blob of exactly the cap is under it and must be sent"); assert_eq!(kit.hits(upload_path).await, 1); let err = client .blob_upload(&hash, &kit.url(upload_path), vec![7u8; 65]) .await .unwrap_err(); match err { SyncKitError::InvalidArgument(m) => { assert!(m.contains("in-memory cap"), "wrong rejection: {m}"); } other => panic!("one byte over the cap must be refused, got {other:?}"), } assert_eq!( kit.hits(upload_path).await, 1, "the refused blob must not have reached the wire" ); } #[tokio::test] async fn the_download_cap_admits_a_body_of_exactly_the_cap_and_refuses_it_one_byte_lower() { // The same body twice, with the cap moved by one byte, so the only thing // the two runs can be telling apart is where the boundary sits. let kit = MockKit::start().await; let (client, key) = kit.keyed(); let plaintext = b"a body served against a lowered ceiling"; let hash = hex::encode(sha2::Sha256::digest(plaintext)); let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); let len = encrypted.len(); let path = "/s3/cap-download"; kit.get(path).bytes(encrypted).await; client.set_max_blob_bytes(len); let got = client .blob_download(&hash, &kit.url(path)) .await .expect("a body of exactly the cap is under it and must decrypt"); assert_eq!(got, plaintext); client.set_max_blob_bytes(len - 1); let err = client .blob_download(&hash, &kit.url(path)) .await .unwrap_err(); match err { SyncKitError::Internal(m) => { assert!(m.contains("exceeds"), "wrong rejection: {m}"); } other => panic!("a body over the cap must be refused, got {other:?}"), } }