mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* 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.
164 lines
7.5 KiB
Go
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)
|
|
}
|