From 23adeb37e20bfed76d99f53b0f10337cc563a92d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 2 Sep 2026 11:35:19 -0700 Subject: [PATCH] s3: check Object Lock on directory-marker keys before bucket deletion (#11096) recursivelyCheckLocksWithClient tested EntryHasActiveLock only on non-directory entries, so a directory-marker object (an S3 key ending in "/") that carries retention or a legal hold was recursed into but never lock-checked. DeleteBucket then saw no locks and removed the bucket, destroying an object under active Object Lock along with the rest of the bucket. DeleteObject already enforces the lock on the same key, so the two paths disagreed. Check the directory entry for an active lock before recursing. Claude-Session: https://claude.ai/code/session_011QqNaxZwnpHgMoAZNp3RkY --- weed/s3api/s3_objectlock/object_lock_check.go | 7 ++ .../s3_objectlock/object_lock_check_test.go | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 weed/s3api/s3_objectlock/object_lock_check_test.go diff --git a/weed/s3api/s3_objectlock/object_lock_check.go b/weed/s3api/s3_objectlock/object_lock_check.go index c1c8c0b18..da09b75d8 100644 --- a/weed/s3api/s3_objectlock/object_lock_check.go +++ b/weed/s3api/s3_objectlock/object_lock_check.go @@ -153,6 +153,13 @@ func recursivelyCheckLocksWithClient(ctx context.Context, client filer_pb.Seawee return false, err } } else { + // A directory-marker object (key ending in "/") is stored as a + // directory entry and can carry a lock, so check it before recursing. + if EntryHasActiveLock(entry, currentTime) { + *hasLocks = true + glog.V(2).Infof("Found directory marker with active lock: %s/%s", dir, entry.Name) + return true, nil + } // Recursively check subdirectories if err := recursivelyCheckLocksWithClient(ctx, client, subDir, hasLocks, currentTime); err != nil { return false, err diff --git a/weed/s3api/s3_objectlock/object_lock_check_test.go b/weed/s3api/s3_objectlock/object_lock_check_test.go new file mode 100644 index 000000000..8f0894d5c --- /dev/null +++ b/weed/s3api/s3_objectlock/object_lock_check_test.go @@ -0,0 +1,93 @@ +package s3_objectlock + +import ( + "context" + "io" + "strconv" + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +type fakeListStream struct { + entries []*filer_pb.Entry + index int +} + +func (s *fakeListStream) Recv() (*filer_pb.ListEntriesResponse, error) { + if s.index >= len(s.entries) { + return nil, io.EOF + } + e := s.entries[s.index] + s.index++ + return &filer_pb.ListEntriesResponse{Entry: e}, nil +} + +func (s *fakeListStream) Header() (metadata.MD, error) { return metadata.MD{}, nil } +func (s *fakeListStream) Trailer() metadata.MD { return metadata.MD{} } +func (s *fakeListStream) CloseSend() error { return nil } +func (s *fakeListStream) Context() context.Context { return context.Background() } +func (s *fakeListStream) SendMsg(any) error { return nil } +func (s *fakeListStream) RecvMsg(any) error { return nil } + +// fakeFilerClient serves a fixed directory -> entries map. +type fakeFilerClient struct { + filer_pb.SeaweedFilerClient + dirs map[string][]*filer_pb.Entry +} + +func (c *fakeFilerClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) { + entries := c.dirs[in.Directory] + if in.StartFromFileName != "" { + // Already returned everything on the first page; the second call gets nothing. + return &fakeListStream{}, nil + } + return &fakeListStream{entries: entries}, nil +} + +func lockedDirMarker(name string) *filer_pb.Entry { + until := strconv.FormatInt(time.Now().Add(24*time.Hour).Unix(), 10) + return &filer_pb.Entry{ + Name: name, + IsDirectory: true, + Extended: map[string][]byte{ + s3_constants.ExtObjectLockModeKey: []byte(s3_constants.RetentionModeCompliance), + s3_constants.ExtRetentionUntilDateKey: []byte(until), + }, + } +} + +func TestHasObjectsWithActiveLocksDirectoryMarker(t *testing.T) { + client := &fakeFilerClient{dirs: map[string][]*filer_pb.Entry{ + "/buckets/lockb": {lockedDirMarker("records")}, + // The marker's own subtree is empty; only the marker entry carries the lock. + "/buckets/lockb/records": {}, + }} + + has, err := HasObjectsWithActiveLocks(context.Background(), client, "/buckets/lockb") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !has { + t.Fatalf("expected the locked directory marker to be reported as an active lock") + } +} + +func TestHasObjectsWithActiveLocksUnlockedDirectory(t *testing.T) { + client := &fakeFilerClient{dirs: map[string][]*filer_pb.Entry{ + "/buckets/plainb": {{Name: "prefix", IsDirectory: true}}, + "/buckets/plainb/prefix": {{Name: "obj.txt"}}, + }} + + has, err := HasObjectsWithActiveLocks(context.Background(), client, "/buckets/plainb") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if has { + t.Fatalf("expected no active lock for an ordinary prefix and object") + } +}