mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 04:06:44 +00:00
s3: add the RenameObject endpoint (#10659)
* 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.
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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 /<bucket>/<key> 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 /<bucket>/<key> 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
|
||||
}
|
||||
|
||||
@@ -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 <bucket>/* must allow per-key batch deletes. Pre-fix the bucket-level check
|
||||
// built arn:aws:s3:::<bucket> 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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user