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>
This commit is contained in:
yi111
2026-09-28 06:56:46 +08:00
committed by GitHub
co-authored by Chris Lu
parent a976b21010
commit a0ee7ba314
2 changed files with 389 additions and 21 deletions
+343
View File
@@ -0,0 +1,343 @@
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")
}
+46 -21
View File
@@ -503,31 +503,56 @@ func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Reque
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
}
// bucketHasUserObjects checks whether a bucket contains any non-special entries.
// Special entries (.uploads, *.versions) are internal to S3 and don't count as user objects.
// bucketHasUserObjects checks whether a bucket contains any user objects.
// Empty directories left behind by deleted objects and internal folders
// (.uploads, *.versions) do not count as user objects.
func (s3a *S3ApiServer) bucketHasUserObjects(bucket string) (bool, error) {
bucketPath := s3a.option.BucketsPath + "/" + bucket
startFrom := ""
// Start with a small batch — most non-empty buckets have a real object early.
// If we only find special entries, switch to larger batches to page through quickly.
limit := uint32(10)
for {
entries, isLast, err := s3a.list(bucketPath, "", startFrom, false, limit)
if err != nil {
return false, err
}
for _, entry := range entries {
if entry.Name != s3_constants.MultipartUploadsFolder &&
!strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) {
return true, nil
return s3a.dirHasUserObjects(s3a.option.BucketsPath + "/" + bucket)
}
func (s3a *S3ApiServer) dirHasUserObjects(root string) (bool, error) {
dirs := []string{root}
for len(dirs) > 0 {
dir := dirs[len(dirs)-1]
dirs = dirs[:len(dirs)-1]
startFrom := ""
// Start with a small batch — most non-empty buckets have a real object
// early; switch to larger batches to page through quickly.
limit := uint32(10)
for {
entries, isLast, err := s3a.list(dir, "", startFrom, false, limit)
if err != nil {
if isFilerNotFound(err) {
break // the directory was deleted between listing and walking it
}
return false, err
}
startFrom = entry.Name
for _, entry := range entries {
startFrom = entry.Name
if entry.Name == "" || entry.Name == "." || entry.Name == ".." || strings.Contains(entry.Name, "/") {
continue
}
if !entry.IsDirectory {
return true, nil
}
// An explicit directory object counts even under a reserved name.
if entry.IsDirectoryKeyObject() {
return true, nil
}
// Internal folders are skipped by object listing at every level,
// so a directory with a reserved name never counts.
if isReservedDirectoryName(entry.Name) {
continue
}
dirs = append(dirs, dir+"/"+entry.Name)
}
if isLast || len(entries) == 0 {
break
}
limit = 1000
}
if isLast {
return false, nil
}
limit = 1000
}
return false, nil
}
// hasObjectsWithActiveLocks checks if any objects in the bucket have active retention or legal hold