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") + } +}