Files
seaweedfs/weed/s3api/s3api_object_handlers_rename_test.go
T
Chris Lu 2d9ea0285c 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.
2026-08-08 21:24:30 -07:00

164 lines
7.5 KiB
Go

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)
}