Files
seaweedfs/weed/s3api/s3api_object_handlers_copy_acl_test.go
T
Chris LuandClaude Opus 5.5 b0c6297041 s3api: per-bucket Block Public Access (BlockPublicAcls, IgnorePublicAcls) (#12381)
* s3api: store and serve a bucket's PublicAccessBlock configuration

PutPublicAccessBlock, GetPublicAccessBlock and DeletePublicAccessBlock
answered NotImplemented. Store the configuration in the bucket entry's
extended attributes next to the ownership controls, carry it in the
cached BucketConfig, and answer Get on an unset bucket with
NoSuchPublicAccessBlockConfiguration like S3 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: refuse public ACLs when a bucket blocks them

With BlockPublicAcls set, a PutObject, CopyObject, CreateMultipartUpload,
POST Object, PutObjectAcl or PutBucketAcl whose canned ACL or grants name
the AllUsers or AuthenticatedUsers group is refused with AccessDenied.
Private and account grants keep working, so clients that send
x-amz-acl: private on every upload are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: ignore public ACL grants for anonymous reads under IgnorePublicAcls

A bucket with IgnorePublicAcls no longer lets an AllUsers grant on the
bucket or on an object answer an anonymous read or listing. Bucket
policies and identity permissions are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject PublicAccessBlock bodies with unknown elements

A misspelled element such as BlockPublicAcl was ignored, so the request
succeeded and stored the flag as false. Answer MalformedXML instead, and
store the configuration without the request's namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: fail closed when a bucket's public access block cannot be read

A stored PublicAccessBlock that does not parse now blocks and ignores
public ACLs instead of acting as if none were set. PutBucketAcl and POST
Object return the bucket lookup error rather than skipping the
BlockPublicAcls check when the bucket config cannot be loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test BlockPublicAcls on copy, multipart initiate and a PUT-then-GET

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: keep stored public access block bytes on unrelated bucket updates

An unreadable stored configuration was replaced with the in-memory
all-true fallback whenever another bucket setting was updated, losing
the original value. Write the key only when the update changes the
public access block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:52:20 -08:00

493 lines
25 KiB
Go

package s3api
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb"
"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/seaweedfs/seaweedfs/weed/wdclient"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
// copyACLFiler records both ordinary creates and the metadata-only self-copy
// transaction, so tests inspect the actual entry sent to the filer.
type copyACLFiler struct {
*ambiguousPutFiler
transactions []*filer_pb.ObjectTransactionRequest
updates []*filer_pb.UpdateEntryRequest
afterLookup func(*filer_pb.LookupDirectoryEntryRequest)
beforeCommit func()
}
// Count two-node RPCs to distinguish a business rejection from failover.
type selfCopyReplyFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
code codes.Code
calls atomic.Int32
}
func (f *selfCopyReplyFiler) UpdateEntry(context.Context, *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
f.calls.Add(1)
return nil, status.Error(f.code, "test self-copy reply")
}
// A snapshot conflict or deletion is an authoritative owner reply. It must
// preserve its status without consulting a peer or tripping owner health.
func TestRoutedSelfCopyBusinessErrors(t *testing.T) {
for _, code := range []codes.Code{codes.FailedPrecondition, codes.NotFound} {
t.Run(code.String(), func(t *testing.T) {
owner := &selfCopyReplyFiler{code: code}
peer := &selfCopyReplyFiler{code: codes.Unavailable}
ownerAddress := startFakeFiler(t, owner)
peerAddress := startFakeFiler(t, peer)
s3a := newPutTestServer(t, peerAddress)
current := &filer_pb.Entry{Name: "obj", Attributes: &filer_pb.FuseAttributes{FileMode: defaultFileMode}}
err := s3a.routedSelfCopy(context.Background(), ownerAddress, "b", "obj", current, proto.Clone(current).(*filer_pb.Entry))
require.Equal(t, code, status.Code(err), "a peer transport error must not mask the owner's business rejection")
require.Equal(t, int32(1), owner.calls.Load())
require.Zero(t, peer.calls.Load(), "rejected commits must not be replayed")
require.False(t, s3a.ownerRecentlyUnreachable(ownerAddress))
})
}
}
// Race hooks place an intervening write after the snapshot or immediately
// before the filer's serialized precondition check, without timing-dependent sleeps.
func (f *copyACLFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
resp, err := f.ambiguousPutFiler.LookupDirectoryEntry(ctx, req)
f.mu.Lock()
defer f.mu.Unlock()
if f.afterLookup != nil {
f.afterLookup(req)
}
return resp, err
}
// Match the filer's normalized whole-entry condition, then atomically replace
// the entry; native condition/commit behavior is covered by server tests too.
func (f *copyACLFiler) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.updates = append(f.updates, proto.Clone(req).(*filer_pb.UpdateEntryRequest))
if f.beforeCommit != nil {
f.beforeCommit()
}
current := f.entries[req.Directory+"/"+req.Entry.Name]
if current == nil {
return nil, status.Error(codes.NotFound, "missing entry")
}
if req.Condition == nil || len(req.Condition.Clauses) != 1 || req.Condition.Clauses[0].Kind != filer_pb.WriteCondition_IF_ENTRY_EQUAL {
return nil, status.Error(codes.InvalidArgument, "self-copy requires an entry condition")
}
normalize := func(e *filer_pb.Entry) *filer_pb.Entry { return filer.FromPbEntry("", e).ToProtoEntry() }
if !proto.Equal(normalize(current), normalize(req.Condition.Clauses[0].ExpectedEntry)) {
return nil, status.Error(codes.FailedPrecondition, "entry changed")
}
f.entries[req.Directory+"/"+req.Entry.Name] = proto.Clone(req.Entry).(*filer_pb.Entry)
return &filer_pb.UpdateEntryResponse{}, nil
}
// These fixtures have no prior destination versions to list during a null write.
func (f *copyACLFiler) ListEntries(_ *filer_pb.ListEntriesRequest, _ filer_pb.SeaweedFiler_ListEntriesServer) error {
return nil
}
func (f *copyACLFiler) ObjectTransaction(_ context.Context, req *filer_pb.ObjectTransactionRequest) (*filer_pb.ObjectTransactionResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.transactions = append(f.transactions, proto.Clone(req).(*filer_pb.ObjectTransactionRequest))
if f.beforeCommit != nil {
f.beforeCommit()
}
for _, mutation := range req.Mutations {
if mutation.Type != filer_pb.ObjectMutation_PATCH_EXTENDED {
return &filer_pb.ObjectTransactionResponse{Error: "unexpected mutation"}, nil
}
entry := f.entries[mutation.Directory+"/"+mutation.Name]
if entry == nil {
return &filer_pb.ObjectTransactionResponse{Error: "missing entry"}, nil
}
for _, key := range mutation.DeleteExtended {
delete(entry.Extended, key)
}
for key, value := range mutation.SetExtended {
entry.Extended[key] = append([]byte(nil), value...)
}
}
return &filer_pb.ObjectTransactionResponse{}, nil
}
// TestCopyObjectDestinationACL uses authenticated CopyObject requests to verify
// that source grants never become destination grants, including self-copies.
func TestCopyObjectDestinationACL(t *testing.T) {
const bucket, writer, bucketOwner, sourceOwner = "copy-acl", "copy-writer", "bucket-owner", "source-owner"
const object, sourceObject = "destination", "source"
const versionID = "672a75d526ef29c79fe6b5680cad4a0d"
tests := []struct {
name, acl, grantHeader, grant, ownership, versioning, denyAction, errorCode string
self, routed, replace, presigned, v2, noRead, noWrite, noWriteACP, sourceVersion bool
query url.Values
sourceMode uint32
mime string
race, condition string
prefix bool
blockPublicAcls bool
}{
{name: "default private"},
{name: "replace metadata also defaults private", replace: true},
{name: "explicit private", acl: "private"},
{name: "explicit public read", acl: "public-read"},
{name: "signed query public read", acl: "public-read", presigned: true},
{name: "custom read", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`},
{name: "custom full control", grantHeader: s3_constants.AmzAclFullControl, grant: `id="bucket-owner"`},
{name: "custom read acp", grantHeader: s3_constants.AmzAclReadAcp, grant: `id="bucket-owner"`},
{name: "custom write acp", grantHeader: s3_constants.AmzAclWriteAcp, grant: `id="bucket-owner"`, presigned: true},
{name: "self copy bootstrap", self: true, replace: true},
{name: "self copy routed private", self: true, replace: true, routed: true},
{name: "self copy routed explicit public", self: true, replace: true, routed: true, acl: "public-read", sourceMode: 0644},
{name: "self copy routed custom", self: true, replace: true, routed: true, grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`},
{name: "self copy changes file mode", self: true, replace: true, routed: true, sourceMode: 0644},
{name: "self copy changes mime", self: true, replace: true, routed: true, mime: "image/png"},
{name: "versioned destination", versioning: s3_constants.VersioningEnabled},
{name: "versioned public destination", versioning: s3_constants.VersioningEnabled, acl: "public-read"},
{name: "suspended destination", versioning: s3_constants.VersioningSuspended},
{name: "specific source version", sourceVersion: true},
{name: "versioned self copy", versioning: s3_constants.VersioningEnabled, sourceVersion: true, self: true},
{name: "preferred bucket owner", ownership: s3_constants.OwnershipBucketOwnerPreferred, acl: "bucket-owner-full-control"},
{name: "enforced bucket owner", ownership: s3_constants.OwnershipBucketOwnerEnforced},
{name: "enforced accepts full control", ownership: s3_constants.OwnershipBucketOwnerEnforced, acl: "bucket-owner-full-control"},
{name: "enforced rejects public", ownership: s3_constants.OwnershipBucketOwnerEnforced, acl: "public-read", errorCode: "AccessControlListNotSupported"},
{name: "default needs no write acp", noWriteACP: true},
{name: "public requires write acp", acl: "public-read", noWriteACP: true, errorCode: "AccessDenied"},
{name: "custom requires write acp", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, noWriteACP: true, errorCode: "AccessDenied"},
{name: "requires source read", noRead: true, errorCode: "AccessDenied"},
{name: "requires destination write", noWrite: true, errorCode: "AccessDenied"},
{name: "query put deny", acl: "public-read", presigned: true, denyAction: "s3:PutObject", errorCode: "AccessDenied"},
{name: "query acl deny", acl: "public-read", presigned: true, denyAction: "s3:PutObjectAcl", errorCode: "AccessDenied"},
{name: "query custom put deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, presigned: true, denyAction: "s3:PutObject", errorCode: "AccessDenied"},
{name: "query custom acl deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, presigned: true, denyAction: "s3:PutObjectAcl", errorCode: "AccessDenied"},
{name: "invalid canned", acl: "invalid", errorCode: "InvalidRequest"},
{name: "conflicting canned and grants", acl: "private", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, errorCode: "InvalidRequest"},
{name: "duplicate signed query", query: url.Values{"x-amz-acl": {"private", "public-read"}}, presigned: true, errorCode: "InvalidRequest"},
{name: "v2 unsigned query ignored", v2: true, query: url.Values{"x-amz-acl": {"public-read"}}},
{name: "private mode changes after first read", self: true, replace: true, routed: true, race: "read"},
{name: "public mode changes after first read", self: true, replace: true, routed: true, race: "read", acl: "public-read", sourceMode: 0644},
{name: "private mode changes before commit", self: true, replace: true, routed: true, race: "commit"},
{name: "public mode changes before commit", self: true, replace: true, routed: true, race: "commit", acl: "public-read", sourceMode: 0644},
{name: "mime changes before commit", self: true, replace: true, routed: true, race: "mime"},
{name: "content changes before commit", self: true, replace: true, routed: true, race: "content"},
{name: "source condition rechecked on retry", self: true, replace: true, routed: true, race: "content", condition: "source", errorCode: "PreconditionFailed"},
{name: "destination condition rechecked on retry", self: true, replace: true, routed: true, race: "content", condition: "destination", errorCode: "PreconditionFailed"},
{name: "deleted before commit", self: true, replace: true, routed: true, race: "delete", errorCode: "NoSuchKey"},
{name: "bounded self copy conflicts", self: true, replace: true, routed: true, race: "repeat", errorCode: "ConditionalRequestConflict"},
{name: "prefix private control", self: true, replace: true, routed: true, prefix: true},
{name: "prefix mode changes before commit", self: true, replace: true, routed: true, prefix: true, race: "commit"},
{name: "self copy reads owner ahead of replica", self: true, replace: true, routed: true, race: "replica"},
{name: "block public acls private", acl: "private", blockPublicAcls: true},
{name: "block public acls rejects public", acl: "public-read", blockPublicAcls: true, errorCode: "AccessDenied"},
{name: "block public acls rejects public self copy", self: true, replace: true, routed: true, acl: "public-read", blockPublicAcls: true, errorCode: "AccessDenied"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filer := &copyACLFiler{ambiguousPutFiler: &ambiguousPutFiler{apply: true, entries: map[string]*filer_pb.Entry{}}}
address := startFakeFiler(t, filer)
s3a := newPutTestServer(t, address)
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
t.Cleanup(s3a.iam.Shutdown)
s3a.iam.isAuthEnabled = true
account := &Account{Id: writer, DisplayName: writer}
identity := &Identity{Name: "copy-acl-test", Account: account, IsStatic: true,
Credentials: []*Credential{{AccessKey: routingTestAccessKey, SecretKey: routingTestSecretKey}}}
for action, enabled := range map[Action]bool{"Read:" + bucket: !tt.noRead, "Write:" + bucket: !tt.noWrite, "WriteAcp:" + bucket: !tt.noWriteACP} {
if enabled {
identity.Actions = append(identity.Actions, action)
}
}
s3a.iam.accessKeyIdent[routingTestAccessKey] = identity
s3a.iam.nameToIdentity[identity.Name] = identity
s3a.iam.accounts[writer] = account
s3a.iam.accounts[bucketOwner] = &Account{Id: bucketOwner, DisplayName: bucketOwner}
ownership := tt.ownership
if ownership == "" {
ownership = s3_constants.OwnershipObjectWriter
}
bucketEntry := &filer_pb.Entry{Name: bucket, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}, Extended: map[string][]byte{
s3_constants.ExtAmzOwnerKey: []byte(bucketOwner), s3_constants.ExtOwnershipKey: []byte(ownership),
}}
filer.entries["/buckets/"+bucket] = bucketEntry
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
bucketConfig := &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: ownership, Versioning: tt.versioning}
if tt.blockPublicAcls {
bucketConfig.PublicAccessBlock = &PublicAccessBlockConfiguration{BlockPublicAcls: true}
}
s3a.bucketConfigCache.Set(bucket, bucketConfig)
s3a.bucketRegistry = NewBucketRegistry(s3a)
s3a.bucketRegistry.LoadBucketMetadata(bucketEntry)
mode := tt.sourceMode
if mode == 0 {
mode = defaultFileMode
}
source := &filer_pb.Entry{Name: sourceObject, Attributes: &filer_pb.FuseAttributes{FileMode: mode, FileSize: 4, Mime: "text/plain"}, Content: []byte("data"),
Extended: map[string][]byte{"X-Amz-Meta-Source": []byte("kept"), "internal-other": []byte("retained")}}
require.Equal(t, s3err.ErrNone, AssembleEntryWithAcp(source, sourceOwner, []*s3.Grant{
{Grantee: &s3.Grantee{Type: aws.String(s3_constants.GrantTypeCanonicalUser), ID: aws.String(sourceOwner)}, Permission: aws.String(s3_constants.PermissionFullControl)},
{Grantee: &s3.Grantee{Type: aws.String(s3_constants.GrantTypeGroup), URI: aws.String(s3_constants.GranteeGroupAllUsers)}, Permission: aws.String(s3_constants.PermissionRead)},
}))
if tt.prefix {
source.IsDirectory = true
source.Attributes.FileMode |= uint32(os.ModeDir)
source.Extended[s3_constants.SeaweedFSPrefixObject] = []byte("true")
filer.entries["/buckets/"+bucket+"/"+sourceObject+"/child"] = &filer_pb.Entry{Name: "child", Content: []byte("child data")}
}
key := object
if tt.self {
key = sourceObject
}
sourcePath := "/buckets/" + bucket + "/" + sourceObject
copySource := "/" + bucket + "/" + sourceObject
if tt.sourceVersion {
source.Name = s3a.getVersionFileName(versionID)
source.Extended[s3_constants.ExtVersionIdKey] = []byte(versionID)
sourcePath += s3_constants.VersionsFolder + "/" + source.Name
copySource += "?versionId=" + versionID
}
filer.entries[sourcePath] = source
originalSource := proto.Clone(source).(*filer_pb.Entry)
if tt.race != "" {
injected := false
intervene := func() {
if injected && tt.race != "repeat" {
return
}
injected = true
if tt.race == "delete" {
delete(filer.entries, sourcePath)
return
}
current := filer.entries[sourcePath]
current.Attributes.FileMode ^= 0024
current.Extended["internal-other"] = []byte("concurrent retained")
current.Extended[s3_constants.ExtLegalHoldKey] = []byte("ON")
if tt.race == "mime" {
current.Attributes.Mime = "image/png"
}
if tt.race == "content" || tt.race == "replica" {
current.Content = []byte("next")
current.Extended[s3_constants.ExtETagKey] = []byte("next-etag")
}
}
if tt.race == "replica" {
// The default filer still has the pre-PUT snapshot. The owner
// already committed the newer entry before this copy starts.
replica := &copyACLFiler{ambiguousPutFiler: &ambiguousPutFiler{entries: map[string]*filer_pb.Entry{sourcePath: proto.Clone(originalSource).(*filer_pb.Entry)}}}
replicaAddress := startFakeFiler(t, replica)
s3a.filerClient = wdclient.NewFilerClient([]pb.ServerAddress{replicaAddress, address}, s3a.option.GrpcDialOption, "")
intervene()
} else if tt.race == "read" {
filer.afterLookup = func(req *filer_pb.LookupDirectoryEntryRequest) {
if req.Directory+"/"+req.Name == sourcePath {
intervene()
}
}
} else {
filer.beforeCommit = intervene
}
}
if tt.versioning != "" || tt.sourceVersion {
filer.entries["/buckets/"+bucket+"/"+key+s3_constants.VersionsFolder] = &filer_pb.Entry{Name: key + s3_constants.VersionsFolder, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
if tt.routed {
s3a.objectWriteLockClient = cluster.NewLockClient(s3a.option.GrpcDialOption, address)
s3a.objectWriteLockClient.SetRing([]pb.ServerAddress{address}, 1)
}
if tt.denyAction != "" {
s3a.policyEngine = NewBucketPolicyEngine()
s3a.iam.policyEngine = s3a.policyEngine
conditionKey, conditionValue := "s3:x-amz-acl", "public-read"
if tt.grantHeader != "" {
conditionKey, conditionValue = "s3:"+strings.ToLower(tt.grantHeader), tt.grant
}
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Principal":"*","Effect":"Deny","Action":%q,"Resource":"arn:aws:s3:::copy-acl/*","Condition":{"StringEquals":{%q:%q}}}]}`, tt.denyAction, conditionKey, conditionValue)
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy(bucket, policy))
}
req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"/"+key, nil)
req = mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": key})
req.Header.Set("X-Amz-Copy-Source", copySource)
if tt.condition != "" {
header := "X-Amz-Copy-Source-If-Match"
if tt.condition == "destination" {
header = "If-Match"
}
req.Header.Set(header, getEtagFromEntry(prefixObjectSource(originalSource)))
}
// Forged internal headers must not influence the new owner or grants.
req.Header.Set(s3_constants.ExtAmzOwnerKey, "forged-owner")
req.Header.Set(s3_constants.ExtAmzAclKey, string(source.Extended[s3_constants.ExtAmzAclKey]))
if tt.replace {
req.Header.Set(s3_constants.AmzUserMetaDirective, "REPLACE")
req.Header.Set("X-Amz-Meta-Destination", "new")
req.Header.Set("Content-Type", "text/plain")
}
if tt.mime != "" {
req.Header.Set("Content-Type", tt.mime)
}
if tt.acl != "" {
req.Header.Set(s3_constants.AmzCannedAcl, tt.acl)
}
if tt.grantHeader != "" {
req.Header.Set(tt.grantHeader, tt.grant)
}
req.URL.RawQuery = tt.query.Encode()
if tt.presigned {
query := req.URL.Query()
for _, header := range []string{s3_constants.AmzCannedAcl, tt.grantHeader} {
if value := req.Header.Get(header); value != "" {
query.Set(header, value)
req.Header.Del(header)
}
}
req.URL.RawQuery = query.Encode()
signer := v4.NewSigner(credentials.NewStaticCredentials(routingTestAccessKey, routingTestSecretKey, ""))
_, err := signer.Presign(req, nil, "s3", "us-east-1", time.Minute, time.Now())
require.NoError(t, err)
} else if tt.v2 {
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
req.Header.Set("Authorization", signatureV2(&Credential{AccessKey: routingTestAccessKey, SecretKey: routingTestSecretKey}, req.Method, req.URL.EscapedPath(), req.URL.RawQuery, req.Header))
} else {
signRoutingTestRequest(t, req, "", "s3")
}
rr := httptest.NewRecorder()
s3a.iam.Auth(s3a.CopyObjectHandler, s3_constants.ACTION_WRITE)(rr, req)
filer.mu.Lock()
defer filer.mu.Unlock()
if tt.errorCode != "" {
require.Contains(t, rr.Body.String(), "<Code>"+tt.errorCode+"</Code>")
require.NotEqual(t, http.StatusOK, rr.Code)
if tt.race != "" {
if tt.race == "repeat" {
require.Equal(t, http.StatusConflict, rr.Code)
}
if tt.race == "delete" {
require.Nil(t, filer.entries[sourcePath])
} else {
require.Equal(t, originalSource.Extended[s3_constants.ExtAmzAclKey], filer.entries[sourcePath].Extended[s3_constants.ExtAmzAclKey], "conflicts must not commit the self-copy ACL")
}
require.LessOrEqual(t, len(filer.updates), 3, "CAS retries must be bounded")
return
}
require.Empty(t, filer.transactions, "rejected copies must not issue mutations")
require.Nil(t, filer.entries["/buckets/"+bucket+"/"+object])
require.True(t, proto.Equal(originalSource, filer.entries[sourcePath]), "rejected copies must preserve the source")
return
}
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
storedPath := "/buckets/" + bucket + "/" + key
if tt.versioning == s3_constants.VersioningEnabled {
newVersion := rr.Header().Get("X-Amz-Version-Id")
require.NotEmpty(t, newVersion)
storedPath += s3_constants.VersionsFolder + "/" + s3a.getVersionFileName(newVersion)
}
stored := filer.entries[storedPath]
require.NotNil(t, stored)
wantContent, wantOther := "data", "retained"
if tt.race != "" {
wantOther = "concurrent retained"
require.Equal(t, []byte("ON"), stored.Extended[s3_constants.ExtLegalHoldKey])
}
if tt.race == "content" || tt.race == "replica" {
wantContent = "next"
var result CopyObjectResult
require.NoError(t, xml.Unmarshal(rr.Body.Bytes(), &result))
require.Equal(t, `"next-etag"`, result.ETag)
}
require.Equal(t, []byte(wantContent), stored.Content)
require.Equal(t, []byte(wantOther), stored.Extended["internal-other"])
if tt.replace {
require.NotContains(t, stored.Extended, "X-Amz-Meta-Source")
require.Equal(t, []byte("new"), stored.Extended["X-Amz-Meta-Destination"])
} else {
require.Equal(t, []byte("kept"), stored.Extended["X-Amz-Meta-Source"])
}
wantOwner := writer
if tt.ownership == s3_constants.OwnershipBucketOwnerEnforced || (tt.ownership == s3_constants.OwnershipBucketOwnerPreferred && tt.acl == "bucket-owner-full-control") {
wantOwner = bucketOwner
}
require.Equal(t, wantOwner, string(stored.Extended[s3_constants.ExtAmzOwnerKey]))
grants := GetAcpGrants(stored.Extended)
ownerFullControl := false
publicRead := false
customPermission := map[string]string{s3_constants.AmzAclRead: s3_constants.PermissionRead, s3_constants.AmzAclFullControl: s3_constants.PermissionFullControl, s3_constants.AmzAclReadAcp: s3_constants.PermissionReadAcp, s3_constants.AmzAclWriteAcp: s3_constants.PermissionWriteAcp}[tt.grantHeader]
for _, grant := range grants {
require.NotEqual(t, sourceOwner, aws.StringValue(grant.Grantee.ID), "source owner grants must not transfer")
ownerFullControl = ownerFullControl || (aws.StringValue(grant.Grantee.ID) == wantOwner && aws.StringValue(grant.Permission) == s3_constants.PermissionFullControl)
publicRead = publicRead || (aws.StringValue(grant.Grantee.URI) == s3_constants.GranteeGroupAllUsers && aws.StringValue(grant.Permission) == s3_constants.PermissionRead)
}
require.True(t, ownerFullControl)
require.Equal(t, tt.acl == "public-read", publicRead)
if customPermission != "" {
require.Len(t, grants, 2)
require.Equal(t, bucketOwner, aws.StringValue(grants[0].Grantee.ID))
require.Equal(t, customPermission, aws.StringValue(grants[0].Permission))
} else if tt.acl == "" || tt.acl == "private" {
require.Len(t, grants, 1, "private copies must not inherit any source grants")
}
wantMode := defaultFileMode
if tt.acl == "public-read" {
wantMode = 0644
}
if tt.prefix {
wantMode |= uint32(os.ModeDir)
require.True(t, stored.IsDirectory)
require.Equal(t, []byte("true"), stored.Extended[s3_constants.SeaweedFSPrefixObject])
require.Equal(t, []byte("child data"), filer.entries[sourcePath+"/child"].Content)
}
require.Equal(t, wantMode, stored.Attributes.FileMode)
if tt.race == "mime" {
require.Equal(t, "text/plain", stored.Attributes.Mime)
}
if tt.mime != "" {
require.Equal(t, tt.mime, stored.Attributes.Mime)
}
require.Empty(t, filer.transactions, "self-copies must not use an unguarded metadata PATCH")
if tt.self && tt.routed && tt.versioning == "" && !tt.sourceVersion {
attempts := 1
if tt.race != "" && tt.race != "read" && tt.race != "replica" {
attempts = 2
}
require.Len(t, filer.updates, attempts)
update := filer.updates[attempts-1]
require.Equal(t, filer_pb.WriteCondition_IF_ENTRY_EQUAL, update.Condition.Clauses[0].Kind)
require.Equal(t, stored.Extended[s3_constants.ExtAmzOwnerKey], update.Entry.Extended[s3_constants.ExtAmzOwnerKey])
require.Equal(t, stored.Extended[s3_constants.ExtAmzAclKey], update.Entry.Extended[s3_constants.ExtAmzAclKey])
} else {
require.Empty(t, filer.updates)
}
if !tt.self || tt.sourceVersion {
require.True(t, proto.Equal(originalSource, filer.entries[sourcePath]), "copy must preserve its source ACL and content")
}
})
}
}