Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 178 additions & 17 deletions crates/core/src/api/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,6 @@ pub fn parse_s3_request(
headers: &http::HeaderMap,
host_style: HostStyle,
) -> Result<S3Operation, ProxyError> {
// 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 {
Expand All @@ -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<S3Operation, ProxyError> {
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<String>), ProxyError> {
let s = raw.strip_prefix('/').unwrap_or(raw);
let (path, version) = match s.split_once("?versionId=") {
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())
})?;
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
Expand Down Expand Up @@ -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-*`)
Expand Down Expand Up @@ -256,16 +326,107 @@ 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 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();
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();
Expand Down
Loading
Loading