Files
seaweedfs/weed/s3api/s3api_object_anonymous_cache_test.go
T
zhao-ycandChris Lu d829275de6 fix(s3): recheck cache metadata and validate null objects (#11618)
* 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>
2026-10-06 14:25:43 +08:00

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