diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index c7c9286..d4a3259 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -92,12 +92,22 @@ const SIGNED_AWS_CHUNKED_UNSUPPORTED: &str = pub const DEFAULT_USER_AGENT: &str = concat!("multistore/", env!("CARGO_PKG_VERSION")); /// Headers forwarded (and signed) when streaming an `aws-chunked` upload: -/// the de-chunk headers S3 needs to reconstruct the payload. +/// the de-chunk headers S3 needs to reconstruct the payload, plus the +/// conditional-write preconditions (`if-match`/`if-none-match`). AWS SDKs and +/// the CLI send `PutObject` bodies as `aws-chunked` by default, so this — not +/// the presigned path — is where an SDK client's precondition must be honored. +/// Unlike the presigned path these headers *are* signed (`sign_s3_request` +/// signs the whole map), so S3 evaluates them and returns 412 on a mismatch. +/// `if-match`/`if-none-match` don't apply to `UploadPart`, but clients never +/// send them there, so sharing this list with the part-streaming caller is a +/// no-op for parts. const AWS_CHUNKED_FORWARD_HEADERS: &[&str] = &[ "content-type", "content-encoding", "x-amz-decoded-content-length", "x-amz-trailer", + "if-match", + "if-none-match", ]; /// Headers forwarded (and signed) when streaming a *plain* (non-aws-chunked) @@ -946,11 +956,17 @@ where original_headers, // Standard HTTP entity headers are safe to forward to a // presigned URL: S3 applies them even though they are not - // part of the (host-only) presigned signature. `x-amz-*` - // write headers (metadata, SSE, tagging, storage-class, - // checksums) are deliberately NOT forwarded here — S3 - // rejects unsigned `x-amz-*` headers on presigned - // requests, so they need the header-signing path. See + // part of the (host-only) presigned signature. This + // includes the conditional-write preconditions + // `if-match`/`if-none-match` — S3 evaluates them and + // returns 412 on a mismatch (same as the GET/HEAD read + // paths above), giving Icechunk the compare-and-swap it + // needs. `x-amz-*` write headers (metadata, SSE, tagging, + // storage-class, checksums, and the + // `x-amz-copy-source-if-*` copy preconditions) are + // deliberately NOT forwarded here — S3 rejects unsigned + // `x-amz-*` headers on presigned requests, so they need + // the header-signing path. See // .plans/2026-06-23-data-edit-operations-design.md. &[ "content-type", @@ -961,6 +977,8 @@ where "content-language", "cache-control", "expires", + "if-match", + "if-none-match", ], request_id, ) @@ -1352,10 +1370,19 @@ where // path signs every header present (see `sign_s3_request`), so forwarding // them here is safe — unlike the presigned PutObject path, where S3 // rejects unsigned `x-amz-*` headers. + // + // `if-match`/`if-none-match` complete the conditional-write story: + // CompleteMultipartUpload honors them too (S3 and MinIO return 412 on a + // mismatch), so a large object written via multipart gets the same + // compare-and-swap guarantee as a single-shot PutObject. They only + // matter on the completion request; `original_headers.get` is `None` on + // Create/UploadPart/Abort, so listing them here is a no-op there. for (name, val) in pending.original_headers.iter() { let n = name.as_str(); - if matches!(n, "content-type" | "content-length" | "content-md5") - || n.starts_with("x-amz-checksum") + if matches!( + n, + "content-type" | "content-length" | "content-md5" | "if-match" | "if-none-match" + ) || n.starts_with("x-amz-checksum") || n == "x-amz-sdk-checksum-algorithm" { headers.insert(name.clone(), val.clone()); @@ -1798,6 +1825,38 @@ mod tests { }); } + #[test] + fn put_forward_preserves_conditional_headers() { + run(async { + let gw = gateway(); + let mut headers = HeaderMap::new(); + headers.insert("if-match", "\"abc123\"".parse().unwrap()); + headers.insert("if-none-match", "*".parse().unwrap()); + let action = gw + .resolve_request(Method::PUT, "/test-bucket/key.txt", None, &headers, None) + .await; + + match action { + HandlerAction::Forward(fwd) => { + assert_eq!(fwd.method, Method::PUT); + assert_eq!( + fwd.headers.get("if-match").map(|v| v.to_str().unwrap()), + Some("\"abc123\""), + "PUT forward should pass through If-Match so the backend enforces the precondition (412)" + ); + assert_eq!( + fwd.headers + .get("if-none-match") + .map(|v| v.to_str().unwrap()), + Some("*"), + "PUT forward should pass through If-None-Match" + ); + } + other => panic!("expected Forward, got {:?}", std::mem::discriminant(&other)), + } + }); + } + // -- User-Agent tests ---------------------------------------------------- #[test] diff --git a/crates/core/tests/conditional_writes.rs b/crates/core/tests/conditional_writes.rs new file mode 100644 index 0000000..b02fba3 --- /dev/null +++ b/crates/core/tests/conditional_writes.rs @@ -0,0 +1,334 @@ +//! Integration test: conditional-write preconditions on `PutObject`. +//! +//! Drives the full [`ProxyGateway::handle_request`] pipeline (parse → authorize +//! → presign → forward → passthrough) against a backend that emulates S3's +//! conditional-write evaluation. Proves two things end-to-end: +//! +//! 1. `If-Match` / `If-None-Match` actually reach the backend on a `PutObject` +//! (before the whitelist fix they were stripped, so the backend saw no +//! precondition and every write "succeeded"). +//! 2. The backend's `412 Precondition Failed` is surfaced to the client +//! unchanged. + +use bytes::Bytes; +use http::{HeaderMap, Method}; +use std::collections::HashMap; +use std::sync::Arc; + +use multistore::api::response::BucketEntry; +use multistore::backend::{build_signer, ForwardResponse, ProxyBackend, RawResponse}; +use multistore::proxy::{GatewayResponse, ProxyGateway}; +use multistore::registry::{BucketRegistry, CredentialRegistry, ResolvedBucket}; +use multistore::route_handler::RequestInfo; +use multistore::types::{ + BucketConfig, ResolvedIdentity, RoleConfig, S3Operation, StoredCredential, +}; +use object_store::list::PaginatedListStore; +use object_store::signer::Signer; + +/// ETag of the object the fake backend pretends already exists. +const STORED_ETAG: &str = "\"v1\""; + +/// Emulate S3's compare-and-swap: the object with `STORED_ETAG` is assumed to +/// exist, so return the status S3 would given the precondition headers on the +/// request — `412` on a failed precondition, `200` otherwise. +fn cas_status(headers: &HeaderMap) -> u16 { + let failed = match (headers.get("if-match"), headers.get("if-none-match")) { + // If-None-Match: * requires the object to be absent — it exists. + (_, Some(v)) if v == "*" => true, + // If-Match must equal the current ETag. + (Some(v), _) => v.to_str().unwrap_or("") != STORED_ETAG, + _ => false, + }; + if failed { + 412 + } else { + 200 + } +} + +/// Backend that emulates S3 compare-and-swap on the forwarded request headers. +/// +/// `forward` covers the presigned + aws-chunked streaming PutObject paths; +/// `send_raw` covers the raw-signed multipart completion path. Both read the +/// precondition off the outbound request, exactly where the real backend does. +#[derive(Clone)] +struct CasBackend; + +impl ProxyBackend for CasBackend { + type ResponseBody = (); + type Body = (); + + async fn forward( + &self, + request: multistore::route_handler::ForwardRequest, + _body: (), + ) -> Result, multistore::error::ProxyError> { + Ok(ForwardResponse { + status: cas_status(&request.headers), + headers: HeaderMap::new(), + body: (), + content_length: Some(0), + }) + } + + fn create_paginated_store( + &self, + _config: &BucketConfig, + ) -> Result, multistore::error::ProxyError> { + unimplemented!("not exercised by conditional-write tests") + } + + fn create_signer( + &self, + config: &BucketConfig, + ) -> Result, multistore::error::ProxyError> { + build_signer(config) + } + + async fn send_raw( + &self, + _method: Method, + _url: String, + headers: HeaderMap, + _body: Bytes, + ) -> Result { + Ok(RawResponse { + status: cas_status(&headers), + headers: HeaderMap::new(), + body: Bytes::new(), + }) + } +} + +#[derive(Clone)] +struct MockRegistry; + +impl BucketRegistry for MockRegistry { + async fn get_bucket( + &self, + name: &str, + _identity: &ResolvedIdentity, + _operation: &S3Operation, + ) -> Result { + Ok(ResolvedBucket { + config: test_bucket_config(name), + list_rewrite: None, + display_name: None, + }) + } + + async fn list_buckets( + &self, + _identity: &ResolvedIdentity, + ) -> Result, multistore::error::ProxyError> { + Ok(vec![]) + } +} + +#[derive(Clone)] +struct MockCreds; + +impl CredentialRegistry for MockCreds { + async fn get_credential( + &self, + _access_key_id: &str, + ) -> Result, multistore::error::ProxyError> { + Ok(None) + } + + async fn get_role( + &self, + _role_id: &str, + ) -> Result, multistore::error::ProxyError> { + Ok(None) + } +} + +fn test_bucket_config(name: &str) -> BucketConfig { + let mut backend_options = HashMap::new(); + backend_options.insert( + "endpoint".into(), + "https://s3.us-east-1.amazonaws.com".into(), + ); + backend_options.insert("bucket_name".into(), "backend-bucket".into()); + backend_options.insert("region".into(), "us-east-1".into()); + backend_options.insert("access_key_id".into(), "AKIAIOSFODNN7EXAMPLE".into()); + backend_options.insert( + "secret_access_key".into(), + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), + ); + BucketConfig { + name: name.to_string(), + backend_type: "s3".into(), + backend_prefix: None, + anonymous_access: true, + allowed_roles: vec![], + backend_options, + } +} + +fn run(f: F) -> F::Output { + futures::executor::block_on(f) +} + +/// PUT the object through the gateway with the given precondition headers and +/// return the status the client would see. +fn put_status(headers: HeaderMap) -> u16 { + let gw = ProxyGateway::new(CasBackend, MockRegistry, MockCreds, None); + let method = Method::PUT; + let req = RequestInfo::new(&method, "/test-bucket/key.txt", None, &headers, None); + let resp = run(gw.handle_request(&req, (), |b: ()| async move { + let () = b; + Ok::(Bytes::new()) + })); + match resp { + GatewayResponse::Response(r) => r.status, + GatewayResponse::Forward(f) => f.status, + } +} + +#[test] +fn put_with_wrong_if_match_returns_412() { + let mut headers = HeaderMap::new(); + headers.insert("if-match", "\"stale\"".parse().unwrap()); + assert_eq!( + put_status(headers), + 412, + "a PUT with a wrong If-Match must fail with 412 Precondition Failed, not silently succeed" + ); +} + +#[test] +fn put_with_matching_if_match_succeeds() { + let mut headers = HeaderMap::new(); + headers.insert("if-match", STORED_ETAG.parse().unwrap()); + assert_eq!( + put_status(headers), + 200, + "a PUT whose If-Match matches the current ETag must succeed" + ); +} + +#[test] +fn put_with_if_none_match_star_fails_when_object_exists() { + let mut headers = HeaderMap::new(); + headers.insert("if-none-match", "*".parse().unwrap()); + assert_eq!( + put_status(headers), + 412, + "If-None-Match: * must fail with 412 when the object already exists" + ); +} + +#[test] +fn put_without_precondition_succeeds() { + assert_eq!( + put_status(HeaderMap::new()), + 200, + "an unconditional PUT must succeed" + ); +} + +/// AWS SDKs and the CLI send `PutObject` bodies as `aws-chunked` streaming +/// uploads, which take the header-signed streaming path rather than the +/// presigned one. The precondition must reach (and be enforced by) the backend +/// there too — otherwise the fix would miss the most common real-world client. +fn streaming_put_status(mut headers: HeaderMap) -> u16 { + headers.insert( + "x-amz-content-sha256", + "STREAMING-UNSIGNED-PAYLOAD-TRAILER".parse().unwrap(), + ); + headers.insert("x-amz-decoded-content-length", "11".parse().unwrap()); + put_status(headers) +} + +#[test] +fn streaming_put_with_wrong_if_match_returns_412() { + let mut headers = HeaderMap::new(); + headers.insert("if-match", "\"stale\"".parse().unwrap()); + assert_eq!( + streaming_put_status(headers), + 412, + "an aws-chunked PUT with a wrong If-Match must fail with 412" + ); +} + +#[test] +fn streaming_put_with_if_none_match_star_fails_when_object_exists() { + let mut headers = HeaderMap::new(); + headers.insert("if-none-match", "*".parse().unwrap()); + assert_eq!( + streaming_put_status(headers), + 412, + "an aws-chunked If-None-Match: * must fail with 412 when the object exists" + ); +} + +#[test] +fn streaming_put_with_matching_if_match_succeeds() { + let mut headers = HeaderMap::new(); + headers.insert("if-match", STORED_ETAG.parse().unwrap()); + assert_eq!( + streaming_put_status(headers), + 200, + "an aws-chunked PUT whose If-Match matches must succeed" + ); +} + +/// A large object written via multipart completes with `CompleteMultipartUpload` +/// (`POST /{bucket}/{key}?uploadId=…`), which takes the raw-signed `send_raw` +/// path. The precondition must reach the backend there too, so a multipart write +/// gets the same compare-and-swap guarantee as a single-shot PutObject. +fn complete_mpu_status(headers: HeaderMap) -> u16 { + let gw = ProxyGateway::new(CasBackend, MockRegistry, MockCreds, None); + let method = Method::POST; + let req = RequestInfo::new( + &method, + "/test-bucket/key.txt", + Some("uploadId=test-upload"), + &headers, + None, + ); + let resp = run(gw.handle_request(&req, (), |b: ()| async move { + let () = b; + Ok::(Bytes::new()) + })); + match resp { + GatewayResponse::Response(r) => r.status, + GatewayResponse::Forward(f) => f.status, + } +} + +#[test] +fn complete_mpu_with_wrong_if_match_returns_412() { + let mut headers = HeaderMap::new(); + headers.insert("if-match", "\"stale\"".parse().unwrap()); + assert_eq!( + complete_mpu_status(headers), + 412, + "CompleteMultipartUpload with a wrong If-Match must fail with 412" + ); +} + +#[test] +fn complete_mpu_with_if_none_match_star_fails_when_object_exists() { + let mut headers = HeaderMap::new(); + headers.insert("if-none-match", "*".parse().unwrap()); + assert_eq!( + complete_mpu_status(headers), + 412, + "CompleteMultipartUpload If-None-Match: * must fail with 412 when the object exists" + ); +} + +#[test] +fn complete_mpu_with_matching_if_match_succeeds() { + let mut headers = HeaderMap::new(); + headers.insert("if-match", STORED_ETAG.parse().unwrap()); + assert_eq!( + complete_mpu_status(headers), + 200, + "CompleteMultipartUpload whose If-Match matches must succeed" + ); +} diff --git a/docs/reference/operations.md b/docs/reference/operations.md index dc5416b..4ca63eb 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -28,7 +28,15 @@ ### Writes and request headers -`PutObject` forwards the request body plus standard HTTP entity headers (`Content-Type`, `Content-Disposition`, `Content-Encoding`, `Content-Language`, `Cache-Control`, `Expires`, `Content-MD5`) to a presigned URL. `x-amz-*` headers (user metadata `x-amz-meta-*`, storage class, tagging, ACLs, SSE, and checksum headers such as `x-amz-checksum-*`) are **not** forwarded: S3 rejects unsigned `x-amz-*` headers on presigned requests, and the proxy presigns over `host` only. Supporting those headers requires a header-signing forward path — see the design note in `.plans/`. +`PutObject` forwards the request body plus standard HTTP entity headers (`Content-Type`, `Content-Disposition`, `Content-Encoding`, `Content-Language`, `Cache-Control`, `Expires`, `Content-MD5`) and the conditional-write preconditions (`If-Match`, `If-None-Match`) to a presigned URL. S3 applies all of these even though they are not part of the (host-only) presigned signature. `x-amz-*` headers (user metadata `x-amz-meta-*`, storage class, tagging, ACLs, SSE, checksum headers such as `x-amz-checksum-*`, and the `x-amz-copy-source-if-*` copy preconditions) are **not** forwarded: S3 rejects unsigned `x-amz-*` headers on presigned requests, and the proxy presigns over `host` only. Supporting those headers requires a header-signing forward path — see the design note in `.plans/`. + +**Conditional writes.** `If-Match` / `If-None-Match` are enforced by the backend: a write with a stale or wrong ETag fails with **412 Precondition Failed** (and `If-None-Match: *` fails when the object already exists) instead of silently clobbering. This is the compare-and-swap that native Zarr/Icechunk writers rely on to protect refs from concurrent commits, and it holds across every write path that produces an object: + +- **`PutObject`, presigned path** — plain bodies; preconditions forwarded unsigned (S3 applies them anyway). +- **`PutObject`, `aws-chunked` streaming path** — what AWS SDKs and the CLI use by default; preconditions forwarded *and* signed via the header-signing re-sign. +- **`CompleteMultipartUpload`** — the request that materializes a multipart object; preconditions forwarded (and signed, like all headers on the raw multipart path), so large multipart writes get the same guarantee. + +Either way the backend's 412 passes through unchanged. ## STS Operations diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 7c55b6d..17f5d3d 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -303,6 +303,201 @@ def test_copy_object_rejected(self): client.delete_object(Bucket="private-uploads", Key=src) # cleanup +# --------------------------------------------------------------------------- +# Conditional writes +# --------------------------------------------------------------------------- + +class TestConditionalWrites: + """Conditional-write preconditions (`If-Match` / `If-None-Match`) on PutObject. + + boto3's `put_object` streams the body as `aws-chunked`, so these exercise + the header-signed streaming forward path (the one AWS SDKs actually use) + end-to-end against MinIO, which enforces the precondition and returns 412. + This is the compare-and-swap native Zarr/Icechunk writers rely on to keep + concurrent commits from clobbering each other. + """ + + def test_if_none_match_star_blocks_overwrite(self): + """`If-None-Match: *` must fail with 412 when the object already exists, + and must NOT clobber the existing content (create-if-absent CAS).""" + client = static_client() + key = f"cond-inm-{uuid.uuid4()}.txt" + client.put_object(Bucket="private-uploads", Key=key, Body=b"original") + + with pytest.raises(ClientError) as exc_info: + client.put_object( + Bucket="private-uploads", + Key=key, + Body=b"should-not-land", + IfNoneMatch="*", + ) + assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412 + assert exc_info.value.response["Error"]["Code"] == "PreconditionFailed" + + # The precondition failure must have left the original object intact. + resp = client.get_object(Bucket="private-uploads", Key=key) + assert resp["Body"].read() == b"original" + + client.delete_object(Bucket="private-uploads", Key=key) + + def test_if_none_match_star_allows_create(self): + """`If-None-Match: *` must succeed when the object does not yet exist.""" + client = static_client() + key = f"cond-inm-new-{uuid.uuid4()}.txt" + client.put_object( + Bucket="private-uploads", + Key=key, + Body=b"fresh", + IfNoneMatch="*", + ) + resp = client.get_object(Bucket="private-uploads", Key=key) + assert resp["Body"].read() == b"fresh" + + client.delete_object(Bucket="private-uploads", Key=key) + + def test_if_match_wrong_etag_rejected(self): + """A PUT with a wrong `If-Match` must fail with 412 and not overwrite.""" + client = static_client() + key = f"cond-ifm-{uuid.uuid4()}.txt" + client.put_object(Bucket="private-uploads", Key=key, Body=b"original") + + with pytest.raises(ClientError) as exc_info: + client.put_object( + Bucket="private-uploads", + Key=key, + Body=b"should-not-land", + IfMatch='"0000000000000000deadbeef00000000"', + ) + assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412 + assert exc_info.value.response["Error"]["Code"] == "PreconditionFailed" + + resp = client.get_object(Bucket="private-uploads", Key=key) + assert resp["Body"].read() == b"original" + + client.delete_object(Bucket="private-uploads", Key=key) + + def test_if_match_correct_etag_succeeds(self): + """A PUT whose `If-Match` equals the current ETag must succeed and + replace the object (the successful side of the compare-and-swap).""" + client = static_client() + key = f"cond-ifm-ok-{uuid.uuid4()}.txt" + put = client.put_object(Bucket="private-uploads", Key=key, Body=b"v1") + etag = put["ETag"] + + client.put_object( + Bucket="private-uploads", + Key=key, + Body=b"v2", + IfMatch=etag, + ) + resp = client.get_object(Bucket="private-uploads", Key=key) + assert resp["Body"].read() == b"v2" + + client.delete_object(Bucket="private-uploads", Key=key) + + def _complete_multipart(self, client, key: str, body: bytes, **complete_kwargs): + """Drive a low-level multipart upload, passing extra kwargs (e.g. + IfMatch/IfNoneMatch) to CompleteMultipartUpload. + + If the completion fails (e.g. a 412 precondition), the MPU is left open + by the backend — abort it before propagating so repeated CI runs don't + accumulate stray incomplete uploads in the test bucket. + """ + create = client.create_multipart_upload(Bucket="private-uploads", Key=key) + upload_id = create["UploadId"] + parts = [] + # 5 MiB + remainder → at least two parts (S3 requires >=5 MiB per + # non-final part), forcing a real multipart completion. + chunks = [body[: 5 * MIB], body[5 * MIB :]] + for num, chunk in enumerate(chunks, start=1): + up = client.upload_part( + Bucket="private-uploads", + Key=key, + PartNumber=num, + UploadId=upload_id, + Body=chunk, + ) + parts.append({"PartNumber": num, "ETag": up["ETag"]}) + try: + return client.complete_multipart_upload( + Bucket="private-uploads", + Key=key, + UploadId=upload_id, + MultipartUpload={"Parts": parts}, + **complete_kwargs, + ) + except ClientError: + client.abort_multipart_upload( + Bucket="private-uploads", Key=key, UploadId=upload_id + ) + raise + + def test_multipart_complete_if_none_match_star_blocks_overwrite(self): + """`If-None-Match: *` on CompleteMultipartUpload must fail with 412 when + the object exists — a large multipart write gets the same CAS guarantee + as a single-shot PutObject.""" + client = static_client() + key = f"cond-mpu-inm-{uuid.uuid4()}.bin" + body = b"m" * (6 * MIB) + self._complete_multipart(client, key, body) # object now exists + + with pytest.raises(ClientError) as exc_info: + self._complete_multipart(client, key, body, IfNoneMatch="*") + assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412 + assert exc_info.value.response["Error"]["Code"] == "PreconditionFailed" + + client.delete_object(Bucket="private-uploads", Key=key) + + def test_multipart_complete_if_match_wrong_etag_rejected(self): + """A wrong `If-Match` on CompleteMultipartUpload must fail with 412.""" + client = static_client() + key = f"cond-mpu-ifm-{uuid.uuid4()}.bin" + body = b"m" * (6 * MIB) + self._complete_multipart(client, key, body) # object now exists + + with pytest.raises(ClientError) as exc_info: + self._complete_multipart( + client, + key, + body, + IfMatch='"0000000000000000deadbeef00000000"', + ) + assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412 + assert exc_info.value.response["Error"]["Code"] == "PreconditionFailed" + + client.delete_object(Bucket="private-uploads", Key=key) + + def test_upload_part_tolerates_leaked_precondition(self): + """`UploadPart` shares its `aws-chunked` forward list with the conditional + PutObject path. `If-Match`/`If-None-Match` don't apply to a part, and + boto3 can't even set them there, so the shared list is only safe if a + (hand-injected) precondition rides through the proxy's sign-and-forward + without breaking the upload — the backend ignores it. + + Inject a *signed* `If-Match` on every UploadPart and assert the multipart + upload still completes intact. Locks in the shared-list assumption at the + proxy level (a regression here would surface as a signature/4xx failure). + """ + client = static_client() + key = f"cond-part-leak-{uuid.uuid4()}.bin" + + def inject_if_match(request, **kwargs): + # before-sign → the header is part of the client's SigV4 signature, + # mirroring a client that genuinely (if pointlessly) sent it. + request.headers["If-Match"] = '"0000000000000000deadbeef00000000"' + + client.meta.events.register("before-sign.s3.UploadPart", inject_if_match) + + # 6 MiB → two parts, forcing a real multipart upload. + self._complete_multipart(client, key, b"p" * (6 * MIB)) + assert ( + client.head_object(Bucket="private-uploads", Key=key)["ContentLength"] + == 6 * MIB + ) + + client.delete_object(Bucket="private-uploads", Key=key) + + # --------------------------------------------------------------------------- # Multipart uploads # ---------------------------------------------------------------------------