max / makenotwork
11 files changed,
+443 insertions,
-80 deletions
| @@ -264,6 +264,11 @@ pub(super) async fn multipart_parts( | |||
| 264 | 264 | part_number as i32, | |
| 265 | 265 | Some(expires_in), | |
| 266 | 266 | Some(content_length as i64), | |
| 267 | + | // No checksum: the CLI streams plain file bytes and asks for | |
| 268 | + | // part URLs ahead of reading them, so it has no digest to bind | |
| 269 | + | // yet. The synckit blob path, which seals a part before asking | |
| 270 | + | // for its URL, does bind one. | |
| 271 | + | None, | |
| 267 | 272 | ) | |
| 268 | 273 | .await?; | |
| 269 | 274 | parts.push(MultipartPartUrl { |
| @@ -273,6 +273,20 @@ pub(super) async fn blob_multipart_parts( | |||
| 273 | 273 | ))); | |
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | + | // Checksums are positional, so a short or long list would silently bind the | |
| 277 | + | // wrong digest to a part — reject it rather than guess the alignment. | |
| 278 | + | if let Some(checksums) = &req.checksums { | |
| 279 | + | if checksums.len() != req.count as usize { | |
| 280 | + | return Err(AppError::BadRequest(format!( | |
| 281 | + | "checksums must have exactly {} entries, one per requested part", | |
| 282 | + | req.count | |
| 283 | + | ))); | |
| 284 | + | } | |
| 285 | + | for c in checksums { | |
| 286 | + | validation::validate_sha256_base64(c)?; | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 276 | 290 | let s3_key = crate::storage::S3Client::generate_synckit_blob_key( | |
| 277 | 291 | sync_user.app_id, sync_user.user_id, &req.hash, | |
| 278 | 292 | ); | |
| @@ -282,6 +296,11 @@ pub(super) async fn blob_multipart_parts( | |||
| 282 | 296 | let mut parts = Vec::with_capacity((last - req.first_part + 1) as usize); | |
| 283 | 297 | for part_number in req.first_part..=last { | |
| 284 | 298 | let content_length = plan.part_len(part_number); | |
| 299 | + | let checksum = req | |
| 300 | + | .checksums | |
| 301 | + | .as_ref() | |
| 302 | + | .and_then(|c| c.get((part_number - req.first_part) as usize)) | |
| 303 | + | .map(String::as_str); | |
| 285 | 304 | let url = synckit_s3 | |
| 286 | 305 | .presign_upload_part( | |
| 287 | 306 | &s3_key, | |
| @@ -289,6 +308,7 @@ pub(super) async fn blob_multipart_parts( | |||
| 289 | 308 | part_number as i32, | |
| 290 | 309 | Some(expires_in), | |
| 291 | 310 | Some(content_length as i64), | |
| 311 | + | checksum, | |
| 292 | 312 | ) | |
| 293 | 313 | .await | |
| 294 | 314 | .context("presign upload part for sync blob")?; | |
| @@ -394,17 +414,29 @@ pub(super) async fn blob_multipart_abort( | |||
| 394 | 414 | /// Idempotent: returns success without creating a duplicate. | |
| 395 | 415 | /// | |
| 396 | 416 | /// Content-addressing trust model (ultra-fuzz Run 4 Storage NOTE, decision | |
| 397 | - | /// 2026-06-23): the blob `hash` is treated as a content-address LABEL — confirm | |
| 398 | - | /// reads the authoritative `object_size` from S3 but does not re-hash the bytes | |
| 399 | - | /// to prove they match `hash`. The blast radius is per-user only: the key is | |
| 400 | - | /// `{app_id}/{user_id}/{hash}` and storage is `UNIQUE(app_id, user_id, hash)`, | |
| 401 | - | /// so a client that stores mismatched bytes can poison only its OWN dedup | |
| 402 | - | /// namespace — no cross-user effect, no data exposure. The correct A+ fix binds | |
| 403 | - | /// `x-amz-checksum-sha256` into the presigned PUT so S3 itself rejects a | |
| 404 | - | /// mismatched upload at write time (zero server-side download); that requires the | |
| 405 | - | /// `synckit-client` SDK to send the checksum header, so it is deferred to the | |
| 406 | - | /// SDK's own remediation rather than paying a full object download + re-hash on | |
| 407 | - | /// every confirm here. | |
| 417 | + | /// 2026-06-23; revised 2026-07-21): the blob `hash` is treated as a | |
| 418 | + | /// content-address LABEL — confirm reads the authoritative `object_size` from S3 | |
| 419 | + | /// but does not re-hash the bytes to prove they match `hash`. The blast radius | |
| 420 | + | /// is per-user only: the key is `{app_id}/{user_id}/{hash}` and storage is | |
| 421 | + | /// `UNIQUE(app_id, user_id, hash)`, so a client that stores mismatched bytes can | |
| 422 | + | /// poison only its OWN dedup namespace — no cross-user effect, no data exposure. | |
| 423 | + | /// | |
| 424 | + | /// This note used to say the A+ fix was binding `x-amz-checksum-sha256` into the | |
| 425 | + | /// presigned PUT so S3 rejects a mismatched upload at write time. That reasoning | |
| 426 | + | /// does not hold for these blobs, and the correction is worth keeping: the stored | |
| 427 | + | /// object is E2E *ciphertext* sealed with random per-chunk nonces, while `hash` | |
| 428 | + | /// is the SHA-256 of the *plaintext*. The server never sees plaintext, so it | |
| 429 | + | /// cannot derive the expected ciphertext digest at presign time — any checksum it | |
| 430 | + | /// binds has to come from the client, i.e. the party whose honesty was in | |
| 431 | + | /// question. Checksum binding (which the multipart path now does per part) buys | |
| 432 | + | /// transport integrity, not content-address enforcement. | |
| 433 | + | /// | |
| 434 | + | /// What actually binds the bytes to the address is the AEAD: each chunk is sealed | |
| 435 | + | /// with `(hash, chunk_index, chunk_count)` as associated data, so ciphertext that | |
| 436 | + | /// opens under `hash` is cryptographically tied to it, and the client re-hashes | |
| 437 | + | /// the plaintext after decrypting. A client storing mismatched bytes breaks only | |
| 438 | + | /// its own blob. Server-side re-hashing would cost a full object download per | |
| 439 | + | /// confirm to defend a client against itself, which is why it is not done. | |
| 408 | 440 | #[utoipa::path(post, path = "/api/v1/sync/blobs/confirm", tag = "SyncKit", | |
| 409 | 441 | request_body = BlobConfirmRequest, | |
| 410 | 442 | responses((status = 204, description = "Upload confirmed")), |
| @@ -402,6 +402,14 @@ pub(crate) struct BlobMultipartPartsRequest { | |||
| 402 | 402 | pub size_bytes: i64, | |
| 403 | 403 | pub first_part: u32, | |
| 404 | 404 | pub count: u32, | |
| 405 | + | /// SHA-256 of each requested part's bytes, base64 of the raw digest, | |
| 406 | + | /// positionally aligned with `first_part..first_part + count`. Bound into | |
| 407 | + | /// the presigned URL so S3 rehashes the part and rejects a mismatch at write | |
| 408 | + | /// time. Optional for now, since a client can only supply a checksum for a | |
| 409 | + | /// part it has already built — which in practice means asking for one part | |
| 410 | + | /// at a time. When present the length must equal `count`. | |
| 411 | + | #[serde(default)] | |
| 412 | + | pub checksums: Option<Vec<String>>, | |
| 405 | 413 | } | |
| 406 | 414 | ||
| 407 | 415 | #[derive(Serialize, utoipa::ToSchema)] |
| @@ -535,7 +535,11 @@ pub trait StorageBackend: Send + Sync { | |||
| 535 | 535 | /// `max_bytes`, when set, is signed as `Content-Length`, the same | |
| 536 | 536 | /// defense-in-depth as [`Self::presign_upload`] — the authoritative size | |
| 537 | 537 | /// check still happens at confirm time. | |
| 538 | - | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option<u64>, max_bytes: Option<i64>) -> Result<String>; | |
| 538 | + | /// | |
| 539 | + | /// `checksum_sha256` (base64 of the raw digest), when set, is signed as | |
| 540 | + | /// `x-amz-checksum-sha256` and IS enforced: S3 rehashes the part and | |
| 541 | + | /// rejects a mismatch before the bytes are durable. | |
| 542 | + | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option<u64>, max_bytes: Option<i64>, checksum_sha256: Option<&str>) -> Result<String>; | |
| 539 | 543 | /// Complete a multipart upload from the collected `(part_number, etag)` | |
| 540 | 544 | /// pairs. Parts may be passed in any order; the backend sorts them. | |
| 541 | 545 | async fn complete_multipart_upload(&self, s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)]) -> Result<()>; | |
| @@ -982,9 +986,9 @@ impl S3Client { | |||
| 982 | 986 | ||
| 983 | 987 | /// Presign one `UploadPart` request. See the | |
| 984 | 988 | /// [`StorageBackend::presign_upload_part`] trait method. | |
| 985 | - | pub async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option<u64>, max_bytes: Option<i64>) -> Result<String> { | |
| 989 | + | pub async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option<u64>, max_bytes: Option<i64>, checksum_sha256: Option<&str>) -> Result<String> { | |
| 986 | 990 | self.inner | |
| 987 | - | .presign_upload_part(s3_key.as_str(), upload_id, part_number, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS), max_bytes) | |
| 991 | + | .presign_upload_part(s3_key.as_str(), upload_id, part_number, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS), max_bytes, checksum_sha256) | |
| 988 | 992 | .await | |
| 989 | 993 | .map_err(AppError::Storage) | |
| 990 | 994 | } | |
| @@ -1269,8 +1273,8 @@ impl StorageBackend for S3Client { | |||
| 1269 | 1273 | self.create_multipart_upload(s3_key, content_type).await | |
| 1270 | 1274 | } | |
| 1271 | 1275 | ||
| 1272 | - | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option<u64>, max_bytes: Option<i64>) -> Result<String> { | |
| 1273 | - | self.presign_upload_part(s3_key, upload_id, part_number, expiry_secs, max_bytes).await | |
| 1276 | + | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option<u64>, max_bytes: Option<i64>, checksum_sha256: Option<&str>) -> Result<String> { | |
| 1277 | + | self.presign_upload_part(s3_key, upload_id, part_number, expiry_secs, max_bytes, checksum_sha256).await | |
| 1274 | 1278 | } | |
| 1275 | 1279 | ||
| 1276 | 1280 | async fn complete_multipart_upload(&self, s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)]) -> Result<()> { |
| @@ -248,6 +248,26 @@ pub fn validate_sync_blob_hash(hash: &str) -> Result<(), AppError> { | |||
| 248 | 248 | Ok(()) | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | + | /// Validate a base64-encoded SHA-256 digest, the form S3 wants for | |
| 252 | + | /// `x-amz-checksum-sha256`. | |
| 253 | + | /// | |
| 254 | + | /// Bound into a presigned URL, so a malformed value would produce a URL that | |
| 255 | + | /// fails at S3 with an opaque error long after the client committed to it. | |
| 256 | + | /// Reject it here, where the message can say what was wrong. | |
| 257 | + | pub fn validate_sha256_base64(value: &str) -> Result<(), AppError> { | |
| 258 | + | use base64::Engine; | |
| 259 | + | let decoded = base64::engine::general_purpose::STANDARD | |
| 260 | + | .decode(value) | |
| 261 | + | .map_err(|_| AppError::validation("Checksum must be base64".to_string()))?; | |
| 262 | + | if decoded.len() != 32 { | |
| 263 | + | return Err(AppError::validation(format!( | |
| 264 | + | "Checksum must decode to 32 bytes (SHA-256), got {}", | |
| 265 | + | decoded.len() | |
| 266 | + | ))); | |
| 267 | + | } | |
| 268 | + | Ok(()) | |
| 269 | + | } | |
| 270 | + | ||
| 251 | 271 | /// Validate a developer-defined SDK key. Opaque string identifying which | |
| 252 | 272 | /// workspace/org/end-user a JWT session belongs to. Rejects empty, oversize, | |
| 253 | 273 | /// null bytes, and control characters — same character rules as `validate_sync_row_id` |
| @@ -153,7 +153,7 @@ impl StorageBackend for InMemoryStorage { | |||
| 153 | 153 | Ok(upload_id) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | - | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, _expiry_secs: Option<u64>, _max_bytes: Option<i64>) -> Result<String> { | |
| 156 | + | async fn presign_upload_part(&self, s3_key: &S3Key, upload_id: &str, part_number: i32, _expiry_secs: Option<u64>, _max_bytes: Option<i64>, checksum_sha256: Option<&str>) -> Result<String> { | |
| 157 | 157 | // Mirror the production range check so a bad part number fails in tests | |
| 158 | 158 | // the same way it would against S3. | |
| 159 | 159 | if !(1..=s3_storage::MULTIPART_MAX_PARTS as i32).contains(&part_number) { | |
| @@ -162,7 +162,14 @@ impl StorageBackend for InMemoryStorage { | |||
| 162 | 162 | s3_storage::MULTIPART_MAX_PARTS | |
| 163 | 163 | ))); | |
| 164 | 164 | } | |
| 165 | - | Ok(format!("http://test-storage/{s3_key}?uploadId={upload_id}&partNumber={part_number}")) | |
| 165 | + | // Echo the bound checksum into the URL so tests can assert it reached | |
| 166 | + | // the signer, standing in for the SignedHeaders a real presign carries. | |
| 167 | + | let checksum = checksum_sha256 | |
| 168 | + | .map(|c| format!("&checksum={c}")) | |
| 169 | + | .unwrap_or_default(); | |
| 170 | + | Ok(format!( | |
| 171 | + | "http://test-storage/{s3_key}?uploadId={upload_id}&partNumber={part_number}{checksum}" | |
| 172 | + | )) | |
| 166 | 173 | } | |
| 167 | 174 | ||
| 168 | 175 | async fn complete_multipart_upload(&self, _s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)]) -> Result<()> { |
| @@ -223,6 +223,78 @@ async fn multipart_parts_signs_each_part_with_its_exact_length() { | |||
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | 225 | #[tokio::test] | |
| 226 | + | async fn multipart_parts_binds_the_client_checksum_into_the_signed_url() { | |
| 227 | + | // A checksum bound into the presigned URL is one S3 enforces: it rehashes | |
| 228 | + | // the part and rejects a mismatch before the bytes are durable. The client | |
| 229 | + | // can only supply one for a part it has already sealed, which is why it | |
| 230 | + | // asks for a single part at a time. | |
| 231 | + | use base64::Engine; | |
| 232 | + | ||
| 233 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 234 | + | let user_id = h.signup("mp_sum", "mp_sum@example.com", "Password1!").await; | |
| 235 | + | let (app_id, _key) = create_internal_app(&h.db, user_id).await; | |
| 236 | + | seed_subscription(&h.db, user_id, app_id, "active", SYNCKIT_MAX_MULTIPART_BLOB_SIZE_BYTES).await; | |
| 237 | + | auth_as(&mut h, user_id, app_id, "user-key"); | |
| 238 | + | ||
| 239 | + | let hash = fake_hash(0x18); | |
| 240 | + | let start: Value = h | |
| 241 | + | .client | |
| 242 | + | .post_json( | |
| 243 | + | "/api/sync/blobs/multipart/start", | |
| 244 | + | &json!({ "hash": hash, "size_bytes": LARGE_BLOB }).to_string(), | |
| 245 | + | ) | |
| 246 | + | .await | |
| 247 | + | .json(); | |
| 248 | + | let upload_id = start["upload_id"].as_str().unwrap().to_string(); | |
| 249 | + | ||
| 250 | + | let checksum = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); | |
| 251 | + | let parts: Value = h | |
| 252 | + | .client | |
| 253 | + | .post_json( | |
| 254 | + | "/api/sync/blobs/multipart/parts", | |
| 255 | + | &json!({ | |
| 256 | + | "hash": hash, "upload_id": upload_id, "size_bytes": LARGE_BLOB, | |
| 257 | + | "first_part": 3, "count": 1, "checksums": [checksum], | |
| 258 | + | }) | |
| 259 | + | .to_string(), | |
| 260 | + | ) | |
| 261 | + | .await | |
| 262 | + | .json(); | |
| 263 | + | let url = parts["parts"][0]["url"].as_str().unwrap(); | |
| 264 | + | assert!( | |
| 265 | + | url.contains(&format!("checksum={checksum}")), | |
| 266 | + | "the checksum must reach the signer, got: {url}" | |
| 267 | + | ); | |
| 268 | + | assert_eq!(parts["parts"][0]["part_number"].as_u64().unwrap(), 3); | |
| 269 | + | ||
| 270 | + | // Malformed or misaligned checksums are refused rather than signed: a | |
| 271 | + | // checksum bound to the wrong part would reject a correct upload. | |
| 272 | + | let bad = [ | |
| 273 | + | json!({ "first_part": 1, "count": 1, "checksums": ["not base64!!"] }), | |
| 274 | + | json!({ "first_part": 1, "count": 1, "checksums": [base64::engine::general_purpose::STANDARD.encode([7u8; 16])] }), | |
| 275 | + | json!({ "first_part": 1, "count": 2, "checksums": [checksum] }), | |
| 276 | + | json!({ "first_part": 1, "count": 1, "checksums": [checksum, checksum] }), | |
| 277 | + | ]; | |
| 278 | + | for case in bad { | |
| 279 | + | let mut body = json!({ | |
| 280 | + | "hash": hash, "upload_id": upload_id, "size_bytes": LARGE_BLOB, | |
| 281 | + | }); | |
| 282 | + | for (k, v) in case.as_object().unwrap() { | |
| 283 | + | body[k] = v.clone(); | |
| 284 | + | } | |
| 285 | + | let resp = h | |
| 286 | + | .client | |
| 287 | + | .post_json("/api/sync/blobs/multipart/parts", &body.to_string()) | |
| 288 | + | .await; | |
| 289 | + | assert!( | |
| 290 | + | resp.status.is_client_error(), | |
| 291 | + | "{case} must be refused: {}", | |
| 292 | + | resp.text | |
| 293 | + | ); | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[tokio::test] | |
| 226 | 298 | async fn multipart_complete_then_confirm_records_the_blob() { | |
| 227 | 299 | // The end-to-end handoff. Confirm is untouched by multipart: it reads the | |
| 228 | 300 | // real object size and charges that, not anything the session declared. |
| @@ -683,6 +683,14 @@ impl S3Client { | |||
| 683 | 683 | /// defense-in-depth (not a hard, server-enforced ceiling) as | |
| 684 | 684 | /// [`Self::presign_upload`] — see its docs for why the authoritative size | |
| 685 | 685 | /// check still lives at confirm time. | |
| 686 | + | /// | |
| 687 | + | /// `checksum_sha256` (base64 of the raw 32-byte digest) is signed as | |
| 688 | + | /// `x-amz-checksum-sha256`, and unlike the length this one S3 *does* | |
| 689 | + | /// enforce: it hashes the received part and rejects a mismatch with | |
| 690 | + | /// `BadDigest` before the bytes are durable. The caller must therefore send | |
| 691 | + | /// the header — it is in `SignedHeaders`, so omitting it fails the | |
| 692 | + | /// signature. This is transport integrity (the bytes S3 wrote are the bytes | |
| 693 | + | /// the client hashed), not a statement about what those bytes mean. | |
| 686 | 694 | pub async fn presign_upload_part( | |
| 687 | 695 | &self, | |
| 688 | 696 | key: &str, | |
| @@ -690,6 +698,7 @@ impl S3Client { | |||
| 690 | 698 | part_number: i32, | |
| 691 | 699 | expiry_secs: u64, | |
| 692 | 700 | max_bytes: Option<i64>, | |
| 701 | + | checksum_sha256: Option<&str>, | |
| 693 | 702 | ) -> Result<String, String> { | |
| 694 | 703 | if !(1..=MULTIPART_MAX_PARTS as i32).contains(&part_number) { | |
| 695 | 704 | return Err(format!( | |
| @@ -712,6 +721,9 @@ impl S3Client { | |||
| 712 | 721 | if let Some(n) = max_bytes { | |
| 713 | 722 | req = req.content_length(n); | |
| 714 | 723 | } | |
| 724 | + | if let Some(c) = checksum_sha256 { | |
| 725 | + | req = req.checksum_sha256(c); | |
| 726 | + | } | |
| 715 | 727 | ||
| 716 | 728 | let presigned = req | |
| 717 | 729 | .presigned(presigning_config) | |
| @@ -1254,6 +1266,44 @@ mod tests { | |||
| 1254 | 1266 | assert!(err.contains("non-empty"), "unexpected error: {err}"); | |
| 1255 | 1267 | } | |
| 1256 | 1268 | ||
| 1269 | + | /// The `X-Amz-SignedHeaders` list from a presigned URL. | |
| 1270 | + | fn signed_headers(url: &str) -> String { | |
| 1271 | + | url.split('&') | |
| 1272 | + | .find_map(|p| p.strip_prefix("X-Amz-SignedHeaders=")) | |
| 1273 | + | .map(|v| v.replace("%3B", ";")) | |
| 1274 | + | .expect("presigned URL must carry X-Amz-SignedHeaders") | |
| 1275 | + | } | |
| 1276 | + | ||
| 1277 | + | #[tokio::test] | |
| 1278 | + | async fn presign_upload_signs_content_length_when_bound() { | |
| 1279 | + | // Callers rely on `max_bytes` being enforced, and it is enforced only | |
| 1280 | + | // because it lands in SignedHeaders: a client sending a different | |
| 1281 | + | // Content-Length then fails the signature. That also makes the declared | |
| 1282 | + | // size a hard contract — a caller that declares anything other than the | |
| 1283 | + | // exact body length breaks every upload — so pin it here rather than | |
| 1284 | + | // discovering it against production S3. | |
| 1285 | + | let client = test_client(); | |
| 1286 | + | ||
| 1287 | + | let bound = client | |
| 1288 | + | .presign_upload("k", "application/octet-stream", 900, None, Some(12_345)) | |
| 1289 | + | .await | |
| 1290 | + | .unwrap(); | |
| 1291 | + | let headers = signed_headers(&bound); | |
| 1292 | + | assert!( | |
| 1293 | + | headers.contains("content-length"), | |
| 1294 | + | "max_bytes must be signed, got: {headers}" | |
| 1295 | + | ); | |
| 1296 | + | ||
| 1297 | + | let unbound = client | |
| 1298 | + | .presign_upload("k", "application/octet-stream", 900, None, None) | |
| 1299 | + | .await | |
| 1300 | + | .unwrap(); | |
| 1301 | + | assert!( | |
| 1302 | + | !signed_headers(&unbound).contains("content-length"), | |
| 1303 | + | "without max_bytes the client is free to send any length" | |
| 1304 | + | ); | |
| 1305 | + | } | |
| 1306 | + | ||
| 1257 | 1307 | #[tokio::test] | |
| 1258 | 1308 | async fn presign_upload_part_rejects_out_of_range_part_number() { | |
| 1259 | 1309 | // Pre-flight range check: fires before any network call, so the | |
| @@ -1261,7 +1311,7 @@ mod tests { | |||
| 1261 | 1311 | let client = test_client(); | |
| 1262 | 1312 | for bad in [0, MULTIPART_MAX_PARTS as i32 + 1] { | |
| 1263 | 1313 | let err = client | |
| 1264 | - | .presign_upload_part("k", "uid", bad, 3600, None) | |
| 1314 | + | .presign_upload_part("k", "uid", bad, 3600, None, None) | |
| 1265 | 1315 | .await | |
| 1266 | 1316 | .expect_err("out-of-range part number must be rejected"); | |
| 1267 | 1317 | assert!(err.contains("out of range"), "unexpected error: {err}"); | |
| @@ -1269,6 +1319,33 @@ mod tests { | |||
| 1269 | 1319 | } | |
| 1270 | 1320 | ||
| 1271 | 1321 | #[tokio::test] | |
| 1322 | + | async fn presign_upload_part_signs_the_checksum_when_bound() { | |
| 1323 | + | // S3 enforces a bound checksum by rehashing the part, but only if the | |
| 1324 | + | // client sends the header — which it must, because signing it makes it | |
| 1325 | + | // mandatory. Both halves of that live in SignedHeaders. | |
| 1326 | + | let client = test_client(); | |
| 1327 | + | ||
| 1328 | + | let bound = client | |
| 1329 | + | .presign_upload_part("k", "uid", 1, 900, Some(64), Some("Zm9vYmFyYmF6")) | |
| 1330 | + | .await | |
| 1331 | + | .unwrap(); | |
| 1332 | + | let headers = signed_headers(&bound); | |
| 1333 | + | assert!( | |
| 1334 | + | headers.contains("x-amz-checksum-sha256"), | |
| 1335 | + | "a bound checksum must be signed, got: {headers}" | |
| 1336 | + | ); | |
| 1337 | + | ||
| 1338 | + | let unbound = client | |
| 1339 | + | .presign_upload_part("k", "uid", 1, 900, Some(64), None) | |
| 1340 | + | .await | |
| 1341 | + | .unwrap(); | |
| 1342 | + | assert!( | |
| 1343 | + | !signed_headers(&unbound).contains("checksum"), | |
| 1344 | + | "no checksum bound means no checksum header is required" | |
| 1345 | + | ); | |
| 1346 | + | } | |
| 1347 | + | ||
| 1348 | + | #[tokio::test] | |
| 1272 | 1349 | async fn complete_multipart_rejects_empty_parts() { | |
| 1273 | 1350 | let client = test_client(); | |
| 1274 | 1351 | let err = client |
| @@ -5,9 +5,9 @@ | |||
| 5 | 5 | //! downloads are re-hashed on arrival and discarded on any mismatch, so a | |
| 6 | 6 | //! server that swaps or rolls back a blob cannot slip it past the client. | |
| 7 | 7 | ||
| 8 | - | use std::collections::VecDeque; | |
| 9 | 8 | use std::path::Path; | |
| 10 | 9 | ||
| 10 | + | use base64::Engine; | |
| 11 | 11 | use bytes::{Buf, Bytes, BytesMut}; | |
| 12 | 12 | use sha2::{Digest, Sha256}; | |
| 13 | 13 | use tokio::io::AsyncReadExt; | |
| @@ -28,18 +28,19 @@ use super::helpers::{check_response, Idempotency}; | |||
| 28 | 28 | /// Generous (4 GiB) so legitimate large media still flow. | |
| 29 | 29 | const MAX_BLOB_BYTES: usize = 4 * 1024 * 1024 * 1024; | |
| 30 | 30 | ||
| 31 | - | /// How many presigned part URLs [`SyncKitClient::blob_upload_streaming`] pulls | |
| 32 | - | /// per request. Must not exceed the server's own window bound | |
| 33 | - | /// (`BLOB_MULTIPART_PART_URL_WINDOW`), which rejects a larger `count` outright. | |
| 34 | - | /// Pulling as the upload progresses keeps the number of live upload credentials | |
| 35 | - | /// small for a transfer that may never finish. | |
| 36 | - | const PART_URL_WINDOW: u32 = 100; | |
| 37 | - | ||
| 38 | 31 | impl SyncKitClient { | |
| 39 | 32 | /// Request a presigned upload URL for a blob. | |
| 40 | 33 | /// Returns (upload_url, already_exists). If `already_exists` is true, | |
| 41 | 34 | /// the blob is already on the server and no upload is needed. | |
| 42 | 35 | /// | |
| 36 | + | /// `size_bytes` is the **plaintext** length, the size the caller sees on | |
| 37 | + | /// disk. What [`Self::blob_upload`] actually PUTs is the sealed ciphertext, | |
| 38 | + | /// which is longer by the v3 framing, and the server signs the declared | |
| 39 | + | /// size into the presigned URL as `Content-Length` (a SignedHeader) — so | |
| 40 | + | /// declaring the plaintext length makes every PUT fail the signature. This | |
| 41 | + | /// method converts, keeping the wire-format arithmetic inside the SDK | |
| 42 | + | /// instead of asking three consumer apps to know it. | |
| 43 | + | /// | |
| 43 | 44 | /// `already_exists` is the server's claim, not a verified fact. See | |
| 44 | 45 | /// [`BlobUploadUrlResponse::already_exists`] for what a false `true` costs | |
| 45 | 46 | /// (a withheld blob, never a substituted one). | |
| @@ -49,16 +50,14 @@ impl SyncKitClient { | |||
| 49 | 50 | hash: &str, | |
| 50 | 51 | size_bytes: i64, | |
| 51 | 52 | ) -> Result<BlobUploadUrlResponse> { | |
| 52 | - | if size_bytes < 0 { | |
| 53 | - | return Err(SyncKitError::InvalidArgument( | |
| 54 | - | "size_bytes must be non-negative".into(), | |
| 55 | - | )); | |
| 56 | - | } | |
| 53 | + | let plaintext_len = usize::try_from(size_bytes).map_err(|_| { | |
| 54 | + | SyncKitError::InvalidArgument("size_bytes must be non-negative".into()) | |
| 55 | + | })?; | |
| 57 | 56 | let token = self.require_token()?; | |
| 58 | 57 | ||
| 59 | 58 | let body = Bytes::from(serde_json::to_vec(&BlobUploadUrlRequest { | |
| 60 | 59 | hash: hash.to_string(), | |
| 61 | - | size_bytes, | |
| 60 | + | size_bytes: crypto::blob_encrypted_len(plaintext_len) as i64, | |
| 62 | 61 | })?); | |
| 63 | 62 | ||
| 64 | 63 | self.retry_request_json(Idempotency::ReadOnly, || { | |
| @@ -89,7 +88,7 @@ impl SyncKitClient { | |||
| 89 | 88 | pub async fn blob_upload(&self, hash: &str, presigned_url: &str, data: Vec<u8>) -> Result<()> { | |
| 90 | 89 | if data.len() > MAX_BLOB_BYTES { | |
| 91 | 90 | return Err(SyncKitError::InvalidArgument(format!( | |
| 92 | - | "blob is {} bytes, over the {MAX_BLOB_BYTES}-byte single-upload cap; larger blobs need the (deferred) multipart path", | |
| 91 | + | "blob is {} bytes, over the {MAX_BLOB_BYTES}-byte in-memory cap; use blob_upload_streaming for a file this size", | |
| 93 | 92 | data.len() | |
| 94 | 93 | ))); | |
| 95 | 94 | } | |
| @@ -256,7 +255,6 @@ impl SyncKitClient { | |||
| 256 | 255 | let mut staged = BytesMut::with_capacity(part_size + crypto::BLOB_CHUNK_SIZE); | |
| 257 | 256 | staged.extend_from_slice(&crypto::blob_header_bytes(plaintext_len)); | |
| 258 | 257 | ||
| 259 | - | let mut urls: VecDeque<BlobMultipartPartUrl> = VecDeque::new(); | |
| 260 | 258 | let mut completed = Vec::with_capacity(start.part_count as usize); | |
| 261 | 259 | let mut next_part = 1u32; | |
| 262 | 260 | ||
| @@ -283,7 +281,7 @@ impl SyncKitClient { | |||
| 283 | 281 | while staged.len() >= part_size && next_part < start.part_count { | |
| 284 | 282 | let body = staged.split_to(part_size).freeze(); | |
| 285 | 283 | let etag = self | |
| 286 | - | .upload_blob_part(hash, start, size_bytes, next_part, &mut urls, body) | |
| 284 | + | .upload_blob_part(hash, start, size_bytes, next_part, body) | |
| 287 | 285 | .await?; | |
| 288 | 286 | completed.push(BlobMultipartCompletedPart { | |
| 289 | 287 | part_number: next_part as i32, | |
| @@ -312,7 +310,7 @@ impl SyncKitClient { | |||
| 312 | 310 | } | |
| 313 | 311 | let body = staged.split().freeze(); | |
| 314 | 312 | let etag = self | |
| 315 | - | .upload_blob_part(hash, start, size_bytes, next_part, &mut urls, body) | |
| 313 | + | .upload_blob_part(hash, start, size_bytes, next_part, body) | |
| 316 | 314 | .await?; | |
| 317 | 315 | completed.push(BlobMultipartCompletedPart { | |
| 318 | 316 | part_number: next_part as i32, | |
| @@ -322,41 +320,47 @@ impl SyncKitClient { | |||
| 322 | 320 | Ok(completed) | |
| 323 | 321 | } | |
| 324 | 322 | ||
| 325 | - | /// PUT one part to its presigned URL, refilling the URL window as needed, | |
| 326 | - | /// and return the ETag S3 assigned it. | |
| 323 | + | /// PUT one part to its presigned URL and return the ETag S3 assigned it. | |
| 324 | + | /// | |
| 325 | + | /// The URL is requested for this part alone, and carries the part's SHA-256 | |
| 326 | + | /// bound as `x-amz-checksum-sha256`, so S3 rehashes what it receives and | |
| 327 | + | /// rejects a corrupted part at write time instead of letting it sit until a | |
| 328 | + | /// download fails to decrypt. That is the reason parts are not requested in | |
| 329 | + | /// windows: a digest only exists once the part has been sealed, and only one | |
| 330 | + | /// part is ever in memory. The cost is one small round trip per part. | |
| 327 | 331 | async fn upload_blob_part( | |
| 328 | 332 | &self, | |
| 329 | 333 | hash: &str, | |
| 330 | 334 | start: &BlobMultipartStartResponse, | |
| 331 | 335 | size_bytes: i64, | |
| 332 | 336 | part_number: u32, | |
| 333 | - | urls: &mut VecDeque<BlobMultipartPartUrl>, | |
| 334 | 337 | body: Bytes, | |
| 335 | 338 | ) -> Result<String> { | |
| 336 | - | if urls.is_empty() { | |
| 337 | - | let token = self.require_token()?; | |
| 338 | - | let req_body = Bytes::from(serde_json::to_vec(&BlobMultipartPartsRequest { | |
| 339 | - | hash: hash.to_string(), | |
| 340 | - | upload_id: start.upload_id.clone(), | |
| 341 | - | size_bytes, | |
| 342 | - | first_part: part_number, | |
| 343 | - | count: PART_URL_WINDOW.min(start.part_count - part_number + 1), | |
| 344 | - | })?); | |
| 345 | - | let window: BlobMultipartPartsResponse = self | |
| 346 | - | .retry_request_json(Idempotency::ReadOnly, || { | |
| 347 | - | let req = self | |
| 348 | - | .http | |
| 349 | - | .post(&self.endpoints.blobs_multipart_parts) | |
| 350 | - | .bearer_auth(&token) | |
| 351 | - | .header("content-type", "application/json") | |
| 352 | - | .body(req_body.clone()); | |
| 353 | - | async move { check_response(req.send().await?).await } | |
| 354 | - | }) | |
| 355 | - | .await?; | |
| 356 | - | urls.extend(window.parts); | |
| 357 | - | } | |
| 339 | + | let checksum = | |
| 340 | + | base64::engine::general_purpose::STANDARD.encode(Sha256::digest(&body)); | |
| 341 | + | ||
| 342 | + | let token = self.require_token()?; | |
| 343 | + | let req_body = Bytes::from(serde_json::to_vec(&BlobMultipartPartsRequest { | |
| 344 | + | hash: hash.to_string(), | |
| 345 | + | upload_id: start.upload_id.clone(), | |
| 346 | + | size_bytes, | |
| 347 | + | first_part: part_number, | |
| 348 | + | count: 1, | |
| 349 | + | checksums: vec![checksum.clone()], | |
| 350 | + | })?); | |
| 351 | + | let window: BlobMultipartPartsResponse = self | |
| 352 | + | .retry_request_json(Idempotency::ReadOnly, || { | |
| 353 | + | let req = self | |
| 354 | + | .http | |
| 355 | + | .post(&self.endpoints.blobs_multipart_parts) | |
| 356 | + | .bearer_auth(&token) | |
| 357 | + | .header("content-type", "application/json") | |
| 358 | + | .body(req_body.clone()); | |
| 359 | + | async move { check_response(req.send().await?).await } | |
| 360 | + | }) | |
| 361 | + | .await?; | |
| 358 | 362 | ||
| 359 | - | let part = urls.pop_front().ok_or_else(|| { | |
| 363 | + | let part = window.parts.into_iter().next().ok_or_else(|| { | |
| 360 | 364 | SyncKitError::Internal(format!("server returned no URL for part {part_number}")) | |
| 361 | 365 | })?; | |
| 362 | 366 | // The two sides derive the plan independently, so a disagreement means | |
| @@ -379,6 +383,9 @@ impl SyncKitClient { | |||
| 379 | 383 | .http_stream | |
| 380 | 384 | .put(&part.url) | |
| 381 | 385 | .header("content-type", "application/octet-stream") | |
| 386 | + | // Mandatory, not optional: the checksum is a signed | |
| 387 | + | // header, so omitting it fails the signature. | |
| 388 | + | .header("x-amz-checksum-sha256", &checksum) | |
| 382 | 389 | .body(body.clone()); | |
| 383 | 390 | async move { check_response(req.send().await?).await } | |
| 384 | 391 | }, |
| @@ -560,6 +560,11 @@ pub(crate) struct BlobMultipartPartsRequest { | |||
| 560 | 560 | pub size_bytes: i64, | |
| 561 | 561 | pub first_part: u32, | |
| 562 | 562 | pub count: u32, | |
| 563 | + | /// SHA-256 of each requested part, base64 of the raw digest, aligned with | |
| 564 | + | /// `first_part..first_part + count`. The server binds these into the | |
| 565 | + | /// presigned URLs as `x-amz-checksum-sha256`, so S3 rehashes each part and | |
| 566 | + | /// refuses a corrupted one at write time. | |
| 567 | + | pub checksums: Vec<String>, | |
| 563 | 568 | } | |
| 564 | 569 | ||
| 565 | 570 | #[derive(Deserialize)] |
| @@ -460,6 +460,75 @@ async fn blob_upload_url_success() { | |||
| 460 | 460 | } | |
| 461 | 461 | ||
| 462 | 462 | #[tokio::test] | |
| 463 | + | async fn blob_upload_url_declares_the_length_the_put_will_carry() { | |
| 464 | + | // The server signs the declared size into the presigned URL as | |
| 465 | + | // Content-Length, a SignedHeader — so declaring anything other than the | |
| 466 | + | // exact ciphertext length makes the PUT fail SigV4. The caller passes the | |
| 467 | + | // plaintext size it sees on disk; the SDK converts. This test pins the two | |
| 468 | + | // halves together, which is the only place the mismatch would show up: | |
| 469 | + | // wiremock does not verify signatures, and the server's own tests use an | |
| 470 | + | // in-memory backend that does not sign at all. | |
| 471 | + | let server = MockServer::start().await; | |
| 472 | + | ||
| 473 | + | let upload_path = "/s3/sized-upload"; | |
| 474 | + | Mock::given(method("POST")) | |
| 475 | + | .and(path("/api/v1/sync/blobs/upload")) | |
| 476 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 477 | + | "upload_url": format!("{}{upload_path}", server.uri()), | |
| 478 | + | "already_exists": false, | |
| 479 | + | }))) | |
| 480 | + | .mount(&server) | |
| 481 | + | .await; | |
| 482 | + | Mock::given(method("PUT")) | |
| 483 | + | .and(path(upload_path)) | |
| 484 | + | .respond_with(ResponseTemplate::new(200)) | |
| 485 | + | .mount(&server) | |
| 486 | + | .await; | |
| 487 | + | ||
| 488 | + | let client = authed_client(&server); | |
| 489 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 490 | + | ||
| 491 | + | // Spans two chunks, so the framing overhead is more than a single chunk's. | |
| 492 | + | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 500)) | |
| 493 | + | .map(|i| i as u8) | |
| 494 | + | .collect(); | |
| 495 | + | let hash = format!("{:x}", sha2::Sha256::digest(&plaintext)); | |
| 496 | + | ||
| 497 | + | let resp = client | |
| 498 | + | .blob_upload_url(&hash, plaintext.len() as i64) | |
| 499 | + | .await | |
| 500 | + | .unwrap(); | |
| 501 | + | client | |
| 502 | + | .blob_upload(&hash, &resp.upload_url, plaintext.clone()) | |
| 503 | + | .await | |
| 504 | + | .unwrap(); | |
| 505 | + | ||
| 506 | + | let reqs = server.received_requests().await.unwrap(); | |
| 507 | + | let declared: serde_json::Value = reqs | |
| 508 | + | .iter() | |
| 509 | + | .find(|r| r.url.path() == "/api/v1/sync/blobs/upload") | |
| 510 | + | .unwrap() | |
| 511 | + | .body_json() | |
| 512 | + | .unwrap(); | |
| 513 | + | let put_len = reqs | |
| 514 | + | .iter() | |
| 515 | + | .find(|r| r.url.path() == upload_path) | |
| 516 | + | .unwrap() | |
| 517 | + | .body | |
| 518 | + | .len(); | |
| 519 | + | ||
| 520 | + | assert_eq!( | |
| 521 | + | declared["size_bytes"].as_u64().unwrap(), | |
| 522 | + | put_len as u64, | |
| 523 | + | "the declared size must equal the bytes actually PUT, or the signature fails" | |
| 524 | + | ); | |
| 525 | + | assert!( | |
| 526 | + | put_len > plaintext.len(), | |
| 527 | + | "the PUT carries ciphertext, which is longer than the plaintext" | |
| 528 | + | ); | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | #[tokio::test] | |
| 463 | 532 | async fn blob_upload_encrypts_data() { | |
| 464 | 533 | let server = MockServer::start().await; | |
| 465 | 534 | ||
| @@ -3155,24 +3224,45 @@ mod blob_multipart { | |||
| 3155 | 3224 | p | |
| 3156 | 3225 | } | |
| 3157 | 3226 | ||
| 3158 | - | /// Mount the whole session: start (with the given plan), a parts window | |
| 3159 | - | /// whose URLs all point at one PUT path, and complete. | |
| 3227 | + | /// Stands in for the server's part-URL minting: answers whatever window the | |
| 3228 | + | /// client asked for, rather than a fixed list, since the client requests one | |
| 3229 | + | /// part at a time (it can only checksum a part it has already sealed). | |
| 3230 | + | struct PartsResponder { | |
| 3231 | + | cipher_len: usize, | |
| 3232 | + | part_size: usize, | |
| 3233 | + | base: String, | |
| 3234 | + | } | |
| 3235 | + | ||
| 3236 | + | impl wiremock::Respond for PartsResponder { | |
| 3237 | + | fn respond(&self, req: &wiremock::Request) -> ResponseTemplate { | |
| 3238 | + | let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap(); | |
| 3239 | + | let first = body["first_part"].as_u64().unwrap() as usize; | |
| 3240 | + | let count = body["count"].as_u64().unwrap() as usize; | |
| 3241 | + | let part_count = self.cipher_len.div_ceil(self.part_size); | |
| 3242 | + | let last = (first + count - 1).min(part_count); | |
| 3243 | + | ||
| 3244 | + | let parts: Vec<serde_json::Value> = (first..=last) | |
| 3245 | + | .map(|n| { | |
| 3246 | + | let content_length = if n == part_count { | |
| 3247 | + | self.cipher_len - self.part_size * (part_count - 1) | |
| 3248 | + | } else { | |
| 3249 | + | self.part_size | |
| 3250 | + | }; | |
| 3251 | + | json!({ | |
| 3252 | + | "part_number": n, | |
| 3253 | + | "content_length": content_length, | |
| 3254 | + | "url": format!("{}{PART_PUT_PATH}?partNumber={n}", self.base), | |
| 3255 | + | }) | |
| 3256 | + | }) | |
| 3257 | + | .collect(); | |
| 3258 | + | ResponseTemplate::new(200).set_body_json(json!({ "parts": parts })) | |
| 3259 | + | } | |
| 3260 | + | } | |
| 3261 | + | ||
| 3262 | + | /// Mount the whole session: start (with the given plan), part-URL minting, | |
| 3263 | + | /// the PUT target, and complete. | |
| 3160 | 3264 | async fn mount_session(server: &MockServer, cipher_len: usize, part_size: usize) -> u32 { | |
| 3161 | 3265 | let part_count = cipher_len.div_ceil(part_size) as u32; | |
| 3162 | - | let parts: Vec<serde_json::Value> = (1..=part_count) | |
| 3163 | - | .map(|n| { | |
| 3164 | - | let content_length = if n == part_count { | |
| 3165 | - | cipher_len - part_size * (part_count as usize - 1) | |
| 3166 | - | } else { | |
| 3167 | - | part_size | |
| 3168 | - | }; | |
| 3169 | - | json!({ | |
| 3170 | - | "part_number": n, | |
| 3171 | - | "content_length": content_length, | |
| 3172 | - | "url": format!("{}{PART_PUT_PATH}?partNumber={n}", server.uri()), | |
| 3173 | - | }) | |
| 3174 | - | }) | |
| 3175 | - | .collect(); | |
| 3176 | 3266 | ||
| 3177 | 3267 | Mock::given(method("POST")) | |
| 3178 | 3268 | .and(path(START_PATH)) | |
| @@ -3186,7 +3276,11 @@ mod blob_multipart { | |||
| 3186 | 3276 | .await; | |
| 3187 | 3277 | Mock::given(method("POST")) | |
| 3188 | 3278 | .and(path(PARTS_PATH)) | |
| 3189 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "parts": parts }))) | |
| 3279 | + | .respond_with(PartsResponder { | |
| 3280 | + | cipher_len, | |
| 3281 | + | part_size, | |
| 3282 | + | base: server.uri(), | |
| 3283 | + | }) | |
| 3190 | 3284 | .mount(server) | |
| 3191 | 3285 | .await; | |
| 3192 | 3286 | Mock::given(method("PUT")) | |
| @@ -3254,6 +3348,38 @@ mod blob_multipart { | |||
| 3254 | 3348 | assert_eq!(put.body.len(), expected, "part {} length", i + 1); | |
| 3255 | 3349 | } | |
| 3256 | 3350 | ||
| 3351 | + | // Each part was requested with the SHA-256 of exactly the bytes that | |
| 3352 | + | // part then carried, which is what S3 rehashes against at write time. | |
| 3353 | + | // The pairing is what matters: a checksum bound to the wrong part is | |
| 3354 | + | // worse than none, since it would reject a correct upload. | |
| 3355 | + | let part_reqs: Vec<&wiremock::Request> = | |
| 3356 | + | reqs.iter().filter(|r| r.url.path() == PARTS_PATH).collect(); | |
| 3357 | + | assert_eq!( | |
| 3358 | + | part_reqs.len() as u32, | |
| 3359 | + | part_count, | |
| 3360 | + | "one URL request per part: a digest exists only once the part is sealed" | |
| 3361 | + | ); | |
| 3362 | + | for (i, req) in part_reqs.iter().enumerate() { | |
| 3363 | + | let body: serde_json::Value = req.body_json().unwrap(); | |
| 3364 | + | assert_eq!(body["first_part"].as_u64().unwrap(), i as u64 + 1); | |
| 3365 | + | assert_eq!(body["count"].as_u64().unwrap(), 1); | |
| 3366 | + | let declared = body["checksums"][0].as_str().unwrap(); | |
| 3367 | + | let expected = base64::engine::general_purpose::STANDARD | |
| 3368 | + | .encode(sha2::Sha256::digest(&puts[i].body)); | |
| 3369 | + | assert_eq!(declared, expected, "part {} checksum must match its bytes", i + 1); | |
| 3370 | + | // And the client must actually send it: it is a signed header, so | |
| 3371 | + | // dropping it would fail SigV4 at S3. | |
| 3372 | + | assert_eq!( | |
| 3373 | + | puts[i] | |
| 3374 | + | .headers | |
| 3375 | + | .get("x-amz-checksum-sha256") | |
| 3376 | + | .expect("the PUT must carry the checksum header") | |
| 3377 | + | .to_str() | |
| 3378 | + | .unwrap(), | |
| 3379 | + | declared | |
| 3380 | + | ); | |
| 3381 | + | } | |
| 3382 | + | ||
| 3257 | 3383 | // The concatenated parts are a valid v3 blob for this content address — | |
| 3258 | 3384 | // proof that streaming produced the same wire format as the in-memory | |
| 3259 | 3385 | // encrypt, boundaries and all. |