mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-07 23:07:48 +02:00
* fix(s3): recheck cache metadata and read null directory markers Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * fix(s3): preserve null object identity and bound test cache Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3: trim comments on the anonymous read cache path Signed-off-by: Chris Lu <chris.lu@gmail.com> --------- Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> Signed-off-by: Chris Lu <chris.lu@gmail.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
280 lines
14 KiB
Go
280 lines
14 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// A cache fill may return newer metadata without committing downloaded chunks.
|
|
// Keep lookup snapshots separate so the handler must authorize the RPC response.
|
|
type anonymousReadCacheFiler struct {
|
|
*anonymousReadFiler
|
|
cached *filer_pb.Entry
|
|
}
|
|
|
|
func (f *anonymousReadCacheFiler) CacheRemoteObjectToLocalCluster(context.Context, *filer_pb.CacheRemoteObjectToLocalClusterRequest) (*filer_pb.CacheRemoteObjectToLocalClusterResponse, error) {
|
|
return &filer_pb.CacheRemoteObjectToLocalClusterResponse{Entry: proto.Clone(f.cached).(*filer_pb.Entry)}, nil
|
|
}
|
|
|
|
// anonymousReadDirectoryFiler provides the empty version history left after a marker delete.
|
|
type anonymousReadDirectoryFiler struct {
|
|
*anonymousReadFiler
|
|
}
|
|
|
|
// ListEntries reports an empty history so recovery cannot mistake the parent for a version.
|
|
func (f *anonymousReadDirectoryFiler) ListEntries(*filer_pb.ListEntriesRequest, filer_pb.SeaweedFiler_ListEntriesServer) error {
|
|
return nil
|
|
}
|
|
|
|
// DeleteEntry removes an exhausted version history without triggering missing-RPC retries.
|
|
func (f *anonymousReadDirectoryFiler) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.entries, path.Join(req.Directory, req.Name))
|
|
return &filer_pb.DeleteEntryResponse{}, nil
|
|
}
|
|
|
|
func TestAnonymousObjectACLCacheMetadata(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, change string
|
|
chunks, versioned, ranged, part bool
|
|
want int
|
|
}{
|
|
{name: "uncached ACL revoked", change: "acl", want: 403},
|
|
{name: "uncached policy tag changed", change: "tag", want: 403},
|
|
{name: "uncached policy tag removed", change: "missing-tag", want: 403},
|
|
{name: "uncached delete marker", change: "delete", want: 403},
|
|
{name: "cached ACL revoked", change: "acl", chunks: true, want: 403},
|
|
{name: "cached policy tag changed", change: "tag", chunks: true, want: 403},
|
|
{name: "versioned cached denial clears metadata", change: "acl", chunks: true, versioned: true, want: 403},
|
|
{name: "versioned uncached denial clears metadata", change: "tag", versioned: true, want: 403},
|
|
{name: "part denial clears metadata", change: "acl", chunks: true, part: true, want: 403},
|
|
{name: "uncached public origin read", want: 200},
|
|
{name: "uncached public origin range", ranged: true, want: 206},
|
|
{name: "uncached public origin part", part: true, want: 206},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := &fakeStreamRemoteClient{data: []byte("hello world")}
|
|
oldMaker, existed := remote_storage.RemoteStorageClientMakers["anonymous-cache-test"]
|
|
remote_storage.RemoteStorageClientMakers["anonymous-cache-test"] = &fakeStreamRemoteMaker{client: client}
|
|
t.Cleanup(func() {
|
|
if existed {
|
|
remote_storage.RemoteStorageClientMakers["anonymous-cache-test"] = oldMaker
|
|
} else {
|
|
delete(remote_storage.RemoteStorageClientMakers, "anonymous-cache-test")
|
|
}
|
|
})
|
|
entry := anonymousReadEntry(anonymousReadGrant("READ"))
|
|
entry.Content = nil
|
|
entry.RemoteEntry = &filer_pb.RemoteEntry{RemoteSize: 11}
|
|
entry.Extended[s3_constants.AmzObjectTaggingPrefix+"classification"] = []byte("public")
|
|
entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte("GOVERNANCE")
|
|
entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte("1900000000")
|
|
entry.Extended[s3_constants.ExtLegalHoldKey] = []byte("ON")
|
|
if tc.part {
|
|
entry.Extended[s3_constants.SeaweedFSMultipartPartsCount] = []byte("1")
|
|
boundaries, err := json.Marshal([]PartBoundaryInfo{{PartNumber: 1, StartOffset: 0, EndOffset: 11}})
|
|
require.NoError(t, err)
|
|
entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] = boundaries
|
|
}
|
|
cached := proto.Clone(entry).(*filer_pb.Entry)
|
|
if tc.chunks {
|
|
cached.Chunks = []*filer_pb.FileChunk{{FileId: "1,abc", Size: 11}}
|
|
}
|
|
switch tc.change {
|
|
case "acl":
|
|
cached.Extended[s3_constants.ExtAmzAclKey] = []byte(`[]`)
|
|
case "tag":
|
|
cached.Extended[s3_constants.AmzObjectTaggingPrefix+"classification"] = []byte("private")
|
|
case "missing-tag":
|
|
delete(cached.Extended, s3_constants.AmzObjectTaggingPrefix+"classification")
|
|
case "delete":
|
|
cached.Extended[s3_constants.ExtDeleteMarkerKey] = []byte("true")
|
|
}
|
|
f := &anonymousReadCacheFiler{anonymousReadFiler: &anonymousReadFiler{entries: map[string]*filer_pb.Entry{"/buckets/b/o": entry}}, cached: cached}
|
|
// Reuse one cache slot while differing configurations refresh the fake client,
|
|
// including when a single subtest is repeated. The fake maker ignores the endpoint.
|
|
remoteName := "anonymous-cache-origin"
|
|
mapping, err := proto.Marshal(&remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{"/buckets/b": {Name: remoteName, Bucket: "origin", Path: "/"}}})
|
|
require.NoError(t, err)
|
|
conf, err := proto.Marshal(&remote_pb.RemoteConf{Name: remoteName, Type: "anonymous-cache-test", S3Endpoint: fmt.Sprintf("https://origin-%p.invalid", client)})
|
|
require.NoError(t, err)
|
|
f.entries["/etc/remote/mount.mapping"] = &filer_pb.Entry{Content: mapping}
|
|
f.entries["/etc/remote/"+remoteName+".conf"] = &filer_pb.Entry{Content: conf}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, f))
|
|
s3a.iam = &IdentityAccessManagement{isAuthEnabled: true}
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
config := &BucketConfig{Name: "b", Ownership: s3_constants.OwnershipObjectWriter}
|
|
s3a.bucketConfigCache.Set("b", config)
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("b", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:GetObject*","Resource":"arn:aws:s3:::b/*","Condition":{"StringNotEquals":{"s3:ExistingObjectTag/classification":"public"}}}]}`))
|
|
query := ""
|
|
if tc.part {
|
|
query = "partNumber=1"
|
|
}
|
|
if tc.versioned {
|
|
config.Versioning = "Enabled"
|
|
entry.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
|
|
cached.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
|
|
f.entries["/buckets/b/o.versions/v_v1"] = entry
|
|
query = "versionId=v1"
|
|
}
|
|
headers := map[string]string{}
|
|
if tc.ranged {
|
|
headers["Range"] = "bytes=2-5"
|
|
}
|
|
rr := serveAnonymousRead(s3a, http.MethodGet, "o", query, headers)
|
|
require.Equal(t, tc.want, rr.Code, rr.Body.String())
|
|
if tc.want == 403 {
|
|
require.Nil(t, client.gotLoc, "a denied cache snapshot must never reach the origin")
|
|
require.NotContains(t, rr.Body.String(), "hello world")
|
|
for _, name := range []string{"x-amz-version-id", s3_constants.AmzObjectLockMode, s3_constants.AmzObjectLockRetainUntilDate, s3_constants.AmzObjectLockLegalHold, s3_constants.AmzMpPartsCount} {
|
|
require.Empty(t, rr.Header().Get(name), name)
|
|
}
|
|
} else {
|
|
require.NotNil(t, client.gotLoc)
|
|
if tc.ranged {
|
|
require.Equal(t, "llo ", rr.Body.String())
|
|
require.Equal(t, "bytes 2-5/11", rr.Header().Get("Content-Range"))
|
|
} else {
|
|
require.Equal(t, "hello world", rr.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An explicit zero-byte directory marker is a null-version object, unlike a
|
|
// request for the bare prefix. Both versioning states must keep that distinction.
|
|
func TestAnonymousObjectACLVersionedEmptyDirectoryMarker(t *testing.T) {
|
|
for _, versioning := range []string{"Enabled", "Suspended"} {
|
|
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
|
t.Run(versioning+"/"+method, func(t *testing.T) {
|
|
s3a, f := newAnonymousReadTestServer(t)
|
|
config, _ := s3a.getBucketConfig("b")
|
|
config.Versioning = versioning
|
|
marker := &filer_pb.Entry{Name: "folder", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: "application/octet-stream"}, Extended: map[string][]byte{s3_constants.ExtAmzAclKey: anonymousReadGrant("READ")}}
|
|
f.entries["/buckets/b/folder"] = marker
|
|
for _, query := range []string{"", "versionId=null"} {
|
|
rr := serveAnonymousRead(s3a, method, "folder/", query, nil)
|
|
require.Equal(t, 200, rr.Code, query+": "+rr.Body.String())
|
|
require.Equal(t, "null", rr.Header().Get("x-amz-version-id"))
|
|
}
|
|
rr := serveAnonymousRead(s3a, method, "folder", "", nil)
|
|
require.Equal(t, 404, rr.Code, "the bare prefix is still not an object")
|
|
marker.Extended[s3_constants.ExtAmzAclKey] = []byte(`[]`)
|
|
rr = serveAnonymousRead(s3a, method, "folder/", "", nil)
|
|
require.Equal(t, 403, rr.Code, rr.Body.String())
|
|
require.Empty(t, rr.Header().Get("x-amz-version-id"))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAnonymousObjectACLVersionedDirectoryIdentity distinguishes slash keys from
|
|
// implicit parents, deleted markers, and objects stored under the bare prefix.
|
|
func TestAnonymousObjectACLVersionedDirectoryIdentity(t *testing.T) {
|
|
for _, versioning := range []string{"Enabled", "Suspended"} {
|
|
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
|
for _, kind := range []string{"implicit parent", "deleted marker", "regular file", "prefix object", "empty prefix object"} {
|
|
for _, route := range []struct {
|
|
name, query string
|
|
conditional, history bool
|
|
}{{name: "latest"}, {name: "null", query: "versionId=null"}, {name: "conditional latest", conditional: true}, {name: "conditional null", query: "versionId=null", conditional: true}, {name: "empty history", history: true}} {
|
|
t.Run(versioning+"/"+method+"/"+kind+"/"+route.name, func(t *testing.T) {
|
|
f := &anonymousReadDirectoryFiler{anonymousReadFiler: &anonymousReadFiler{entries: make(map[string]*filer_pb.Entry)}}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, f))
|
|
s3a.iam = &IdentityAccessManagement{isAuthEnabled: true}
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b", Ownership: s3_constants.OwnershipObjectWriter, Versioning: versioning})
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
s3a.iam.policyEngine = s3a.policyEngine
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("b", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject*","Resource":"arn:aws:s3:::b/folder/"}]}`))
|
|
entry := &filer_pb.Entry{Name: "folder", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: 1700000000}}
|
|
if kind == "deleted marker" {
|
|
// Deletion clears the marker metadata while preserving a parent with children.
|
|
entry.Extended = make(map[string][]byte)
|
|
} else if kind == "regular file" || kind == "prefix object" || kind == "empty prefix object" {
|
|
entry = anonymousReadEntry([]byte(`[]`))
|
|
entry.Name = "folder"
|
|
if kind != "regular file" {
|
|
entry.MarkPrefixObject()
|
|
}
|
|
if kind == "empty prefix object" {
|
|
entry.Content = nil
|
|
entry.Attributes.FileSize = 0
|
|
entry.Attributes.Mime = ""
|
|
}
|
|
}
|
|
f.entries["/buckets/b/folder"] = entry
|
|
f.entries["/buckets/b/folder/child"] = anonymousReadEntry([]byte(`[]`))
|
|
assertMissing := func(rr *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String())
|
|
for _, header := range []string{"x-amz-version-id", "Last-Modified", "ETag"} {
|
|
require.Empty(t, rr.Header().Get(header), header)
|
|
}
|
|
require.NotContains(t, rr.Body.String(), "hello world")
|
|
}
|
|
headers := map[string]string{}
|
|
if route.conditional {
|
|
headers["If-Modified-Since"] = "Fri, 01 Jan 2100 00:00:00 GMT"
|
|
}
|
|
if route.history {
|
|
// An empty history exercises recovery instead of the no-history fast path.
|
|
f.entries["/buckets/b/folder/.versions"] = &filer_pb.Entry{IsDirectory: true, Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("")}}
|
|
}
|
|
// Equivalent filer-path spellings must not recover a different object's data.
|
|
for _, object := range []string{"folder/", "folder//", "folder\\"} {
|
|
assertMissing(serveAnonymousRead(s3a, method, object, route.query, headers))
|
|
}
|
|
if kind == "regular file" || kind == "prefix object" || kind == "empty prefix object" {
|
|
// A slash-only policy must not authorize data belonging to the bare key.
|
|
rr := serveAnonymousRead(s3a, method, "folder", route.query, nil)
|
|
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
|
entry.Extended[s3_constants.ExtAmzAclKey] = anonymousReadGrant("READ")
|
|
rr = serveAnonymousRead(s3a, method, "folder", route.query, nil)
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
if method == http.MethodGet {
|
|
require.Equal(t, string(entry.Content), rr.Body.String())
|
|
}
|
|
}
|
|
// The private child remains independently protected.
|
|
rr := serveAnonymousRead(s3a, method, "folder/child", "", nil)
|
|
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
|
|
if method == http.MethodGet {
|
|
require.Contains(t, rr.Body.String(), "AccessDenied")
|
|
}
|
|
// A real historical slash-key version is a file, not a regular-path marker.
|
|
version := anonymousReadEntry([]byte(`[]`))
|
|
version.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
|
|
f.entries["/buckets/b/folder/.versions/v_v1"] = version
|
|
f.entries["/buckets/b/folder/.versions"] = &filer_pb.Entry{IsDirectory: true, Extended: map[string][]byte{s3_constants.ExtLatestVersionIdKey: []byte("v1"), s3_constants.ExtLatestVersionFileNameKey: []byte("v_v1")}}
|
|
for _, versionQuery := range []string{"versionId=v1", ""} {
|
|
rr = serveAnonymousRead(s3a, method, "folder/", versionQuery, nil)
|
|
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
|
require.Equal(t, "v1", rr.Header().Get("x-amz-version-id"))
|
|
if method == http.MethodGet {
|
|
require.Equal(t, "hello world", rr.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|