Files
seaweedfs/weed/s3api/s3api_bucket_delete_test.go
T
a0ee7ba314 s3: ignore empty intermediate directories in bucketHasUserObjects (#11490) (#11491)
* s3: ignore empty intermediate directories in bucketHasUserObjects (#11490)

* s3: keep nested reserved-named dirs from hiding user objects

Reserved folders (.uploads, *.versions) are internal only at the bucket
root; deeper entries with those names are user key prefixes and must be
walked. Also treat a missing subdirectory as empty via isFilerNotFound
(list errors cross gRPC as status errors, not the sentinel), let names
containing backslashes count as objects, and walk iteratively so empty
chains deeper than the old scan depth no longer report non-empty.

* s3: treat reserved-named directories as internal at every level

Object listing interprets .uploads and *.versions directories as
internal storage wherever they appear, so walking them during the
emptiness check would report invisible version remnants as user objects
and block deletion. A reserved name on a file still counts, matching
listing which only special-cases directories.

* s3: count explicit directory objects under reserved names

A directory object created by PutObject (MIME or prefix-object marker
set) is user data even when named .uploads or *.versions; only a plain
directory with a reserved name is internal storage.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-09-28 06:56:46 +08:00

344 lines
10 KiB
Go

package s3api
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeBucketDeleteFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
entriesByDir map[string][]*filer_pb.Entry
deleteReq *filer_pb.DeleteEntryRequest
}
func (f *fakeBucketDeleteFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
entries, ok := f.entriesByDir[req.Directory]
if !ok {
return nil, filer_pb.ErrNotFound
}
for _, e := range entries {
if e.Name == req.Name {
return &filer_pb.LookupDirectoryEntryResponse{Entry: e}, nil
}
}
return nil, filer_pb.ErrNotFound
}
func (f *fakeBucketDeleteFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
entries := f.entriesByDir[req.Directory]
if inPrefix := req.Prefix; inPrefix != "" && inPrefix != "/" {
filtered := make([]*filer_pb.Entry, 0)
for _, e := range entries {
if strings.HasPrefix(e.Name, inPrefix) {
filtered = append(filtered, e)
}
}
entries = filtered
}
if req.StartFromFileName != "" {
filtered := make([]*filer_pb.Entry, 0)
for _, e := range entries {
if e.Name > req.StartFromFileName || (req.InclusiveStartFrom && e.Name == req.StartFromFileName) {
filtered = append(filtered, e)
}
}
entries = filtered
}
if req.Limit > 0 && int(req.Limit) < len(entries) {
entries = entries[:req.Limit]
}
for _, entry := range entries {
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
return err
}
}
return nil
}
func (f *fakeBucketDeleteFiler) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
f.deleteReq = req
return &filer_pb.DeleteEntryResponse{}, nil
}
func newBucketDeleteTestServer(t *testing.T, f *fakeBucketDeleteFiler, allowDeleteBucketNotEmpty bool) *S3ApiServer {
t.Helper()
s3a := newFailoverTestServer(t, startFakeFiler(t, f))
s3a.option.BucketsPath = "/buckets"
s3a.option.AllowDeleteBucketNotEmpty = allowDeleteBucketNotEmpty
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.iam = &IdentityAccessManagement{}
return s3a
}
func TestBucketHasUserObjects_EmptyDirectories(t *testing.T) {
cases := []struct {
name string
entriesByDir map[string][]*filer_pb.Entry
wantHasUser bool
}{
{
name: "completely empty bucket",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {},
},
wantHasUser: false,
},
{
name: "bucket with root file",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "file.txt", IsDirectory: false},
},
},
wantHasUser: true,
},
{
name: "bucket with leftover empty directory from rm --recursive",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "data", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/b/data": {},
},
wantHasUser: false,
},
{
name: "bucket with nested leftover empty directories",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "data", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/b/data": {
{Name: "2026", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/b/data/2026": {},
},
wantHasUser: false,
},
{
name: "bucket with nested directory containing a file",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "data", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/b/data": {
{Name: "2026", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/b/data/2026": {
{Name: "report.pdf", IsDirectory: false},
},
},
wantHasUser: true,
},
{
name: "bucket with explicit directory object (MIME set)",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "logs", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: "application/x-directory"}},
},
"/buckets/b/logs": {},
},
wantHasUser: true,
},
{
name: "bucket with only reserved folders (.uploads, *.versions)",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: s3_constants.MultipartUploadsFolder, IsDirectory: true},
{Name: "oldfile.txt" + s3_constants.VersionsFolder, IsDirectory: true},
},
},
wantHasUser: false,
},
{
name: "nested versions-named directories are internal",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "logs", IsDirectory: true},
},
"/buckets/b/logs": {
{Name: "foo" + s3_constants.VersionsFolder, IsDirectory: true},
},
"/buckets/b/logs/foo" + s3_constants.VersionsFolder: {
{Name: "v1", IsDirectory: false},
},
},
wantHasUser: false,
},
{
name: "nested uploads-named directories are internal",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "data", IsDirectory: true},
},
"/buckets/b/data": {
{Name: s3_constants.MultipartUploadsFolder, IsDirectory: true},
},
"/buckets/b/data/" + s3_constants.MultipartUploadsFolder: {
{Name: "part-1", IsDirectory: false},
},
},
wantHasUser: false,
},
{
name: "directory object with a reserved name counts",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "data", IsDirectory: true},
},
"/buckets/b/data": {
{Name: s3_constants.MultipartUploadsFolder, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: s3_constants.FolderMimeType}},
},
},
wantHasUser: true,
},
{
name: "file with a reserved name counts",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: "dir", IsDirectory: true},
},
"/buckets/b/dir": {
{Name: "data" + s3_constants.VersionsFolder, IsDirectory: false},
},
},
wantHasUser: true,
},
{
name: "file with backslash in name",
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {
{Name: `a\b`, IsDirectory: false},
},
},
wantHasUser: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := &fakeBucketDeleteFiler{entriesByDir: tc.entriesByDir}
s3a := newBucketDeleteTestServer(t, f, false)
got, err := s3a.bucketHasUserObjects("b")
require.NoError(t, err)
assert.Equal(t, tc.wantHasUser, got)
})
}
}
func TestBucketHasUserObjects_DeepEmptyChain(t *testing.T) {
entriesByDir := map[string][]*filer_pb.Entry{
"/buckets/b": {{Name: "d0", IsDirectory: true}},
}
dir := "/buckets/b/d0"
for i := 1; i < 500; i++ {
child := fmt.Sprintf("d%d", i)
entriesByDir[dir] = []*filer_pb.Entry{{Name: child, IsDirectory: true}}
dir = dir + "/" + child
}
entriesByDir[dir] = []*filer_pb.Entry{}
f := &fakeBucketDeleteFiler{entriesByDir: entriesByDir}
s3a := newBucketDeleteTestServer(t, f, false)
got, err := s3a.bucketHasUserObjects("b")
require.NoError(t, err)
assert.False(t, got)
}
func TestDeleteBucketHandler_EmptyDirectoriesAllowedWhenNotEmptyFalse(t *testing.T) {
// A bucket that holds only an empty directory leftover from deleted objects
// must succeed when AllowDeleteBucketNotEmpty is false.
f := &fakeBucketDeleteFiler{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets": {
{Name: "repro-bucket", IsDirectory: true},
},
"/buckets/repro-bucket": {
{Name: "data", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/repro-bucket/data": {},
},
}
s3a := newBucketDeleteTestServer(t, f, false)
s3a.bucketConfigCache.Set("repro-bucket", &BucketConfig{Name: "repro-bucket"})
req := httptest.NewRequest(http.MethodDelete, "/repro-bucket", nil)
req = mux.SetURLVars(req, map[string]string{"bucket": "repro-bucket"})
rr := httptest.NewRecorder()
s3a.DeleteBucketHandler(rr, req)
assert.Equal(t, http.StatusNoContent, rr.Code, "deleting bucket with only empty directory must succeed: %s", rr.Body.String())
assert.NotNil(t, f.deleteReq)
assert.Equal(t, "/buckets", f.deleteReq.Directory)
assert.Equal(t, "repro-bucket", f.deleteReq.Name)
assert.True(t, f.deleteReq.IsRecursive)
}
func TestDeleteBucketHandler_RefusesBucketWithFilesWhenNotEmptyFalse(t *testing.T) {
f := &fakeBucketDeleteFiler{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets": {
{Name: "non-empty-bucket", IsDirectory: true},
},
"/buckets/non-empty-bucket": {
{Name: "data", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
},
"/buckets/non-empty-bucket/data": {
{Name: "hello.txt", IsDirectory: false},
},
},
}
s3a := newBucketDeleteTestServer(t, f, false)
s3a.bucketConfigCache.Set("non-empty-bucket", &BucketConfig{Name: "non-empty-bucket"})
req := httptest.NewRequest(http.MethodDelete, "/non-empty-bucket", nil)
req = mux.SetURLVars(req, map[string]string{"bucket": "non-empty-bucket"})
rr := httptest.NewRecorder()
s3a.DeleteBucketHandler(rr, req)
assert.Equal(t, http.StatusConflict, rr.Code)
assert.Contains(t, rr.Body.String(), "<Code>BucketNotEmpty</Code>")
assert.Nil(t, f.deleteReq, "delete request must not have been sent to filer")
}
func TestDeleteBucketHandler_RefusesBucketWithDirectoryObjectWhenNotEmptyFalse(t *testing.T) {
f := &fakeBucketDeleteFiler{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets": {
{Name: "dir-obj-bucket", IsDirectory: true},
},
"/buckets/dir-obj-bucket": {
{Name: "photos", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: "application/x-directory"}},
},
"/buckets/dir-obj-bucket/photos": {},
},
}
s3a := newBucketDeleteTestServer(t, f, false)
s3a.bucketConfigCache.Set("dir-obj-bucket", &BucketConfig{Name: "dir-obj-bucket"})
req := httptest.NewRequest(http.MethodDelete, "/dir-obj-bucket", nil)
req = mux.SetURLVars(req, map[string]string{"bucket": "dir-obj-bucket"})
rr := httptest.NewRecorder()
s3a.DeleteBucketHandler(rr, req)
assert.Equal(t, http.StatusConflict, rr.Code)
assert.Contains(t, rr.Body.String(), "<Code>BucketNotEmpty</Code>")
assert.Nil(t, f.deleteReq, "delete request must not have been sent to filer")
}