Skip to content

feat: implement same-store CopyObject (x-amz-copy-source)#121

Merged
alukach merged 5 commits into
mainfrom
worktree-copyobject
Jul 22, 2026
Merged

feat: implement same-store CopyObject (x-amz-copy-source)#121
alukach merged 5 commits into
mainfrom
worktree-copyobject

Conversation

@alukach

@alukach alukach commented Jul 21, 2026

Copy link
Copy Markdown
Member

What I'm changing

CopyObject has been rejected outright with 501 NotImplemented since 0.6.0 (#88): the presigned forward path drops the x-amz-copy-source header, so a forwarded copy PUT would silently overwrite the destination with an empty body. Rejecting was the safe stopgap; this PR implements the operation.

The key constraint is architectural — multistore forwards each request to exactly one backing store, but a copy has a source and a destination. A native S3 server-side copy only works when both resolve to the same S3 backend (one endpoint that can read the source and write the destination). That covers the overwhelming majority of real copies: rename, metadata edit, and cross-bucket copies within one account. Cross-store copy would require proxy-side read-then-write streaming (with >5 GB handling and S3's "error embedded in a 200 OK" case) and is deferred — rejected with a clear 501 for now.

How I did it

  • crates/core/src/types.rs — new S3Operation::CopyObject variant carrying the destination (bucket/key) plus the parsed source (src_bucket/src_key/src_version). method() = PUT, action() = PutObject (the destination write; the source read is authorized separately at dispatch). Added the arm to the four S3Operation impls.
  • crates/core/src/api/request.rs — a PUT carrying x-amz-copy-source now parses into CopyObject instead of being rejected. parse_copy_source handles the [/]bucket/key[?versionId=…] wire format and percent-decodes the key to its client-visible form. UploadPartCopy (copy-source + uploadId/partNumber) remains 501 NotImplemented.
  • crates/core/src/proxy.rsexecute_copy resolves and authorizes the source as a read (a synthetic GetObject through get_bucket, reusing the exact GET authorization), guards same_backing_store (both S3, same endpoint/region/credentials — backend bucket names may differ), then signs and sends a PUT via the existing send_raw raw-signed path (same mechanism as batch delete). build_copy_source_header rewrites the source into its backend key space and re-encodes it with S3's strict path set. The backend's CopyObjectResult XML — and any error, including the 200-embedded case — is passed straight through (it carries only ETag + LastModified, neither of which needs rewriting). Copy-relevant client headers (x-amz-metadata-directive, x-amz-tagging*, x-amz-copy-source-if-*, x-amz-meta-*, SSE, ACL, storage-class) are forwarded and signed.
  • crates/core/src/route_handler.rs — boxed HandlerAction::NeedsBody(Box<PendingRequest>). The wider S3Operation pushed the enum past clippy's large_enum_variant threshold; boxing the (less-hot) multipart/batch-delete variant is the localized fix.
  • docs/operations.md and errors.md now document same-store copy and its limits instead of a blanket rejection.

Test plan

  • cargo fmt --check
  • cargo clippy (native) — clean
  • cargo clippy --target wasm32-unknown-unknown -p multistore-cf-workers — clean
  • cargo test -p multistore — 135 pass, incl. new parse tests (copy-source with/without leading slash, versionId, encoded key, malformed values, UploadPartCopy rejection) and unit tests for same_backing_store and build_copy_source_header
  • Not yet exercised against a live S3 backend end-to-end (no integration harness in-tree)

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @alukach's task in 2m 44s —— View job


I'll analyze this and get back to you.


💰 Estimated review cost: $1.04 · 2m44s · 25 turns

@github-actions github-actions Bot added the feat label Jul 21, 2026
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

📖 Docs preview deployed to https://multistore-docs-pr-121.development-seed.workers.dev

  • Date: 2026-07-22T05:05:49Z
  • Commit: be521d5

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-121.development-seed.workers.dev

  • Date: 2026-07-22T05:05:49Z
  • Commit: be521d5

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@alukach
alukach marked this pull request as ready for review July 22, 2026 04:51
require_s3_backend now guards CopyObject as well as multipart, but its message
was hardcoded to "multipart operations not supported", so a CopyObject to a
non-S3 destination surfaced a misleading error. The helper has no operation
context and shouldn't pretend to — drop the operation name rather than thread
it through. Status (InvalidRequest) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alukach
alukach merged commit 02a79b0 into main Jul 22, 2026
16 checks passed
@alukach
alukach deleted the worktree-copyobject branch July 22, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant