From 0346203fa2530ffb21270cc7b65dcd8e8e5d7e89 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 12:16:17 -0700 Subject: [PATCH 1/5] feat: implement same-store CopyObject (x-amz-copy-source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CopyObject was rejected outright (501) since 0.6.0 because the presigned forward path drops the copy-source header and would overwrite the destination with an empty body. Delegate the copy to the backend S3 instead: a signed PUT to the destination carries x-amz-copy-source pointing at the source's backend bucket/key, via the same raw-signed send_raw path batch delete uses. Native S3 copy needs one endpoint that can read source and write destination, so this covers same-store copies (rename, metadata edit, cross-bucket within one account); cross-store and UploadPartCopy are rejected with a clear 501. - types.rs: add S3Operation::CopyObject (dest bucket/key + parsed source bucket/key/version); PUT method, PutObject action (dest write). Source read is authorized separately at dispatch. - api/request.rs: parse a copy-source PUT into CopyObject; parse_copy_source handles the `/bucket/key?versionId=` wire format and percent-decodes the key. UploadPartCopy (copy-source + uploadId) still 501s. - proxy.rs: execute_copy resolves+authorizes the source as a read, guards same_backing_store (same endpoint/region/creds), builds the backend copy-source header (build_copy_source_header re-encodes into the source key space), signs and sends the PUT, and passes the CopyObjectResult XML straight through. Copy-relevant client headers are forwarded and signed. - route_handler.rs: box HandlerAction::NeedsBody — the wider S3Operation pushed the enum past clippy's large-variant threshold. - docs: operations.md/errors.md now describe same-store copy, not a blanket rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/core/src/api/request.rs | 173 ++++++++++++++++++++--- crates/core/src/proxy.rs | 232 ++++++++++++++++++++++++++++++- crates/core/src/route_handler.rs | 3 +- crates/core/src/types.rs | 29 +++- docs/reference/errors.md | 2 +- docs/reference/operations.md | 9 +- 6 files changed, 422 insertions(+), 26 deletions(-) diff --git a/crates/core/src/api/request.rs b/crates/core/src/api/request.rs index b73a9c2..3ec280c 100644 --- a/crates/core/src/api/request.rs +++ b/crates/core/src/api/request.rs @@ -15,18 +15,6 @@ pub fn parse_s3_request( headers: &http::HeaderMap, host_style: HostStyle, ) -> Result { - // Server-side copy (CopyObject / UploadPartCopy) arrives as a PUT carrying - // `x-amz-copy-source`. It is not supported: forwarding such a request via a - // presigned URL would drop the copy-source header and silently overwrite the - // destination with an empty body. Reject it explicitly so the failure is - // unambiguous rather than corrupting data. See - // .plans/2026-06-23-data-edit-operations-design.md for the deferred design. - if *method == Method::PUT && headers.contains_key("x-amz-copy-source") { - return Err(ProxyError::NotImplemented( - "server-side copy (x-amz-copy-source) is not supported".into(), - )); - } - // GET / with path-style → ListBuckets (no bucket in path) if matches!(host_style, HostStyle::Path) && uri_path.trim_start_matches('/').is_empty() { if *method == Method::GET { @@ -44,9 +32,91 @@ pub fn parse_s3_request( } }; + // Server-side copy (CopyObject) arrives as a PUT carrying `x-amz-copy-source`. + // It cannot go through the presigned forward path (which would drop the + // header and overwrite the destination with an empty body), so it parses + // into its own operation handled by `execute_copy`. Callers that invoke + // `build_s3_operation` directly (custom resolvers) must replicate this + // check — `build_s3_operation` has no access to headers. + if *method == Method::PUT { + if let Some(copy_source) = headers.get("x-amz-copy-source") { + let copy_source = copy_source.to_str().map_err(|_| { + ProxyError::InvalidRequest("x-amz-copy-source is not valid UTF-8".into()) + })?; + return build_copy_object(bucket, key, copy_source, query); + } + } + build_s3_operation(method, bucket, key, query) } +/// Build a [`CopyObject`](S3Operation::CopyObject) from the destination +/// bucket/key and the `x-amz-copy-source` header value. +/// +/// `UploadPartCopy` (a copy-source PUT carrying `uploadId`/`partNumber`) is not +/// supported and is rejected as `NotImplemented` — server-side multipart copy +/// is a separate feature from single-request `CopyObject`. +fn build_copy_object( + dest_bucket: String, + dest_key: String, + copy_source: &str, + query: Option<&str>, +) -> Result { + let query_params = parse_query_params(query); + if query_params.iter().any(|(k, _)| k == "uploadId") { + // ponytail: UploadPartCopy deferred — same copy-source mechanism, but + // it targets an in-progress multipart upload. Add when a client needs + // server-side copy of objects larger than the 5 GB single-copy limit. + return Err(ProxyError::NotImplemented( + "server-side multipart copy (UploadPartCopy) is not supported".into(), + )); + } + + validate_key(&dest_key)?; + let (src_bucket, src_key, src_version) = parse_copy_source(copy_source)?; + validate_key(&src_key)?; + + Ok(S3Operation::CopyObject { + bucket: dest_bucket, + key: dest_key, + src_bucket, + src_key, + src_version, + }) +} + +/// Parse an `x-amz-copy-source` value into `(bucket, key, versionId)`. +/// +/// The wire format is `[/]sourcebucket/sourcekey[?versionId=id]` with the key +/// percent-encoded (per the S3 spec). The key is decoded to its client-visible +/// form here; the backend copy-source header re-encodes it against the backend +/// key space when the copy is dispatched. +fn parse_copy_source(raw: &str) -> Result<(String, String, Option), ProxyError> { + let s = raw.strip_prefix('/').unwrap_or(raw); + let (path, version) = match s.split_once("?versionId=") { + Some((p, v)) if !v.is_empty() => (p, Some(v.to_string())), + _ => (s, None), + }; + let (bucket, key_encoded) = path.split_once('/').ok_or_else(|| { + ProxyError::InvalidRequest("x-amz-copy-source must be `bucket/key`".into()) + })?; + if bucket.is_empty() { + return Err(ProxyError::InvalidRequest( + "x-amz-copy-source has an empty bucket".into(), + )); + } + let key = percent_encoding::percent_decode_str(key_encoded) + .decode_utf8() + .map_err(|_| ProxyError::InvalidRequest("x-amz-copy-source key is not valid UTF-8".into()))? + .into_owned(); + if key.is_empty() { + return Err(ProxyError::InvalidRequest( + "x-amz-copy-source has an empty key".into(), + )); + } + Ok((bucket.to_string(), key, version)) +} + /// Build an [`S3Operation`] from an already-extracted bucket, key, and query. /// /// This is used by both [`parse_s3_request`] and custom resolvers that parse @@ -99,8 +169,8 @@ pub fn build_s3_operation( part_number, }) } else { - // `x-amz-copy-source` (CopyObject / UploadPartCopy) is rejected - // upstream in `parse_s3_request`. Callers that invoke + // `x-amz-copy-source` (CopyObject) is handled upstream in + // `parse_s3_request` before it reaches here. Callers that invoke // `build_s3_operation` directly (custom resolvers) are // responsible for their own copy-source handling. // ponytail: deferred — trailer checksums (`x-amz-checksum-*`) @@ -256,16 +326,85 @@ mod tests { } #[test] - fn copy_source_put_is_rejected_not_implemented() { + fn copy_source_put_parses_as_copy_object() { let mut headers = http::HeaderMap::new(); headers.insert("x-amz-copy-source", "/src-bucket/src-key".parse().unwrap()); - let err = parse(Method::PUT, "/dst-bucket/dst-key", None, &headers).unwrap_err(); + let op = parse(Method::PUT, "/dst-bucket/dst-key", None, &headers).unwrap(); + assert!( + matches!( + op, + S3Operation::CopyObject { + ref bucket, + ref key, + ref src_bucket, + ref src_key, + src_version: None, + } if bucket == "dst-bucket" + && key == "dst-key" + && src_bucket == "src-bucket" + && src_key == "src-key" + ), + "copy-source PUT must parse as CopyObject, got {op:?}" + ); + } + + #[test] + fn copy_source_without_leading_slash_and_with_version_parses() { + let mut headers = http::HeaderMap::new(); + headers.insert( + "x-amz-copy-source", + "src-bucket/a%20b.txt?versionId=v1".parse().unwrap(), + ); + let op = parse(Method::PUT, "/dst/k", None, &headers).unwrap(); + match op { + S3Operation::CopyObject { + src_bucket, + src_key, + src_version, + .. + } => { + assert_eq!(src_bucket, "src-bucket"); + // The encoded key is decoded to its client-visible form. + assert_eq!(src_key, "a b.txt"); + assert_eq!(src_version.as_deref(), Some("v1")); + } + other => panic!("expected CopyObject, got {other:?}"), + } + } + + #[test] + fn upload_part_copy_is_rejected_not_implemented() { + let mut headers = http::HeaderMap::new(); + headers.insert("x-amz-copy-source", "/src/k".parse().unwrap()); + let err = parse( + Method::PUT, + "/dst/k", + Some("partNumber=1&uploadId=abc"), + &headers, + ) + .unwrap_err(); assert!( matches!(err, ProxyError::NotImplemented(_)), - "copy-source PUT must be rejected as NotImplemented, got {err:?}" + "UploadPartCopy must be NotImplemented, got {err:?}" ); } + #[test] + fn copy_source_with_malformed_value_is_rejected() { + let headers = |v: &str| { + let mut h = http::HeaderMap::new(); + h.insert("x-amz-copy-source", v.parse().unwrap()); + h + }; + for bad in ["", "/", "no-slash", "src-bucket/", "/src-bucket/"] { + let err = parse(Method::PUT, "/dst/k", None, &headers(bad)).unwrap_err(); + assert!( + matches!(err, ProxyError::InvalidRequest(_)), + "copy-source {bad:?} must be InvalidRequest, got {err:?}" + ); + } + } + #[test] fn plain_put_still_parses_as_put_object() { let op = parse(Method::PUT, "/b/k.txt", None, &http::HeaderMap::new()).unwrap(); diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index c7c9286..8ddd4d8 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -54,7 +54,7 @@ use crate::api::request::{self, HostStyle}; use crate::api::response::{BucketList, ErrorResponse, ListAllMyBucketsResult}; use crate::auth; use crate::auth::TemporaryCredentialResolver; -use crate::backend::multipart::{build_backend_url, sign_s3_request}; +use crate::backend::multipart::{build_backend_url, sign_s3_request, S3_PATH_ENCODE_SET}; use crate::backend::request_signer::{hash_payload, UNSIGNED_PAYLOAD}; use crate::backend::ForwardResponse; use crate::backend::ProxyBackend; @@ -561,7 +561,7 @@ where // re-read late (which would re-expose the cross-request hazard). match prebuffered.take() { Some(bytes) => { - let result = self.handle_with_body(pending, bytes).await; + let result = self.handle_with_body(*pending, bytes).await; let s = result.status; let rb = response_body_bytes(&result.body); ( @@ -1028,7 +1028,7 @@ where | S3Operation::CompleteMultipartUpload { .. } | S3Operation::AbortMultipartUpload { .. } => { Self::require_s3_backend(bucket_config)?; - Ok(HandlerAction::NeedsBody(pending())) + Ok(HandlerAction::NeedsBody(Box::new(pending()))) } // Batch delete needs the body to read the key list and authorize // each key individually. @@ -1042,7 +1042,38 @@ where // The body is buffered whole; bound it like other uploads. The // key count is additionally capped when the body is parsed. self.check_upload_size(original_headers)?; - Ok(HandlerAction::NeedsBody(pending())) + Ok(HandlerAction::NeedsBody(Box::new(pending()))) + } + S3Operation::CopyObject { + src_bucket, + src_key, + src_version, + .. + } => { + Self::require_s3_backend(bucket_config)?; + // Resolve + authorize the source as a read. Reusing get_bucket + // with a synthetic GetObject applies the exact authorization a + // real GET of the source object would — the copy's destination + // write was already authorized by the main pipeline. + let src_op = S3Operation::GetObject { + bucket: src_bucket.clone(), + key: src_key.clone(), + }; + let src = self + .bucket_registry + .get_bucket(src_bucket, ctx.identity, &src_op) + .await?; + let result = self + .execute_copy( + bucket_config, + &src.config, + operation, + src_key, + src_version.as_deref(), + original_headers, + ) + .await?; + Ok(HandlerAction::Response(result)) } _ => Err(ProxyError::Internal("unexpected operation".into())), } @@ -1504,6 +1535,155 @@ where body: ProxyResponseBody::from_bytes(Bytes::from(xml)), }) } + + /// Execute a server-side copy (`CopyObject`) via raw signed HTTP. + /// + /// The copy is delegated to the backend S3: a signed `PUT` to the + /// destination key carries `x-amz-copy-source` pointing at the source's + /// backend bucket/key. Only same-store copies (source and destination on + /// the same S3 endpoint + credentials) are supported — a native S3 copy + /// cannot reach across two distinct backends. + /// + /// The backend's `CopyObjectResult` XML (and any 4xx/5xx, including the + /// "error embedded in a 200" case) is passed straight through: it carries + /// only an ETag + LastModified, neither of which needs rewriting. + async fn execute_copy( + &self, + dest_config: &BucketConfig, + src_config: &BucketConfig, + operation: &S3Operation, + src_key: &str, + src_version: Option<&str>, + original_headers: &HeaderMap, + ) -> Result { + // ponytail: cross-store copy rejected, not streamed. A native S3 copy + // needs one endpoint that can read the source and write the + // destination. Upgrade path: proxy-side read-then-write streaming + // (with >5 GB handling and 200-embedded-error detection) when a real + // cross-store use case appears. + if !same_backing_store(src_config, dest_config) { + return Err(ProxyError::NotImplemented( + "cross-store copy (source and destination on different backends) is not supported" + .into(), + )); + } + + let copy_source = build_copy_source_header(src_config, src_key, src_version)?; + let backend_url = build_backend_url(dest_config, operation)?; + + let mut headers = HeaderMap::new(); + headers.insert( + "x-amz-copy-source", + copy_source.parse().map_err(|_| { + ProxyError::Internal("invalid x-amz-copy-source header value".into()) + })?, + ); + // Forward the copy-relevant client headers. Every header here is signed + // by `sign_s3_request`, so unlike the presigned PUT path, `x-amz-*` + // headers are safe to pass through. + for (name, val) in original_headers.iter() { + if is_copy_forward_header(name.as_str()) { + headers.insert(name.clone(), val.clone()); + } + } + headers.insert(http::header::USER_AGENT, self.user_agent.parse().unwrap()); + + // Empty request body: sign the hash of the empty payload. + let payload_hash = hash_payload(&[]); + sign_s3_request( + &Method::PUT, + &backend_url, + &mut headers, + dest_config, + &payload_hash, + )?; + + let raw_resp = self + .backend + .send_raw(Method::PUT, backend_url, headers, Bytes::new()) + .await?; + + tracing::debug!(status = raw_resp.status, "copy backend response"); + + Ok(ProxyResult { + status: raw_resp.status, + headers: filter_response_headers(&raw_resp.headers), + body: ProxyResponseBody::from_bytes(raw_resp.body), + }) + } +} + +/// Whether two bucket configs point at the same S3 backing store, such that a +/// native server-side copy from one to the other is possible. +/// +/// Requires both to be S3 backends sharing the same endpoint, region, and +/// credentials — then a `PUT` signed for the destination endpoint can name the +/// source bucket in `x-amz-copy-source` and S3 performs the copy internally. +/// The bucket names may differ (cross-bucket copy within one account). This is +/// deliberately conservative: two configs that resolve to the same physical +/// store via different credentials are treated as distinct. +fn same_backing_store(a: &BucketConfig, b: &BucketConfig) -> bool { + a.is_s3_backend() + && b.is_s3_backend() + && a.option("endpoint") == b.option("endpoint") + && a.option("region") == b.option("region") + && a.option("access_key_id") == b.option("access_key_id") + && a.option("secret_access_key") == b.option("secret_access_key") + && a.option("token") == b.option("token") +} + +/// Build the backend `x-amz-copy-source` header value for a same-store copy: +/// `/{backend_bucket}/{encoded backend key}[?versionId=id]`. +/// +/// The key is mapped into the source's backend key space (prefix applied) and +/// percent-encoded with the same strict set S3 uses for canonical paths. +fn build_copy_source_header( + src_config: &BucketConfig, + src_key: &str, + src_version: Option<&str>, +) -> Result { + let src_bucket = src_config.option("bucket_name").unwrap_or(""); + if src_bucket.is_empty() { + return Err(ProxyError::NotImplemented( + "copy from a backend without a bucket name is not supported".into(), + )); + } + let backend_key = apply_backend_prefix(src_config, src_key); + let encoded_key = + percent_encoding::utf8_percent_encode(&backend_key, S3_PATH_ENCODE_SET).to_string(); + let mut value = format!("/{src_bucket}/{encoded_key}"); + if let Some(version) = src_version { + value.push_str("?versionId="); + value.push_str(version); + } + Ok(value) +} + +/// Client request headers forwarded (and signed) onto a backend CopyObject PUT. +/// The copy-source header itself and the client's own SigV4 headers +/// (`authorization`, `x-amz-date`, `x-amz-content-sha256`, session token) are +/// deliberately excluded — the request is re-signed for the backend. +fn is_copy_forward_header(name: &str) -> bool { + matches!( + name, + "content-type" + | "content-disposition" + | "content-encoding" + | "content-language" + | "cache-control" + | "expires" + | "x-amz-metadata-directive" + | "x-amz-tagging-directive" + | "x-amz-tagging" + | "x-amz-acl" + | "x-amz-storage-class" + | "x-amz-website-redirect-location" + | "x-amz-copy-source-if-match" + | "x-amz-copy-source-if-none-match" + | "x-amz-copy-source-if-modified-since" + | "x-amz-copy-source-if-unmodified-since" + ) || name.starts_with("x-amz-meta-") + || name.starts_with("x-amz-server-side-encryption") } impl Dispatch for ProxyGateway @@ -2713,4 +2893,48 @@ mod tests { assert_eq!(err.status_code(), 400, "key {key:?} must be a 400"); } } + + #[test] + fn same_backing_store_matches_shared_endpoint_and_creds() { + // Two virtual buckets on the same endpoint/creds but different backend + // buckets are the same store — a cross-bucket copy is native. + let mut a = test_bucket_config("src"); + let mut b = test_bucket_config("dst"); + b.backend_options + .insert("bucket_name".into(), "other-backend-bucket".into()); + assert!(same_backing_store(&a, &b)); + + // Different endpoint → different store. + b.backend_options + .insert("endpoint".into(), "https://minio.example.com".into()); + assert!(!same_backing_store(&a, &b)); + + // Different credentials → different store (conservative). + let mut c = test_bucket_config("dst2"); + c.backend_options + .insert("access_key_id".into(), "AKIADIFFERENT".into()); + assert!(!same_backing_store(&a, &c)); + + // A non-S3 backend is never a copy-compatible store. + a.backend_type = "azure".into(); + let d = test_bucket_config("dst3"); + assert!(!same_backing_store(&a, &d)); + } + + #[test] + fn copy_source_header_encodes_backend_key_and_version() { + let mut config = test_bucket_config("src"); + config.backend_prefix = Some("data/".into()); + let value = build_copy_source_header(&config, "a b/c=d.txt", Some("v9")).unwrap(); + // Prefix applied, space and `=` percent-encoded, `/` preserved. + assert_eq!(value, "/backend-bucket/data/a%20b/c%3Dd.txt?versionId=v9"); + } + + #[test] + fn copy_source_header_without_bucket_name_is_rejected() { + let mut config = test_bucket_config("src"); + config.backend_options.remove("bucket_name"); + let err = build_copy_source_header(&config, "k", None).unwrap_err(); + assert!(matches!(err, ProxyError::NotImplemented(_))); + } } diff --git a/crates/core/src/route_handler.rs b/crates/core/src/route_handler.rs index a7389d5..ba62e9a 100644 --- a/crates/core/src/route_handler.rs +++ b/crates/core/src/route_handler.rs @@ -48,7 +48,8 @@ pub enum HandlerAction { Forward(ForwardRequest), /// The handler needs the request body to continue (multipart operations). /// The runtime should materialize the body and call `handle_with_body`. - NeedsBody(PendingRequest), + /// Boxed: `PendingRequest` is far larger than the other variants. + NeedsBody(Box), } /// A presigned URL request for the runtime to execute. diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 01497c1..90d36c7 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -380,6 +380,25 @@ pub enum S3Operation { bucket: String, key: String, }, + /// Server-side copy (`PUT /{bucket}/{key}` carrying `x-amz-copy-source`). + /// + /// Carries both the destination (`bucket`/`key`) and the parsed source + /// (`src_bucket`/`src_key`/`src_version`). Unlike every other operation, + /// a copy touches two virtual buckets: the destination is authorized as a + /// write via [`action`](S3Operation::action) on the main pipeline, and the + /// source is authorized as a read separately when the copy is dispatched. + CopyObject { + /// Destination virtual bucket. + bucket: String, + /// Destination key. + key: String, + /// Source virtual bucket, from `x-amz-copy-source`. + src_bucket: String, + /// Source key (percent-decoded, client-visible), from `x-amz-copy-source`. + src_key: String, + /// Optional source `versionId` from `x-amz-copy-source`. + src_version: Option, + }, /// Batch delete (`POST /{bucket}?delete`). The keys to delete live in the /// request body, so this operation carries only the bucket — the body is /// parsed and each key authorized individually once it arrives. @@ -405,7 +424,9 @@ impl S3Operation { | S3Operation::ListBucket { .. } | S3Operation::ListBuckets => http::Method::GET, S3Operation::HeadObject { .. } => http::Method::HEAD, - S3Operation::PutObject { .. } | S3Operation::UploadPart { .. } => http::Method::PUT, + S3Operation::PutObject { .. } + | S3Operation::UploadPart { .. } + | S3Operation::CopyObject { .. } => http::Method::PUT, S3Operation::DeleteObject { .. } | S3Operation::AbortMultipartUpload { .. } => { http::Method::DELETE } @@ -420,7 +441,9 @@ impl S3Operation { match self { S3Operation::GetObject { .. } => Action::GetObject, S3Operation::HeadObject { .. } => Action::HeadObject, - S3Operation::PutObject { .. } => Action::PutObject, + // A copy's destination is a write; the source read is authorized + // separately at dispatch (see `execute_copy`). + S3Operation::PutObject { .. } | S3Operation::CopyObject { .. } => Action::PutObject, S3Operation::ListBucket { .. } => Action::ListBucket, S3Operation::CreateMultipartUpload { .. } => Action::CreateMultipartUpload, S3Operation::UploadPart { .. } => Action::UploadPart, @@ -446,6 +469,7 @@ impl S3Operation { | S3Operation::CompleteMultipartUpload { bucket, .. } | S3Operation::AbortMultipartUpload { bucket, .. } | S3Operation::DeleteObject { bucket, .. } + | S3Operation::CopyObject { bucket, .. } | S3Operation::DeleteObjects { bucket } => Some(bucket), S3Operation::ListBuckets => None, } @@ -461,6 +485,7 @@ impl S3Operation { | S3Operation::UploadPart { key, .. } | S3Operation::CompleteMultipartUpload { key, .. } | S3Operation::AbortMultipartUpload { key, .. } + | S3Operation::CopyObject { key, .. } | S3Operation::DeleteObject { key, .. } => key, S3Operation::ListBucket { .. } | S3Operation::ListBuckets diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 927c63b..b839f58 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -24,7 +24,7 @@ The proxy returns S3-compatible error responses in XML format: | RoleNotFound | 403 | `AccessDenied` | Requested role doesn't exist in config | | InvalidRequest | 400 | `InvalidRequest` | Malformed S3 request | | MalformedXml | 400 | `MalformedXML` | Request body XML is malformed, empty, or exceeds limits (e.g. a batch delete naming no objects or more than 1000 keys) | -| NotImplemented | 501 | `NotImplemented` | Recognized but unsupported operation (e.g. server-side copy via `x-amz-copy-source`) | +| NotImplemented | 501 | `NotImplemented` | Recognized but unsupported operation (e.g. cross-store `CopyObject`, `UploadPartCopy`) | | EntityTooLarge | 400 | `EntityTooLarge` | Upload `Content-Length` exceeds the configured maximum body size | | BackendError | 503 | `ServiceUnavailable` | Backend object store is unreachable or returned an error | | PreconditionFailed | 412 | `PreconditionFailed` | Conditional request failed (If-Match, etc.) | diff --git a/docs/reference/operations.md b/docs/reference/operations.md index dc5416b..1f3c524 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -7,6 +7,7 @@ | GetObject | `GET /{bucket}/{key}` | Forward | Download a file | | HeadObject | `HEAD /{bucket}/{key}` | Forward | Get file metadata | | PutObject | `PUT /{bucket}/{key}` | Forward | Upload a file | +| CopyObject | `PUT /{bucket}/{key}` + `x-amz-copy-source` | Response | Server-side copy within one backing store | | DeleteObject | `DELETE /{bucket}/{key}` | Forward | Delete a file | | DeleteObjects | `POST /{bucket}?delete` | NeedsBody | Batch-delete up to 1000 keys (`aws s3 rm --recursive`, `delete_objects`) | | ListBucket | `GET /{bucket}` | Response | List objects in a bucket (ListObjectsV1 and V2) | @@ -26,6 +27,12 @@ `DeleteObjects` carries its keys in the request body, so authorization happens in two stages: the bucket-level check confirms the caller may delete *something* in the bucket, then **each key in the body is authorized individually** against the caller's allowed prefixes. Keys the caller is not permitted to delete are returned as per-key `AccessDenied` entries in the `DeleteResult` (S3's partial-result semantics) and are never forwarded to the backend; authorized keys are deleted regardless. Anonymous callers cannot batch-delete. +### Server-side copy + +`CopyObject` is a `PUT /{bucket}/{key}` carrying an `x-amz-copy-source: /{src-bucket}/{src-key}[?versionId=…]` header. The proxy resolves and authorizes **both** ends — the source as a read (`GetObject`) and the destination as a write (`PutObject`) — then delegates the copy to the backend: a signed `PUT` to the destination key carries `x-amz-copy-source` rewritten into the source's backend bucket/key space. The backend's `CopyObjectResult` XML (and any error, including S3's "error embedded in a `200 OK`" case) is passed straight through. Copy-relevant client headers are forwarded and signed: `x-amz-metadata-directive`, `x-amz-tagging-directive`, `x-amz-tagging`, `x-amz-acl`, `x-amz-storage-class`, `x-amz-website-redirect-location`, `x-amz-meta-*`, `x-amz-server-side-encryption*`, and the `x-amz-copy-source-if-*` preconditions. + +**Same-store only.** A native S3 copy needs one endpoint that can read the source and write the destination, so source and destination must resolve to the same S3 backend — same endpoint, region, and credentials (the backend bucket names may differ, so cross-bucket copies within one account work). A cross-store copy, or a copy from a backend without a `bucket_name`, is rejected with `501 NotImplemented`. `UploadPartCopy` (a copy-source `PUT` with `uploadId`/`partNumber`) is likewise not supported. + ### 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/`. @@ -52,7 +59,7 @@ Handled by OIDC discovery closures (registered on the `Router` via `OidcRouterEx > [!WARNING] > - **Multipart and batch delete are S3 only** — Both use raw HTTP with `S3RequestSigner` and are gated to `backend_type = "s3"`. Non-S3 backends should use single PUT/DELETE requests. > - **DeleteObject does not return confirmation** — The proxy forwards the DELETE and returns the backend's response status. -> - **Server-side copy is not supported** — A `PUT` carrying `x-amz-copy-source` (CopyObject / UploadPartCopy) is rejected with `501 NotImplemented` rather than silently overwriting the destination. +> - **Server-side copy is same-store only** — `CopyObject` works when source and destination resolve to the same S3 backend (see "Server-side copy" above). A cross-store copy, or `UploadPartCopy`, is rejected with `501 NotImplemented`. > - **`x-amz-*` write headers are dropped** — Object metadata, storage class, tagging, ACLs, SSE, and checksum headers on writes are not forwarded (see "Writes and request headers" above). > - **Versioned/MFA delete is not handled** — A `?versionId=` on a delete is ignored; the current object version is deleted. > - **Degenerate object keys are rejected** — Keys containing empty, `.`, or `..` path segments (including leading/trailing slashes, e.g. `dir/` folder markers), or ASCII control characters, return `400 InvalidRequest` on every keyed operation. Real S3 accepts such keys; the proxy is deliberately stricter because they cannot be addressed consistently across its presigned and raw-signed backend paths. Batch-delete body keys are exempt, as the remediation route for legacy keys already stored under such names. From c702c3f8ece4032c73a44302ed101067225856f7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 15:03:09 -0700 Subject: [PATCH 2/5] fix: parse empty versionId in x-amz-copy-source without corrupting key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty `versionId` (`bucket/key?versionId=`) failed the `!v.is_empty()` match guard and fell through to the fallback arm, which used the untrimmed string — leaving `?versionId=` glued onto the source key and silently copying from a non-existent key instead of erroring. Always split on the path; treat an empty version as `None`. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/api/request.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/core/src/api/request.rs b/crates/core/src/api/request.rs index 3ec280c..7481147 100644 --- a/crates/core/src/api/request.rs +++ b/crates/core/src/api/request.rs @@ -94,8 +94,8 @@ fn build_copy_object( fn parse_copy_source(raw: &str) -> Result<(String, String, Option), ProxyError> { let s = raw.strip_prefix('/').unwrap_or(raw); let (path, version) = match s.split_once("?versionId=") { - Some((p, v)) if !v.is_empty() => (p, Some(v.to_string())), - _ => (s, None), + Some((p, v)) => (p, (!v.is_empty()).then(|| v.to_string())), + None => (s, None), }; let (bucket, key_encoded) = path.split_once('/').ok_or_else(|| { ProxyError::InvalidRequest("x-amz-copy-source must be `bucket/key`".into()) @@ -372,6 +372,28 @@ mod tests { } } + #[test] + fn copy_source_with_empty_version_id_parses_key_without_suffix() { + let mut headers = http::HeaderMap::new(); + headers.insert( + "x-amz-copy-source", + "src-bucket/key?versionId=".parse().unwrap(), + ); + let op = parse(Method::PUT, "/dst/k", None, &headers).unwrap(); + match op { + S3Operation::CopyObject { + src_key, + src_version, + .. + } => { + // The `?versionId=` suffix must be stripped, not glued onto the key. + assert_eq!(src_key, "key"); + assert_eq!(src_version, None); + } + other => panic!("expected CopyObject, got {other:?}"), + } + } + #[test] fn upload_part_copy_is_rejected_not_implemented() { let mut headers = http::HeaderMap::new(); From ce60991e3b3522435295219d488553f7155ba40c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 15:57:04 -0700 Subject: [PATCH 3/5] test: end-to-end integration tests for same-store CopyObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three tests driving the full copy path through resolve_request against the CaptureHeadersBackend, mirroring the multipart integration test: - copy_object_end_to_end_sends_resigned_backend_put: parse → authorize dest+source → same-store check → build backend copy-source → re-sign → send_raw, asserting the exact wire request (rewritten/encoded copy-source, forwarded copy-relevant headers, empty-payload hash, copy headers present in SignedHeaders). - copy_object_forwards_version_id_to_backend: versionId rides through. - cross_store_copy_is_rejected_501: different backends → 501, backend never contacted. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/proxy.rs | 145 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index 8ddd4d8..28b676f 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -2937,4 +2937,149 @@ mod tests { let err = build_copy_source_header(&config, "k", None).unwrap_err(); assert!(matches!(err, ProxyError::NotImplemented(_))); } + + /// End-to-end same-store `CopyObject`: a `PUT` carrying `x-amz-copy-source` + /// drives a re-signed backend `PUT` with an empty body. Exercises the whole + /// path — parse → authorize destination (as `PutObject`) → authorize source + /// (as `GetObject`) → same-store check → build backend copy-source → sign → + /// `send_raw` — and pins the wire request the backend actually receives. + #[test] + fn copy_object_end_to_end_sends_resigned_backend_put() { + run(async { + let captured = Arc::new(std::sync::Mutex::new(None)); + let backend = CaptureHeadersBackend { + captured: captured.clone(), + }; + let gw = ProxyGateway::new(backend, MockRegistry, MockCreds, None); + + let mut headers = HeaderMap::new(); + // Wire key is percent-encoded per the S3 spec (space → %20). + headers.insert( + "x-amz-copy-source", + "/src-bucket/src%20key.txt".parse().unwrap(), + ); + // Copy-relevant client headers must be forwarded AND signed. + headers.insert("x-amz-metadata-directive", "REPLACE".parse().unwrap()); + headers.insert("x-amz-meta-team", "platform".parse().unwrap()); + + let action = gw + .resolve_request(Method::PUT, "/dst-bucket/dst-key.txt", None, &headers, None) + .await; + + let status = match action { + HandlerAction::Response(resp) => resp.status, + other => panic!( + "expected Response, got {:?}", + std::mem::discriminant(&other) + ), + }; + assert_eq!(status, 200, "same-store copy returns the backend's status"); + + let sent = captured + .lock() + .unwrap() + .clone() + .expect("backend was called"); + + // Copy-source is decoded, mapped into the source's backend bucket/key + // space, then re-encoded with S3's canonical path set. + assert_eq!( + sent.get("x-amz-copy-source").unwrap(), + "/backend-bucket/src%20key.txt" + ); + // Copy-relevant client headers reached the backend. + assert_eq!(sent.get("x-amz-metadata-directive").unwrap(), "REPLACE"); + assert_eq!(sent.get("x-amz-meta-team").unwrap(), "platform"); + // Empty body: the signed payload hash is sha256(""). + assert_eq!( + sent.get("x-amz-content-sha256").unwrap(), + hash_payload(&[]).as_str() + ); + // The backend request carries a fresh proxy signature (the copy is + // re-signed with backend credentials, never the client's)... + let auth = sent.get("authorization").unwrap().to_str().unwrap(); + assert!( + auth.starts_with("AWS4-HMAC-SHA256 Credential="), + "expected re-signed Authorization, got: {auth}" + ); + // ...and the copy-relevant headers are part of SignedHeaders (else S3 + // silently ignores the copy-source and the copy does nothing). + assert!( + auth.contains("x-amz-copy-source") + && auth.contains("x-amz-metadata-directive") + && auth.contains("x-amz-meta-team"), + "copy headers missing from SignedHeaders: {auth}" + ); + }); + } + + /// A `versionId` on the copy-source rides through to the backend copy-source. + #[test] + fn copy_object_forwards_version_id_to_backend() { + run(async { + let captured = Arc::new(std::sync::Mutex::new(None)); + let gw = ProxyGateway::new( + CaptureHeadersBackend { + captured: captured.clone(), + }, + MockRegistry, + MockCreds, + None, + ); + let mut headers = HeaderMap::new(); + headers.insert( + "x-amz-copy-source", + "/src-bucket/obj.txt?versionId=v42".parse().unwrap(), + ); + let action = gw + .resolve_request(Method::PUT, "/dst-bucket/dst.txt", None, &headers, None) + .await; + assert!(matches!(action, HandlerAction::Response(_))); + let sent = captured + .lock() + .unwrap() + .clone() + .expect("backend was called"); + assert_eq!( + sent.get("x-amz-copy-source").unwrap(), + "/backend-bucket/obj.txt?versionId=v42" + ); + }); + } + + /// A cross-store copy (source resolves to a different backend) cannot be a + /// native S3 copy, so it is rejected with `501` and the backend is never + /// contacted — no bytes are streamed through the proxy. + #[test] + fn cross_store_copy_is_rejected_501() { + run(async { + let captured = Arc::new(std::sync::Mutex::new(None)); + let gw = ProxyGateway::new( + CaptureHeadersBackend { + captured: captured.clone(), + }, + MockRegistry, + MockCreds, + None, + ); + let mut headers = HeaderMap::new(); + // `azure-*` names resolve to a non-S3 backend in the test registry, + // so source and destination are on different stores. + headers.insert("x-amz-copy-source", "/azure-src/obj.txt".parse().unwrap()); + let action = gw + .resolve_request(Method::PUT, "/dst-bucket/dst.txt", None, &headers, None) + .await; + match action { + HandlerAction::Response(resp) => assert_eq!(resp.status, 501), + other => panic!( + "expected 501 Response, got {:?}", + std::mem::discriminant(&other) + ), + } + assert!( + captured.lock().unwrap().is_none(), + "backend must not be contacted for a rejected cross-store copy" + ); + }); + } } From 50acf88a010c854e9f6cae58dd4e2dae63f7f8c9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 16:39:53 -0700 Subject: [PATCH 4/5] test(integration): assert same-store CopyObject succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `test_copy_object_rejected` test asserted the pre-implementation behavior (all copies → 501). Same-store copy now works, so replace it with two tests exercised live against MinIO through the Workers gateway: - test_copy_object_same_bucket: copy within one bucket returns 200 and the destination has the source bytes. - test_copy_object_cross_bucket_same_store: copy across two virtual buckets on the same backing store (public-data → private-uploads) is a native same-store copy and succeeds. Cross-store rejection stays covered by the Rust unit test (cross_store_copy_is_rejected_501); the integration env has a single backend. Co-Authored-By: Claude Opus 4.8 --- tests/integration/test_integration.py | 50 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 7c55b6d..7477c8d 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -281,26 +281,46 @@ def test_batch_delete_partial_authorization(self): full.delete_object(Bucket="private-uploads", Key=denied_key) # cleanup - def test_copy_object_rejected(self): - """Server-side copy (x-amz-copy-source) is rejected with 501 - NotImplemented and does not create the destination.""" + def test_copy_object_same_bucket(self): + """Server-side copy (x-amz-copy-source) within one bucket succeeds and + the destination has the source's content.""" client = static_client() src = f"copy-src-{uuid.uuid4()}.txt" dst = f"copy-dst-{uuid.uuid4()}.txt" - client.put_object(Bucket="private-uploads", Key=src, Body=b"source") - with pytest.raises(ClientError) as exc: - client.copy_object( - Bucket="private-uploads", - Key=dst, - CopySource={"Bucket": "private-uploads", "Key": src}, - ) - assert exc.value.response["Error"]["Code"] == "NotImplemented", exc.value.response - assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 501 - # The destination must not have been created. - with pytest.raises(ClientError): - client.head_object(Bucket="private-uploads", Key=dst) + client.put_object(Bucket="private-uploads", Key=src, Body=b"source-bytes") + resp = client.copy_object( + Bucket="private-uploads", + Key=dst, + CopySource={"Bucket": "private-uploads", "Key": src}, + ) + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert resp["CopyObjectResult"]["ETag"] + # The destination now exists with the copied bytes. + got = client.get_object(Bucket="private-uploads", Key=dst) + assert got["Body"].read() == b"source-bytes" client.delete_object(Bucket="private-uploads", Key=src) # cleanup + client.delete_object(Bucket="private-uploads", Key=dst) + + def test_copy_object_cross_bucket_same_store(self): + """A copy across two virtual buckets that resolve to the same backing + store (same endpoint/credentials) is a native same-store copy and + succeeds — the backend bucket names differing does not matter.""" + client = static_client() + src = f"copy-src-{uuid.uuid4()}.txt" + dst = f"copy-dst-{uuid.uuid4()}.txt" + client.put_object(Bucket="public-data", Key=src, Body=b"cross-bucket") + resp = client.copy_object( + Bucket="private-uploads", + Key=dst, + CopySource={"Bucket": "public-data", "Key": src}, + ) + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + got = client.get_object(Bucket="private-uploads", Key=dst) + assert got["Body"].read() == b"cross-bucket" + + client.delete_object(Bucket="public-data", Key=src) # cleanup + client.delete_object(Bucket="private-uploads", Key=dst) # --------------------------------------------------------------------------- From ac13ffe62dc4796f63aa3d1bbc1a43b3df977073 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 21 Jul 2026 22:03:15 -0700 Subject: [PATCH 5/5] feat: forward copy-source SSE-C headers on CopyObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To read an SSE-C-encrypted source, a CopyObject must carry the x-amz-copy-source-server-side-encryption-customer-* headers so the backend can decrypt it. These start with x-amz-copy-source-..., so they slipped past the allowlist's x-amz-server-side-encryption* prefix (which only matches the destination SSE headers) and were dropped before signing — an SSE-C source would 400 against real S3. - proxy.rs: add x-amz-copy-source-server-side-encryption prefix to is_copy_forward_header; label both SSE prefixes (dest vs source). - proxy.rs: extend copy_object_end_to_end_sends_resigned_backend_put to send a source SSE-C header and assert it reaches the backend and is signed (fails without the fix). - test_smoke.py: TestCopyObjectSseCSource — real-S3 regression, gated by SMOKE_WRITE_BUCKET. MinIO refuses SSE-C over the Docker loop's plaintext HTTP, so this can only run against real S3. - docs: note both SSE prefixes in the forwarded copy-header list. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/proxy.rs | 18 +++++++++++++- docs/reference/operations.md | 2 +- tests/smoke/test_smoke.py | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index 28b676f..9341316 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -1683,7 +1683,10 @@ fn is_copy_forward_header(name: &str) -> bool { | "x-amz-copy-source-if-modified-since" | "x-amz-copy-source-if-unmodified-since" ) || name.starts_with("x-amz-meta-") + // Destination SSE (incl. SSE-C `-customer-*`, encrypting the copy). || name.starts_with("x-amz-server-side-encryption") + // Source SSE-C (`-customer-*`, decrypting an SSE-C-encrypted source). + || name.starts_with("x-amz-copy-source-server-side-encryption") } impl Dispatch for ProxyGateway @@ -2961,6 +2964,11 @@ mod tests { // Copy-relevant client headers must be forwarded AND signed. headers.insert("x-amz-metadata-directive", "REPLACE".parse().unwrap()); headers.insert("x-amz-meta-team", "platform".parse().unwrap()); + // Source SSE-C: needed to decrypt an SSE-C-encrypted source object. + headers.insert( + "x-amz-copy-source-server-side-encryption-customer-algorithm", + "AES256".parse().unwrap(), + ); let action = gw .resolve_request(Method::PUT, "/dst-bucket/dst-key.txt", None, &headers, None) @@ -2990,6 +2998,13 @@ mod tests { // Copy-relevant client headers reached the backend. assert_eq!(sent.get("x-amz-metadata-directive").unwrap(), "REPLACE"); assert_eq!(sent.get("x-amz-meta-team").unwrap(), "platform"); + // Source SSE-C header reached the backend (dropping it would make an + // SSE-C-encrypted source unreadable and the copy 400 against S3). + assert_eq!( + sent.get("x-amz-copy-source-server-side-encryption-customer-algorithm") + .unwrap(), + "AES256" + ); // Empty body: the signed payload hash is sha256(""). assert_eq!( sent.get("x-amz-content-sha256").unwrap(), @@ -3007,7 +3022,8 @@ mod tests { assert!( auth.contains("x-amz-copy-source") && auth.contains("x-amz-metadata-directive") - && auth.contains("x-amz-meta-team"), + && auth.contains("x-amz-meta-team") + && auth.contains("x-amz-copy-source-server-side-encryption-customer-algorithm"), "copy headers missing from SignedHeaders: {auth}" ); }); diff --git a/docs/reference/operations.md b/docs/reference/operations.md index 1f3c524..a2e8a56 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -29,7 +29,7 @@ ### Server-side copy -`CopyObject` is a `PUT /{bucket}/{key}` carrying an `x-amz-copy-source: /{src-bucket}/{src-key}[?versionId=…]` header. The proxy resolves and authorizes **both** ends — the source as a read (`GetObject`) and the destination as a write (`PutObject`) — then delegates the copy to the backend: a signed `PUT` to the destination key carries `x-amz-copy-source` rewritten into the source's backend bucket/key space. The backend's `CopyObjectResult` XML (and any error, including S3's "error embedded in a `200 OK`" case) is passed straight through. Copy-relevant client headers are forwarded and signed: `x-amz-metadata-directive`, `x-amz-tagging-directive`, `x-amz-tagging`, `x-amz-acl`, `x-amz-storage-class`, `x-amz-website-redirect-location`, `x-amz-meta-*`, `x-amz-server-side-encryption*`, and the `x-amz-copy-source-if-*` preconditions. +`CopyObject` is a `PUT /{bucket}/{key}` carrying an `x-amz-copy-source: /{src-bucket}/{src-key}[?versionId=…]` header. The proxy resolves and authorizes **both** ends — the source as a read (`GetObject`) and the destination as a write (`PutObject`) — then delegates the copy to the backend: a signed `PUT` to the destination key carries `x-amz-copy-source` rewritten into the source's backend bucket/key space. The backend's `CopyObjectResult` XML (and any error, including S3's "error embedded in a `200 OK`" case) is passed straight through. Copy-relevant client headers are forwarded and signed: `x-amz-metadata-directive`, `x-amz-tagging-directive`, `x-amz-tagging`, `x-amz-acl`, `x-amz-storage-class`, `x-amz-website-redirect-location`, `x-amz-meta-*`, `x-amz-server-side-encryption*` (destination SSE, including SSE-C), `x-amz-copy-source-server-side-encryption*` (source SSE-C, to decrypt an SSE-C-encrypted source), and the `x-amz-copy-source-if-*` preconditions. **Same-store only.** A native S3 copy needs one endpoint that can read the source and write the destination, so source and destination must resolve to the same S3 backend — same endpoint, region, and credentials (the backend bucket names may differ, so cross-bucket copies within one account work). A cross-store copy, or a copy from a backend without a `bucket_name`, is rejected with `501 NotImplemented`. `UploadPartCopy` (a copy-source `PUT` with `uploadId`/`partNumber`) is likewise not supported. diff --git a/tests/smoke/test_smoke.py b/tests/smoke/test_smoke.py index d8248bd..de1ff9b 100644 --- a/tests/smoke/test_smoke.py +++ b/tests/smoke/test_smoke.py @@ -323,3 +323,51 @@ def test_head_object_with_cacheable_extension(self, actions_credentials): assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 finally: client.delete_object(Bucket=WRITE_BUCKET, Key=key) + + +@requires_write_bucket +class TestCopyObjectSseCSource: + """Same-store CopyObject with an SSE-C-encrypted source, against real S3. + + Regression guard for the copy-source SSE-C headers. To read an + SSE-C-encrypted source, the copy must carry + `x-amz-copy-source-server-side-encryption-customer-{algorithm,key,key-MD5}` + so the backend can decrypt it. If the proxy's copy-header allowlist drops + them (they don't match the destination `x-amz-server-side-encryption*` + prefix), S3 can't decrypt the source and rejects the copy with 400. + + MinIO (the integration backend) refuses SSE-C over the plaintext HTTP the + Docker loop uses, so this can only run against real S3 here. boto3 sends the + raw 32-byte key; the SDK base64-encodes it and computes the key MD5. + """ + + def test_copy_object_with_sse_c_source(self, actions_credentials): + client = s3_client(actions_credentials) + src = f"smoke-ssec-src-{uuid.uuid4()}.txt" + dst = f"smoke-ssec-dst-{uuid.uuid4()}.txt" + # AES256 requires a 32-byte customer key. + sse_key = b"0123456789abcdef0123456789abcdef" + body = b"sse-c-source-bytes" + try: + client.put_object( + Bucket=WRITE_BUCKET, + Key=src, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=sse_key, + ) + # Dest stored unencrypted; only the source decrypt headers matter + # here — dropping them makes this copy 400 before the fix. + resp = client.copy_object( + Bucket=WRITE_BUCKET, + Key=dst, + CopySource={"Bucket": WRITE_BUCKET, "Key": src}, + CopySourceSSECustomerAlgorithm="AES256", + CopySourceSSECustomerKey=sse_key, + ) + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + got = client.get_object(Bucket=WRITE_BUCKET, Key=dst) + assert got["Body"].read() == body + finally: + client.delete_object(Bucket=WRITE_BUCKET, Key=src) + client.delete_object(Bucket=WRITE_BUCKET, Key=dst)