From 2d9ea0285c75bd4a29453de5f127bcbf19b56b83 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 8 Aug 2026 21:24:30 -0700 Subject: [PATCH] s3: add the RenameObject endpoint (#10659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * s3: add the RenameObject endpoint PUT /{bucket}/{key}?renameObject with x-amz-rename-source moves an object through the filer's AtomicRenameEntry, so no bytes are read or rewritten and the ETag, tags and SSE keys travel with the entry. Only unversioned buckets: a versioned rename would have to rebuild the .versions chain, and AWS offers RenameObject on directory buckets, which cannot be versioned. The source arrives in a header, so it is authorized separately for read and delete; both keys are locked, in key order, across the precondition checks and the move. * s3: let a matched source ETag precondition settle its date precondition RFC 7232 has an ETag precondition outrank the date precondition on its own side, and AWS documents the same for CopyObject: a matching x-amz-copy-source-if-match with a failing x-amz-copy-source-if-unmodified-since copies rather than returning 412. The source check evaluated all four headers in sequence, so the date header could still veto a decided ETag match. validateConditionalHeadersForReads already applies this precedence; the source path now matches it. * s3: cover a rename source named as a directory without a trailing slash Renaming a directory would move a whole subtree, so it has to stay a missing key whether or not the caller wrote the trailing slash. * s3: accept a bare object key as the RenameObject source AWS spells x-amz-rename-source both ways. Its CLI, Java and Rust examples pass the bare source key, and only a second CLI example and the boto3 conditional example pass bucket/key; the API reference's own example is a bare key too. The header was read as bucket/key only, so the form AWS leads with was rejected with InvalidArgument and the endpoint was unusable as documented. A value is now read as a literal key first — the only reading that can never name the wrong object — and as bucket-qualified second, when it carries the request's own bucket and the literal key does not exist. That costs one extra lookup only for a source that starts with the bucket's own name. Another bucket's name in the source is no longer a distinct error: RenameObject moves within one bucket, so it is simply part of a key this bucket does not hold, and it reports NoSuchKey. * s3: only a proven absence picks the other reading of a rename source A source that resolves to a directory is not a miss to fall through on: the literal path is still what the caller named, so answering for it beats renaming a different object under the bucket-qualified reading. With a directory at bucket/source.txt and an object at source.txt, a rename naming the former moved the latter. A failed lookup is not a proof of absence either, so a blip can no longer redirect a rename to the other reading. --- test/s3/copying/s3_rename_test.go | 331 ++++++++++++++++++ weed/s3api/auth_credentials.go | 12 +- weed/s3api/iam_batch_delete_test.go | 16 +- weed/s3api/s3_constants/header.go | 7 + weed/s3api/s3api_object_handlers_copy.go | 74 ++-- weed/s3api/s3api_object_handlers_delete.go | 4 +- weed/s3api/s3api_object_handlers_rename.go | 251 +++++++++++++ .../s3api_object_handlers_rename_test.go | 163 +++++++++ weed/s3api/s3api_server.go | 3 + weed/s3api/s3err/s3api_errors.go | 13 + 10 files changed, 829 insertions(+), 45 deletions(-) create mode 100644 test/s3/copying/s3_rename_test.go create mode 100644 weed/s3api/s3api_object_handlers_rename.go create mode 100644 weed/s3api/s3api_object_handlers_rename_test.go diff --git a/test/s3/copying/s3_rename_test.go b/test/s3/copying/s3_rename_test.go new file mode 100644 index 000000000..881f4cca0 --- /dev/null +++ b/test/s3/copying/s3_rename_test.go @@ -0,0 +1,331 @@ +package copying_test + +import ( + "context" + "errors" + "fmt" + "net/url" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createRenameSource builds the x-amz-rename-source value the way the AWS CLI, +// Java and Rust examples do: the bare source key, URL encoded. +func createRenameSource(key string) string { + return url.PathEscape(key) +} + +// createQualifiedRenameSource builds the alternate bucket/key form the second +// AWS CLI example and the boto3 conditional example use. +func createQualifiedRenameSource(bucketName, key string) string { + return fmt.Sprintf("%s/%s", bucketName, url.PathEscape(key)) +} + +func renameObject(t *testing.T, client *s3.Client, bucketName, srcKey, dstKey string) { + t.Helper() + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(dstKey), + RenameSource: aws.String(createRenameSource(srcKey)), + }) + require.NoError(t, err) +} + +// requireRenameStatus asserts err carries the given HTTP status code. +func requireRenameStatus(t *testing.T, err error, status int) { + t.Helper() + require.Error(t, err) + var respErr *smithyhttp.ResponseError + require.True(t, errors.As(err, &respErr), "expected an HTTP response error, got %v", err) + assert.Equal(t, status, respErr.HTTPStatusCode(), "unexpected error: %v", err) +} + +func objectExists(t *testing.T, client *s3.Client, bucketName, key string) bool { + t.Helper() + _, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + return err == nil +} + +// TestRenameObject renames an object and checks the bytes, metadata and ETag +// arrive under the new key while the old key disappears. +func TestRenameObject(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + content := "rename me" + put := putObjectWithMetadata(t, client, bucketName, "source.txt", content, + map[string]string{"origin": "source"}, "text/plain") + + renameObject(t, client, bucketName, "source.txt", "renamed/target.txt") + + assert.False(t, objectExists(t, client, bucketName, "source.txt"), "source should be gone") + + resp := getObject(t, client, bucketName, "renamed/target.txt") + assert.Equal(t, content, getObjectBody(t, resp)) + assert.Equal(t, "text/plain", aws.ToString(resp.ContentType)) + assert.Equal(t, "source", resp.Metadata["origin"]) + assert.Equal(t, aws.ToString(put.ETag), aws.ToString(resp.ETag), "ETag must survive the rename") +} + +// TestRenameObjectOverwritesDestination: without a conditional header a rename +// replaces whatever the destination key held. +func TestRenameObjectOverwritesDestination(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + putObject(t, client, bucketName, "source.txt", "new content") + putObject(t, client, bucketName, "target.txt", "old content") + + renameObject(t, client, bucketName, "source.txt", "target.txt") + + resp := getObject(t, client, bucketName, "target.txt") + assert.Equal(t, "new content", getObjectBody(t, resp)) +} + +// TestRenameObjectIfNoneMatch: If-None-Match: * protects an existing destination. +func TestRenameObjectIfNoneMatch(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + putObject(t, client, bucketName, "source.txt", "new content") + putObject(t, client, bucketName, "target.txt", "old content") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createRenameSource("source.txt")), + DestinationIfNoneMatch: aws.String("*"), + }) + requireRenameStatus(t, err, 412) + + resp := getObject(t, client, bucketName, "target.txt") + assert.Equal(t, "old content", getObjectBody(t, resp)) + assert.True(t, objectExists(t, client, bucketName, "source.txt"), "a failed rename must leave the source alone") + + // The same rename onto a free key succeeds. + _, err = client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("free.txt"), + RenameSource: aws.String(createRenameSource("source.txt")), + DestinationIfNoneMatch: aws.String("*"), + }) + require.NoError(t, err) +} + +// TestRenameObjectSourceIfMatch gates the rename on the source's ETag. +func TestRenameObjectSourceIfMatch(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + put := putObject(t, client, bucketName, "source.txt", "content") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createRenameSource("source.txt")), + SourceIfMatch: aws.String("\"00000000000000000000000000000000\""), + }) + requireRenameStatus(t, err, 412) + assert.True(t, objectExists(t, client, bucketName, "source.txt")) + + _, err = client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createRenameSource("source.txt")), + SourceIfMatch: put.ETag, + }) + require.NoError(t, err) + assert.False(t, objectExists(t, client, bucketName, "source.txt")) + assert.True(t, objectExists(t, client, bucketName, "target.txt")) +} + +// TestRenameObjectOntoDirectory: a key that already holds other objects is a +// directory, and an object must not be allowed to replace one. +func TestRenameObjectOntoDirectory(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + putObject(t, client, bucketName, "source.txt", "content") + putObject(t, client, bucketName, "target/child.txt", "child") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target"), + RenameSource: aws.String(createRenameSource("source.txt")), + }) + requireRenameStatus(t, err, 409) + assert.True(t, objectExists(t, client, bucketName, "source.txt")) + assert.True(t, objectExists(t, client, bucketName, "target/child.txt")) +} + +// TestRenameObjectDirectorySource: a directory can be named without a trailing +// slash, and renaming one would move a whole subtree. It is not an object, so it +// is a missing key. +func TestRenameObjectDirectorySource(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + putObject(t, client, bucketName, "source/child.txt", "child") + + for _, src := range []string{"source", "source/"} { + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createRenameSource(src)), + }) + requireRenameStatus(t, err, 404) + } + assert.True(t, objectExists(t, client, bucketName, "source/child.txt")) + assert.False(t, objectExists(t, client, bucketName, "target.txt")) +} + +// TestRenameObjectMissingSource reports a missing source as NoSuchKey. +func TestRenameObjectMissingSource(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createRenameSource("absent.txt")), + }) + requireRenameStatus(t, err, 404) +} + +// TestRenameObjectQualifiedSource: the bucket/key form AWS's second CLI example +// and the boto3 conditional example use resolves to the same object as the bare +// key. +func TestRenameObjectQualifiedSource(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + putObject(t, client, bucketName, "dir/source.txt", "content") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createQualifiedRenameSource(bucketName, "dir/source.txt")), + }) + require.NoError(t, err) + assert.False(t, objectExists(t, client, bucketName, "dir/source.txt")) + assert.Equal(t, "content", getObjectBody(t, getObject(t, client, bucketName, "target.txt"))) +} + +// TestRenameObjectSourceShadowingTheBucketName: a key whose own first segment is +// the bucket name is a real key, and must win over reading the same value as a +// bucket-qualified source. +func TestRenameObjectSourceShadowingTheBucketName(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + shadowed := bucketName + "/source.txt" + putObject(t, client, bucketName, shadowed, "shadowed") + putObject(t, client, bucketName, "source.txt", "bare") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createQualifiedRenameSource(bucketName, "source.txt")), + }) + require.NoError(t, err) + assert.Equal(t, "shadowed", getObjectBody(t, getObject(t, client, bucketName, "target.txt"))) + assert.False(t, objectExists(t, client, bucketName, shadowed)) + assert.True(t, objectExists(t, client, bucketName, "source.txt"), "the bare key must be left alone") +} + +// TestRenameObjectSourceShadowedByADirectory: the literal reading of the source +// names a directory here, and the bucket-qualified reading names a live object. +// Naming a directory is an error about that directory — falling through to the +// other reading would rename a different object than the one asked for. +func TestRenameObjectSourceShadowedByADirectory(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + putObject(t, client, bucketName, bucketName+"/source.txt/child.txt", "child") + putObject(t, client, bucketName, "source.txt", "bare") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createQualifiedRenameSource(bucketName, "source.txt")), + }) + requireRenameStatus(t, err, 404) + assert.True(t, objectExists(t, client, bucketName, "source.txt"), "the bare key must be left alone") + assert.False(t, objectExists(t, client, bucketName, "target.txt")) +} + +// TestRenameObjectCrossBucket: RenameObject moves within one bucket, so another +// bucket's name in the source is just part of a key this bucket does not hold. +func TestRenameObjectCrossBucket(t *testing.T) { + client := getS3Client(t) + srcBucket := getNewBucketName() + createBucket(t, client, srcBucket) + defer deleteBucket(t, client, srcBucket) + dstBucket := getNewBucketName() + createBucket(t, client, dstBucket) + defer deleteBucket(t, client, dstBucket) + + putObject(t, client, srcBucket, "source.txt", "content") + + _, err := client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(dstBucket), + Key: aws.String("target.txt"), + RenameSource: aws.String(createQualifiedRenameSource(srcBucket, "source.txt")), + }) + requireRenameStatus(t, err, 404) + assert.True(t, objectExists(t, client, srcBucket, "source.txt")) +} + +// TestRenameObjectVersionedBucket: versioned buckets are not supported yet, and +// must say so rather than silently dropping versions. +func TestRenameObjectVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ + Bucket: aws.String(bucketName), + VersioningConfiguration: &types.VersioningConfiguration{Status: types.BucketVersioningStatusEnabled}, + }) + require.NoError(t, err) + + putObject(t, client, bucketName, "source.txt", "content") + + _, err = client.RenameObject(context.TODO(), &s3.RenameObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("target.txt"), + RenameSource: aws.String(createRenameSource("source.txt")), + }) + requireRenameStatus(t, err, 501) + assert.True(t, objectExists(t, client, bucketName, "source.txt")) +} diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 10f0afb2f..37fcc26fb 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -1671,7 +1671,7 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac // Batch DeleteObjects keys arrive in the body, not the URL: a bucket-level check // here can't match object-scoped policies. DeleteMultipleObjectsHandler authorizes - // each key via AuthorizeBatchDeleteKey. + // each key via AuthorizeObjectDelete. if action == s3_constants.ACTION_WRITE && r.Method == http.MethodPost && object == "" && r.URL.Query().Has("delete") { r.Header.Set(s3_constants.AmzAccountId, identity.Account.Id) @@ -2739,11 +2739,11 @@ func (iam *IdentityAccessManagement) AuthorizeCopySource(r *http.Request, identi return iam.VerifyActionPermission(srcReq, identity, Action(action), srcBucket, srcObject) } -// AuthorizeBatchDeleteKey authorizes one key from a DeleteObjects body. The route -// Auth middleware only authenticated the caller (keys arrive in the body, not the -// URL), so each key is checked here against a synthetic DELETE // that -// makes ResolveS3Action and buildResourceARN target the object. Mirrors AuthorizeCopySource. -func (iam *IdentityAccessManagement) AuthorizeBatchDeleteKey(r *http.Request, identity *Identity, bucket, objectKey, versionId string) s3err.ErrorCode { +// AuthorizeObjectDelete authorizes removing one key the request URL does not +// name: a key from a DeleteObjects body, or the source of a RenameObject. It is +// checked against a synthetic DELETE // so that ResolveS3Action and +// buildResourceARN target the object. Mirrors AuthorizeCopySource. +func (iam *IdentityAccessManagement) AuthorizeObjectDelete(r *http.Request, identity *Identity, bucket, objectKey, versionId string) s3err.ErrorCode { if !iam.isEnabled() { return s3err.ErrNone } diff --git a/weed/s3api/iam_batch_delete_test.go b/weed/s3api/iam_batch_delete_test.go index 17772f7dc..c235f6ff5 100644 --- a/weed/s3api/iam_batch_delete_test.go +++ b/weed/s3api/iam_batch_delete_test.go @@ -9,10 +9,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestAuthorizeBatchDeleteKey_AwsCanonicalPolicy: a policy granting s3:DeleteObject +// TestAuthorizeObjectDelete_AwsCanonicalPolicy: a policy granting s3:DeleteObject // on /* must allow per-key batch deletes. Pre-fix the bucket-level check // built arn:aws:s3::: and never matched the object-scoped policy. -func TestAuthorizeBatchDeleteKey_AwsCanonicalPolicy(t *testing.T) { +func TestAuthorizeObjectDelete_AwsCanonicalPolicy(t *testing.T) { const bucket = "test-bucket" const policyName = "delete-test-bucket-objects" @@ -43,17 +43,17 @@ func TestAuthorizeBatchDeleteKey_AwsCanonicalPolicy(t *testing.T) { r := httptest.NewRequest("POST", "/"+bucket+"?delete", nil) require.Equal(t, s3err.ErrNone, - iam.AuthorizeBatchDeleteKey(r, identity, bucket, "objects/a.txt", ""), + iam.AuthorizeObjectDelete(r, identity, bucket, "objects/a.txt", ""), "s3:DeleteObject on arn:aws:s3:::%s/* must allow deleting %s/objects/a.txt", bucket, bucket) require.Equal(t, s3err.ErrAccessDenied, - iam.AuthorizeBatchDeleteKey(r, identity, "other-bucket", "objects/a.txt", ""), + iam.AuthorizeObjectDelete(r, identity, "other-bucket", "objects/a.txt", ""), "keys outside the granted bucket must be denied") } -// TestAuthorizeBatchDeleteKey_PrefixScopedPolicy: a prefix-scoped policy must allow +// TestAuthorizeObjectDelete_PrefixScopedPolicy: a prefix-scoped policy must allow // batch deletes under the prefix and deny keys outside it, per-key. -func TestAuthorizeBatchDeleteKey_PrefixScopedPolicy(t *testing.T) { +func TestAuthorizeObjectDelete_PrefixScopedPolicy(t *testing.T) { const bucket = "test-bucket" const policyName = "delete-prefix-only" @@ -84,10 +84,10 @@ func TestAuthorizeBatchDeleteKey_PrefixScopedPolicy(t *testing.T) { r := httptest.NewRequest("POST", "/"+bucket+"?delete", nil) require.Equal(t, s3err.ErrNone, - iam.AuthorizeBatchDeleteKey(r, identity, bucket, "safe/inside.txt", ""), + iam.AuthorizeObjectDelete(r, identity, bucket, "safe/inside.txt", ""), "key under granted prefix must be allowed") require.Equal(t, s3err.ErrAccessDenied, - iam.AuthorizeBatchDeleteKey(r, identity, bucket, "danger/outside.txt", ""), + iam.AuthorizeObjectDelete(r, identity, bucket, "danger/outside.txt", ""), "key outside the granted prefix must be denied per-key, not at the batch level") } diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index a41c7ab36..aa13c6df5 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -106,6 +106,13 @@ const ( AmzCopySourceIfModifiedSince = "X-Amz-Copy-Source-If-Modified-Since" AmzCopySourceIfUnmodifiedSince = "X-Amz-Copy-Source-If-Unmodified-Since" + // RenameObject + AmzRenameSource = "X-Amz-Rename-Source" + AmzRenameSourceIfMatch = "X-Amz-Rename-Source-If-Match" + AmzRenameSourceIfNoneMatch = "X-Amz-Rename-Source-If-None-Match" + AmzRenameSourceIfModifiedSince = "X-Amz-Rename-Source-If-Modified-Since" + AmzRenameSourceIfUnmodifiedSince = "X-Amz-Rename-Source-If-Unmodified-Since" + // S3 Server-Side Encryption with Customer-provided Keys (SSE-C) AmzServerSideEncryptionCustomerAlgorithm = "X-Amz-Server-Side-Encryption-Customer-Algorithm" AmzServerSideEncryptionCustomerKey = "X-Amz-Server-Side-Encryption-Customer-Key" diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go index 3ae75be90..846e4b7bc 100644 --- a/weed/s3api/s3api_object_handlers_copy.go +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -1502,56 +1502,72 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e // Helper methods for copy operations to avoid code duplication +// sourceConditionalHeaderNames names the four headers an operation uses to make +// itself conditional on the state of its source object. CopyObject spells them +// x-amz-copy-source-if-*, RenameObject x-amz-rename-source-if-*. +type sourceConditionalHeaderNames struct { + ifMatch string + ifNoneMatch string + ifModifiedSince string + ifUnmodifiedSince string +} + +var copySourceConditionalHeaders = sourceConditionalHeaderNames{ + ifMatch: s3_constants.AmzCopySourceIfMatch, + ifNoneMatch: s3_constants.AmzCopySourceIfNoneMatch, + ifModifiedSince: s3_constants.AmzCopySourceIfModifiedSince, + ifUnmodifiedSince: s3_constants.AmzCopySourceIfUnmodifiedSince, +} + // validateConditionalCopyHeaders validates the conditional copy headers against the source entry func (s3a *S3ApiServer) validateConditionalCopyHeaders(r *http.Request, entry *filer_pb.Entry) s3err.ErrorCode { - sourceETag := copyEntryETag(entry) + return validateSourceConditionalHeaders(r, entry, copySourceConditionalHeaders) +} - // Check X-Amz-Copy-Source-If-Match - if ifMatch := r.Header.Get(s3_constants.AmzCopySourceIfMatch); ifMatch != "" { - // Remove quotes if present - ifMatch = strings.Trim(ifMatch, `"`) - sourceETag = strings.Trim(sourceETag, `"`) - glog.V(3).Infof("CopyObjectHandler: If-Match check - expected %s, got %s", ifMatch, sourceETag) - if ifMatch != sourceETag { - glog.V(3).Infof("CopyObjectHandler: If-Match failed - expected %s, got %s", ifMatch, sourceETag) - return s3err.ErrPreconditionFailed - } +// validateSourceConditionalHeaders evaluates the conditional headers against an +// already-resolved source entry, so the source is known to exist here. +// +// The evaluation order is RFC 7232's, the same one validateConditionalHeadersForReads +// applies: an ETag precondition wins over the date precondition on its own side, so a +// matched If-Match makes If-Unmodified-Since moot and a passed If-None-Match makes +// If-Modified-Since moot. AWS documents that precedence for CopyObject too — a +// matching x-amz-copy-source-if-match with a failing x-amz-copy-source-if-unmodified-since +// copies rather than returning 412. +func validateSourceConditionalHeaders(r *http.Request, entry *filer_pb.Entry, names sourceConditionalHeaderNames) s3err.ErrorCode { + sourceETag := strings.Trim(copyEntryETag(entry), `"`) + ifMatch := strings.Trim(r.Header.Get(names.ifMatch), `"`) + ifNoneMatch := strings.Trim(r.Header.Get(names.ifNoneMatch), `"`) + + if ifMatch != "" && ifMatch != "*" && ifMatch != sourceETag { + glog.V(3).Infof("%s failed - expected %s, got %s", names.ifMatch, ifMatch, sourceETag) + return s3err.ErrPreconditionFailed } - // Check X-Amz-Copy-Source-If-None-Match - if ifNoneMatch := r.Header.Get(s3_constants.AmzCopySourceIfNoneMatch); ifNoneMatch != "" { - // Remove quotes if present - ifNoneMatch = strings.Trim(ifNoneMatch, `"`) - sourceETag = strings.Trim(sourceETag, `"`) - glog.V(3).Infof("CopyObjectHandler: If-None-Match check - comparing %s with %s", ifNoneMatch, sourceETag) - if ifNoneMatch == sourceETag { - glog.V(3).Infof("CopyObjectHandler: If-None-Match failed - matched %s", sourceETag) - return s3err.ErrPreconditionFailed - } + if ifNoneMatch != "" && (ifNoneMatch == "*" || ifNoneMatch == sourceETag) { + glog.V(3).Infof("%s failed - matched %s", names.ifNoneMatch, sourceETag) + return s3err.ErrPreconditionFailed } - // Check X-Amz-Copy-Source-If-Modified-Since - if ifModifiedSince := r.Header.Get(s3_constants.AmzCopySourceIfModifiedSince); ifModifiedSince != "" { + if ifModifiedSince := r.Header.Get(names.ifModifiedSince); ifModifiedSince != "" && ifNoneMatch == "" { t, err := parseHTTPDate(ifModifiedSince) if err != nil { - glog.V(3).Infof("CopyObjectHandler: Invalid If-Modified-Since header: %v", err) + glog.V(3).Infof("invalid %s header: %v", names.ifModifiedSince, err) return s3err.ErrInvalidRequest } if !time.Unix(entry.Attributes.Mtime, 0).After(t) { - glog.V(3).Infof("CopyObjectHandler: If-Modified-Since failed") + glog.V(3).Infof("%s failed", names.ifModifiedSince) return s3err.ErrPreconditionFailed } } - // Check X-Amz-Copy-Source-If-Unmodified-Since - if ifUnmodifiedSince := r.Header.Get(s3_constants.AmzCopySourceIfUnmodifiedSince); ifUnmodifiedSince != "" { + if ifUnmodifiedSince := r.Header.Get(names.ifUnmodifiedSince); ifUnmodifiedSince != "" && ifMatch == "" { t, err := parseHTTPDate(ifUnmodifiedSince) if err != nil { - glog.V(3).Infof("CopyObjectHandler: Invalid If-Unmodified-Since header: %v", err) + glog.V(3).Infof("invalid %s header: %v", names.ifUnmodifiedSince, err) return s3err.ErrInvalidRequest } if time.Unix(entry.Attributes.Mtime, 0).After(t) { - glog.V(3).Infof("CopyObjectHandler: If-Unmodified-Since failed") + glog.V(3).Infof("%s failed", names.ifUnmodifiedSince) return s3err.ErrPreconditionFailed } } diff --git a/weed/s3api/s3api_object_handlers_delete.go b/weed/s3api/s3api_object_handlers_delete.go index 826f37199..bd60d05ef 100644 --- a/weed/s3api/s3api_object_handlers_delete.go +++ b/weed/s3api/s3api_object_handlers_delete.go @@ -442,7 +442,7 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h deletedCount := 0 // Per-key authorization: keys arrive in the body, so the route Auth middleware - // only authenticated. Authorize each key via AuthorizeBatchDeleteKey below. + // only authenticated. Authorize each key via AuthorizeObjectDelete below. var identity *Identity if id := s3_constants.GetIdentityFromContext(r); id != nil { identity, _ = id.(*Identity) @@ -462,7 +462,7 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h deleteErrors = append(deleteErrors, deleteErrorFromCode(s3err.ErrAccessDenied, object.Key, object.VersionId)) continue } - if authErr := s3a.iam.AuthorizeBatchDeleteKey(r, identity, bucket, object.Key, object.VersionId); authErr != s3err.ErrNone { + if authErr := s3a.iam.AuthorizeObjectDelete(r, identity, bucket, object.Key, object.VersionId); authErr != s3err.ErrNone { deleteErrors = append(deleteErrors, deleteErrorFromCode(authErr, object.Key, object.VersionId)) continue } diff --git a/weed/s3api/s3api_object_handlers_rename.go b/weed/s3api/s3api_object_handlers_rename.go new file mode 100644 index 000000000..8ac6c0be6 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_rename.go @@ -0,0 +1,251 @@ +package s3api + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" + "github.com/seaweedfs/seaweedfs/weed/util" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var renameSourceConditionalHeaders = sourceConditionalHeaderNames{ + ifMatch: s3_constants.AmzRenameSourceIfMatch, + ifNoneMatch: s3_constants.AmzRenameSourceIfNoneMatch, + ifModifiedSince: s3_constants.AmzRenameSourceIfModifiedSince, + ifUnmodifiedSince: s3_constants.AmzRenameSourceIfUnmodifiedSince, +} + +// RenameObjectHandler implements RenameObject: +// +// PUT /{bucket}/{destination key}?renameObject +// x-amz-rename-source: /{bucket}/{source key} +// +// The object is moved by the filer's AtomicRenameEntry, so its bytes are never +// read or rewritten and its metadata (ETag, tags, SSE keys) travels unchanged. +// Versioned buckets are rejected: the move would have to rebuild the .versions +// chain, and AWS itself only offers RenameObject on directory buckets, which +// cannot be versioned. +func (s3a *S3ApiServer) RenameObjectHandler(w http.ResponseWriter, r *http.Request) { + bucket, dstObject := s3_constants.GetBucketAndObject(r) + + candidates, errCode := renameSourceCandidates(r, bucket) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + srcObject := s3a.pickRenameSource(bucket, candidates) + + glog.V(3).Infof("RenameObjectHandler %s: %s => %s", bucket, srcObject, dstObject) + + if len(dstObject) > s3_constants.MaxS3ObjectKeyLength { + s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError) + return + } + if err := s3a.validateTableBucketObjectPath(bucket, dstObject); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + // A trailing slash names a directory, and renaming one would move a whole + // subtree rather than an object. + if strings.HasSuffix(dstObject, "/") { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + if strings.HasSuffix(srcObject, "/") { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + if srcObject == dstObject { + s3err.WriteErrorResponse(w, r, s3err.ErrRenameDestinationSameAsSource) + return + } + + // The route's Auth middleware only authorized the destination, because that + // is what the request URL names. The source arrives in a header and loses + // its key, so it needs both read and delete permission checked here. + if errCode := s3a.authorizeRenameSource(r, bucket, srcObject); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + versioningState, err := s3a.getVersioningState(bucket) + if err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + glog.Errorf("RenameObjectHandler: versioning state for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + if versioningState != "" { + s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented) + return + } + + errCode = s3a.withRenameWriteLocks(bucket, srcObject, dstObject, func() s3err.ErrorCode { + entry, err := s3a.resolveCopySourceEntry(bucket, srcObject, "", "") + if errCode := classifyCopySourceError(entry, err); errCode != s3err.ErrNone { + return errCode + } + if errCode := validateSourceConditionalHeaders(r, entry, renameSourceConditionalHeaders); errCode != s3err.ErrNone { + return errCode + } + if errCode := s3a.checkConditionalHeaders(r, bucket, dstObject); errCode != s3err.ErrNone { + return errCode + } + return s3a.renameObjectEntry(r.Context(), bucket, srcObject, dstObject) + }) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + stats_collect.RecordBucketActiveTime(bucket) + writeSuccessResponseEmpty(w, r) +} + +// renameSourceCandidates reads x-amz-rename-source into the source keys it may +// mean, best guess first. +// +// AWS spells the source both ways: its CLI, Java and Rust examples pass a bare +// key, while a second CLI example and the boto3 conditional example pass +// bucket/key. A value is therefore read as a literal key first — that is the +// form AWS leads with, and it is the only reading that can never name the wrong +// object — and, when it is prefixed with the request's own bucket, as that +// bucket-qualified form second. There is no cross-bucket reading: RenameObject +// moves within one bucket, and the filer refuses to move an entry between two. +func renameSourceCandidates(r *http.Request, bucket string) ([]string, s3err.ErrorCode) { + rawSource := r.Header.Get(s3_constants.AmzRenameSource) + if rawSource == "" { + return nil, s3err.ErrInvalidRenameSource + } + // PathUnescape, not QueryUnescape: the value is a path, where '+' is a + // literal plus and not a space. + source, err := url.PathUnescape(rawSource) + if err != nil { + source = rawSource + } + + // NormalizeObjectKey drops the leading slash both forms may carry. + source = s3_constants.NormalizeObjectKey(source) + if source == "" { + return nil, s3err.ErrInvalidRenameSource + } + + candidates := []string{source} + if qualified := strings.TrimPrefix(source, bucket+"/"); qualified != source && qualified != "" { + candidates = append(candidates, qualified) + } + // `.`/`..` segments are collapsed by the filer's path join, so reject them + // here as the request URL's own key already is. + for _, candidate := range candidates { + if !s3_constants.IsValidObjectKey(candidate) { + return nil, s3err.ErrInvalidRenameSource + } + } + return candidates, s3err.ErrNone +} + +// pickRenameSource resolves which reading of the source header the bucket +// actually holds. A single candidate is returned unprobed, so the common bare +// key costs no extra lookup; when both readings are possible the one the bucket +// holds wins, and when neither does the last is reported missing. +// +// Only a proven absence moves on to the next reading. A path that holds +// something the rename cannot move — a directory, say — is still the path the +// caller named, and answering for it beats renaming a different object under +// the other reading; so is a path whose lookup merely failed, since a blip must +// not be able to redirect a rename. +func (s3a *S3ApiServer) pickRenameSource(bucket string, candidates []string) string { + for _, candidate := range candidates[:len(candidates)-1] { + // A trailing slash never names an object, and never reaches a usable + // directory/name split either. + if strings.HasSuffix(candidate, "/") { + continue + } + if !renameSourceAbsent(s3a.resolveCopySourceEntry(bucket, candidate, "", "")) { + return candidate + } + } + return candidates[len(candidates)-1] +} + +// renameSourceAbsent reports whether a lookup proved the candidate absent. Only +// the filer saying so counts; a lookup that failed for any other reason is not +// a proof of absence. +func renameSourceAbsent(entry *filer_pb.Entry, err error) bool { + if entry != nil { + return false + } + return err == nil || errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound +} + +func (s3a *S3ApiServer) authorizeRenameSource(r *http.Request, bucket, srcObject string) s3err.ErrorCode { + if s3a.iam == nil || !s3a.iam.isEnabled() { + return s3err.ErrNone + } + var identity *Identity + if id, ok := s3_constants.GetIdentityFromContext(r).(*Identity); ok { + identity = id + } + // The rename both reads the source object and removes it from its key. + if errCode := s3a.iam.AuthorizeCopySource(r, identity, bucket, srcObject, ""); errCode != s3err.ErrNone { + return errCode + } + return s3a.iam.AuthorizeObjectDelete(r, identity, bucket, srcObject, "") +} + +// withRenameWriteLocks holds the object write lock of both keys across the +// precondition checks and the move. The keys are locked in a fixed order so a +// rename in the opposite direction cannot deadlock against this one. +func (s3a *S3ApiServer) withRenameWriteLocks(bucket, srcObject, dstObject string, fn func() s3err.ErrorCode) s3err.ErrorCode { + first, second := srcObject, dstObject + if second < first { + first, second = second, first + } + return s3a.withObjectWriteLock(bucket, first, nil, func() s3err.ErrorCode { + return s3a.withObjectWriteLock(bucket, second, nil, fn) + }) +} + +func (s3a *S3ApiServer) renameObjectEntry(ctx context.Context, bucket, srcObject, dstObject string) s3err.ErrorCode { + srcDir, srcName := util.FullPath(s3a.toFilerPath(bucket, srcObject)).DirAndName() + dstDir, dstName := util.FullPath(s3a.toFilerPath(bucket, dstObject)).DirAndName() + + // The move overwrites an existing destination object, but a directory in + // the way is a conflict the filer reports as an opaque error. + if existing, err := s3a.getEntry(dstDir, dstName); err == nil && existing.IsDirectory { + return s3err.ErrExistingObjectIsDirectory + } else if err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + glog.Errorf("RenameObject %s: destination %s: %v", bucket, dstObject, err) + return s3err.ErrInternalError + } + + err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + _, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{ + OldDirectory: srcDir, + OldName: srcName, + NewDirectory: dstDir, + NewName: dstName, + }) + return err + }) + if err != nil { + glog.Errorf("RenameObject %s: %s => %s: %v", bucket, srcObject, dstObject, err) + if isTransientFilerError(err) { + return s3err.ErrServiceUnavailable + } + return s3err.ErrInternalError + } + return s3err.ErrNone +} diff --git a/weed/s3api/s3api_object_handlers_rename_test.go b/weed/s3api/s3api_object_handlers_rename_test.go new file mode 100644 index 000000000..4c7397435 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_rename_test.go @@ -0,0 +1,163 @@ +package s3api + +import ( + "net/http" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRenameSourceCandidates: AWS spells x-amz-rename-source as a bare key in +// its CLI, Java and Rust examples and as bucket/key in a second CLI example and +// the boto3 conditional one, so both readings have to survive parsing. The +// literal key leads; the bucket-qualified reading follows only when the value +// carries the request's own bucket. +func TestRenameSourceCandidates(t *testing.T) { + tests := []struct { + name string + source string + want []string + wantErr s3err.ErrorCode + }{ + {"bare key", "source.txt", []string{"source.txt"}, s3err.ErrNone}, + {"bare key with leading slash", "/source.txt", []string{"source.txt"}, s3err.ErrNone}, + {"bare key with prefix", "dir/source.txt", []string{"dir/source.txt"}, s3err.ErrNone}, + {"bucket qualified", "/bucket/dir/key.txt", []string{"bucket/dir/key.txt", "dir/key.txt"}, s3err.ErrNone}, + {"bucket qualified without leading slash", "bucket/key.txt", []string{"bucket/key.txt", "key.txt"}, s3err.ErrNone}, + {"key whose first segment is another bucket", "other/key.txt", []string{"other/key.txt"}, s3err.ErrNone}, + {"percent encoded", "a%20b.txt", []string{"a b.txt"}, s3err.ErrNone}, + {"plus stays literal", "a+b.txt", []string{"a+b.txt"}, s3err.ErrNone}, + {"duplicate slashes collapse", "//dir//key.txt", []string{"dir/key.txt"}, s3err.ErrNone}, + {"bucket name alone is a key", "bucket", []string{"bucket"}, s3err.ErrNone}, + {"bucket prefix with empty key", "bucket/", []string{"bucket/"}, s3err.ErrNone}, + {"missing header", "", nil, s3err.ErrInvalidRenameSource}, + {"parent traversal", "../other/key.txt", nil, s3err.ErrInvalidRenameSource}, + {"encoded parent traversal", "%2e%2e/other/key.txt", nil, s3err.ErrInvalidRenameSource}, + {"parent traversal behind the bucket", "/bucket/../other/key.txt", nil, s3err.ErrInvalidRenameSource}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r, err := http.NewRequest(http.MethodPut, "/bucket/dst.txt?renameObject", nil) + require.NoError(t, err) + if tc.source != "" { + r.Header.Set(s3_constants.AmzRenameSource, tc.source) + } + + candidates, errCode := renameSourceCandidates(r, "bucket") + assert.Equal(t, tc.wantErr, errCode) + assert.Equal(t, tc.want, candidates) + }) + } +} + +func TestRenameSourceConditionalHeaders(t *testing.T) { + mtime := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + entry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{Mtime: mtime.Unix()}, + Extended: map[string][]byte{s3_constants.ExtETagKey: []byte("d41d8cd98f00b204e9800998ecf8427e")}, + } + + tests := []struct { + name string + header string + value string + want s3err.ErrorCode + }{ + {"if-match hit", s3_constants.AmzRenameSourceIfMatch, `"d41d8cd98f00b204e9800998ecf8427e"`, s3err.ErrNone}, + {"if-match miss", s3_constants.AmzRenameSourceIfMatch, "0000", s3err.ErrPreconditionFailed}, + {"if-match star", s3_constants.AmzRenameSourceIfMatch, "*", s3err.ErrNone}, + {"if-none-match miss", s3_constants.AmzRenameSourceIfNoneMatch, "0000", s3err.ErrNone}, + {"if-none-match hit", s3_constants.AmzRenameSourceIfNoneMatch, "d41d8cd98f00b204e9800998ecf8427e", s3err.ErrPreconditionFailed}, + // AWS documents * on the source If-None-Match as always failing. + {"if-none-match star", s3_constants.AmzRenameSourceIfNoneMatch, "*", s3err.ErrPreconditionFailed}, + {"modified since older", s3_constants.AmzRenameSourceIfModifiedSince, mtime.Add(-time.Hour).Format(http.TimeFormat), s3err.ErrNone}, + {"modified since newer", s3_constants.AmzRenameSourceIfModifiedSince, mtime.Add(time.Hour).Format(http.TimeFormat), s3err.ErrPreconditionFailed}, + {"unmodified since newer", s3_constants.AmzRenameSourceIfUnmodifiedSince, mtime.Add(time.Hour).Format(http.TimeFormat), s3err.ErrNone}, + {"unmodified since older", s3_constants.AmzRenameSourceIfUnmodifiedSince, mtime.Add(-time.Hour).Format(http.TimeFormat), s3err.ErrPreconditionFailed}, + {"unparsable date", s3_constants.AmzRenameSourceIfModifiedSince, "not a date", s3err.ErrInvalidRequest}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r, err := http.NewRequest(http.MethodPut, "/bucket/dst.txt?renameObject", nil) + require.NoError(t, err) + r.Header.Set(tc.header, tc.value) + + assert.Equal(t, tc.want, validateSourceConditionalHeaders(r, entry, renameSourceConditionalHeaders)) + }) + } + + t.Run("no headers", func(t *testing.T) { + r, err := http.NewRequest(http.MethodPut, "/bucket/dst.txt?renameObject", nil) + require.NoError(t, err) + assert.Equal(t, s3err.ErrNone, validateSourceConditionalHeaders(r, entry, renameSourceConditionalHeaders)) + }) +} + +// TestSourceConditionalHeaderPrecedence: RFC 7232 lets an ETag precondition +// settle its own side, so the date header next to it is not evaluated. AWS +// documents the same for CopyObject: a matching x-amz-copy-source-if-match with +// a failing x-amz-copy-source-if-unmodified-since copies instead of returning 412. +func TestSourceConditionalHeaderPrecedence(t *testing.T) { + mtime := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + etag := "d41d8cd98f00b204e9800998ecf8427e" + entry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{Mtime: mtime.Unix()}, + Extended: map[string][]byte{s3_constants.ExtETagKey: []byte(etag)}, + } + before := mtime.Add(-time.Hour).Format(http.TimeFormat) + after := mtime.Add(time.Hour).Format(http.TimeFormat) + + for _, names := range []sourceConditionalHeaderNames{copySourceConditionalHeaders, renameSourceConditionalHeaders} { + t.Run(names.ifMatch, func(t *testing.T) { + t.Run("matched if-match outranks a failing if-unmodified-since", func(t *testing.T) { + r, err := http.NewRequest(http.MethodPut, "/bucket/dst.txt", nil) + require.NoError(t, err) + r.Header.Set(names.ifMatch, etag) + r.Header.Set(names.ifUnmodifiedSince, before) + assert.Equal(t, s3err.ErrNone, validateSourceConditionalHeaders(r, entry, names)) + }) + + t.Run("passed if-none-match outranks a failing if-modified-since", func(t *testing.T) { + r, err := http.NewRequest(http.MethodPut, "/bucket/dst.txt", nil) + require.NoError(t, err) + r.Header.Set(names.ifNoneMatch, "0000") + r.Header.Set(names.ifModifiedSince, after) + assert.Equal(t, s3err.ErrNone, validateSourceConditionalHeaders(r, entry, names)) + }) + + t.Run("a failing if-match still loses", func(t *testing.T) { + r, err := http.NewRequest(http.MethodPut, "/bucket/dst.txt", nil) + require.NoError(t, err) + r.Header.Set(names.ifMatch, "0000") + r.Header.Set(names.ifUnmodifiedSince, after) + assert.Equal(t, s3err.ErrPreconditionFailed, validateSourceConditionalHeaders(r, entry, names)) + }) + }) + } +} + +// TestRouting_RenameObject pins PUT /bucket/key?renameObject to the RenameObject +// route rather than the plain PutObject one that would otherwise match it. +func TestRouting_RenameObject(t *testing.T) { + router := mux.NewRouter() + setupRoutingTestServer(t).registerRouter(router) + + req, err := http.NewRequest(http.MethodPut, "http://localhost/bucket/dst.txt?renameObject", nil) + require.NoError(t, err) + req.Header.Set(s3_constants.AmzRenameSource, "/bucket/src.txt") + + var match mux.RouteMatch + require.True(t, router.Match(req, &match), "no route matched") + + queries, err := match.Route.GetQueriesTemplates() + require.NoError(t, err) + assert.Equal(t, []string{"renameObject="}, queries) +} diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index a1a5f59ce..7b2e6c746 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -830,6 +830,9 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { // DeleteObjectTagging bucket.Methods(http.MethodDelete).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteObjectTaggingHandler, ACTION_TAGGING)), "DELETE")).Queries("tagging", "") + // RenameObject + bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.RenameObjectHandler, ACTION_WRITE)), "PUT")).Queries("renameObject", "") + // PutObjectACL bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectAclHandler, ACTION_WRITE_ACP)), "PUT")).Queries("acl", "") // PutObjectRetention diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go index 754d1caff..f5ce20652 100644 --- a/weed/s3api/s3err/s3api_errors.go +++ b/weed/s3api/s3err/s3api_errors.go @@ -163,6 +163,9 @@ const ( // Peer went away before the request body was fully received ErrClientDisconnected + + ErrInvalidRenameSource + ErrRenameDestinationSameAsSource ) // Error message constants for checksum validation @@ -349,6 +352,16 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "Copy Source must mention the source bucket and key: sourcebucket/sourcekey.", HTTPStatusCode: http.StatusBadRequest, }, + ErrInvalidRenameSource: { + Code: "InvalidArgument", + Description: "Rename Source must mention the source bucket and key: sourcebucket/sourcekey.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrRenameDestinationSameAsSource: { + Code: "InvalidRequest", + Description: "This rename request is illegal because it is trying to rename an object to itself.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrInvalidTag: { Code: "InvalidTag", Description: "The Tag value you have provided is invalid",