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:
Chris Lu
2026-08-08 21:24:30 -07:00
committed by GitHub
parent 67b0cc0706
commit 2d9ea0285c
10 changed files with 829 additions and 45 deletions
+331
View File
@@ -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"))
}
+6 -6
View File
@@ -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
}
+8 -8
View File
@@ -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")
}
+7
View File
@@ -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"
+45 -29
View File
@@ -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
}
}
+2 -2
View File
@@ -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
}
+251
View File
@@ -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)
}
+3
View File
@@ -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
+13
View File
@@ -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",