Files
seaweedFS/weed/s3api/s3api_object_handlers_list_test.go
Plamen Nikolov ff84ef880d fix(s3api): make ListObjectsV1 namespaced and prevent marker-echo pagination loops (#8409)
* fix(s3api): make ListObjectsV1 namespaced and stop marker-echo pagination loops

* test(s3api): harden marker-echo coverage and align V1 encoding tag

* test(s3api): cover encoded marker matching and trim redundant setup

* refactor(s3api): tighten V1 list helper visibility and test mock docs
2026-02-23 23:45:08 -08:00

817 lines
29 KiB
Go

package s3api
import (
"context"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
grpc "google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
type testListEntriesStream struct {
entries []*filer_pb.Entry
idx int
}
func (s *testListEntriesStream) Recv() (*filer_pb.ListEntriesResponse, error) {
if s.idx >= len(s.entries) {
return nil, io.EOF
}
resp := &filer_pb.ListEntriesResponse{Entry: s.entries[s.idx]}
s.idx++
return resp, nil
}
func (s *testListEntriesStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
func (s *testListEntriesStream) Trailer() metadata.MD { return metadata.MD{} }
func (s *testListEntriesStream) Close() error { return nil }
func (s *testListEntriesStream) Context() context.Context { return context.Background() }
func (s *testListEntriesStream) SendMsg(m interface{}) error { return nil }
func (s *testListEntriesStream) RecvMsg(m interface{}) error { return nil }
func (s *testListEntriesStream) CloseSend() error { return nil }
type testFilerClient struct {
filer_pb.SeaweedFilerClient
entriesByDir map[string][]*filer_pb.Entry
}
func (c *testFilerClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
entries := c.entriesByDir[in.Directory]
// Simplified mock: implements basic prefix filtering but ignores Limit, StartFromFileName, and InclusiveStartFrom
// to keep test logic focused. Prefix "/" is treated as no filter for bucket root compatibility.
if in.Prefix != "" && in.Prefix != "/" {
filtered := make([]*filer_pb.Entry, 0)
for _, e := range entries {
if strings.HasPrefix(e.Name, in.Prefix) {
filtered = append(filtered, e)
}
}
entries = filtered
}
// Respect Limit
if in.Limit > 0 && int(in.Limit) < len(entries) {
entries = entries[:in.Limit]
}
return &testListEntriesStream{entries: entries}, nil
}
type markerEchoFilerClient struct {
filer_pb.SeaweedFilerClient
entriesByDir map[string][]*filer_pb.Entry
returnFollowing bool
}
// markerEchoFilerClient intentionally ignores request Limit/InclusiveStartFrom
// and simulates a backend that may echo StartFromFileName. entriesByDir controls
// returned entries; returnFollowing controls whether ListEntries returns only the
// echoed marker or the echoed marker plus following entries.
func (c *markerEchoFilerClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
entries := c.entriesByDir[in.Directory]
ensureEntryAttributes(entries)
if in.StartFromFileName == "" {
return &testListEntriesStream{entries: entries}, nil
}
for i, e := range entries {
if e.Name == in.StartFromFileName {
// Emulate buggy backend behavior: return marker again even when exclusive.
if c.returnFollowing {
echoAndFollowing := entries[i:]
return &testListEntriesStream{entries: echoAndFollowing}, nil
}
return &testListEntriesStream{entries: []*filer_pb.Entry{e}}, nil
}
}
return &testListEntriesStream{entries: nil}, nil
}
func ensureEntryAttributes(entries []*filer_pb.Entry) {
for _, entry := range entries {
if entry == nil {
continue
}
if entry.Attributes == nil {
entry.Attributes = &filer_pb.FuseAttributes{}
}
}
}
func TestListObjectsHandler(t *testing.T) {
// https://docs.aws.amazon.com/AmazonS3/latest/API/v2-RESTBucketGET.html
expected := `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult><Name>test_container</Name><Prefix></Prefix><Marker></Marker><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>1.zip</Key><ETag>&#34;4397da7a7649e8085de9916c240e8166&#34;</ETag><Size>1234567</Size><Owner><ID>65a011niqo39cdf8ec533ec3d1ccaafsa932</ID></Owner><StorageClass>STANDARD</StorageClass><LastModified>2011-04-09T12:34:49Z</LastModified></Contents><EncodingType></EncodingType></ListBucketResult>`
response := ListBucketResult{
Name: "test_container",
Prefix: "",
Marker: "",
NextMarker: "",
MaxKeys: 1000,
IsTruncated: false,
Contents: []ListEntry{{
Key: "1.zip",
LastModified: time.Date(2011, 4, 9, 12, 34, 49, 0, time.UTC),
ETag: "\"4397da7a7649e8085de9916c240e8166\"",
Size: 1234567,
Owner: &CanonicalUser{
ID: "65a011niqo39cdf8ec533ec3d1ccaafsa932",
},
StorageClass: "STANDARD",
}},
}
encoded := string(s3err.EncodeXMLResponse(response))
if encoded != expected {
t.Errorf("unexpected output: %s\nexpecting:%s", encoded, expected)
}
}
func TestListObjectsV1NamespaceResponse(t *testing.T) {
response := ListBucketResult{
Name: "test_container",
Prefix: "",
Marker: "",
NextMarker: "",
MaxKeys: 1000,
IsTruncated: false,
}
encoded := string(s3err.EncodeXMLResponse(toListBucketResultV1(response)))
assert.Contains(t, encoded, `<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">`)
}
func Test_normalizePrefixMarker(t *testing.T) {
type args struct {
prefix string
marker string
}
tests := []struct {
name string
args args
wantAlignedDir string
wantAlignedPrefix string
wantAlignedMarker string
}{
{"bucket root listing with delimiter",
args{"/",
""},
"",
"",
"",
},
{"prefix is a directory",
args{"/parentDir/data/",
""},
"parentDir",
"data",
"",
},
{"normal case",
args{"/parentDir/data/0",
"parentDir/data/0e/0e149049a2137b0cc12e"},
"parentDir/data",
"0",
"0e/0e149049a2137b0cc12e",
},
{"empty prefix",
args{"",
"parentDir/data/0e/0e149049a2137b0cc12e"},
"",
"",
"parentDir/data/0e/0e149049a2137b0cc12e",
},
{"empty directory",
args{"parent",
"parentDir/data/0e/0e149049a2137b0cc12e"},
"",
"parent",
"parentDir/data/0e/0e149049a2137b0cc12e",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotAlignedDir, gotAlignedPrefix, gotAlignedMarker := normalizePrefixMarker(tt.args.prefix, tt.args.marker)
assert.Equalf(t, tt.wantAlignedDir, gotAlignedDir, "normalizePrefixMarker(%v, %v)", tt.args.prefix, tt.args.marker)
assert.Equalf(t, tt.wantAlignedPrefix, gotAlignedPrefix, "normalizePrefixMarker(%v, %v)", tt.args.prefix, tt.args.marker)
assert.Equalf(t, tt.wantAlignedMarker, gotAlignedMarker, "normalizePrefixMarker(%v, %v)", tt.args.prefix, tt.args.marker)
})
}
}
func TestBuildTruncatedNextMarker(t *testing.T) {
t.Run("does not duplicate prefix segment in next continuation token", func(t *testing.T) {
prefix := "export_2026-02-10_17-00-23"
nextMarker := "export_2026-02-10_17-00-23/4156000e.jpg"
actual := buildTruncatedNextMarker("xemu", prefix, nextMarker, false, "")
assert.Equal(t, "xemu/export_2026-02-10_17-00-23/4156000e.jpg", actual)
})
t.Run("keeps common prefix marker trailing slash", func(t *testing.T) {
actual := buildTruncatedNextMarker("xemu", "export_2026-02-10_17-00-23", "", true, "nested")
assert.Equal(t, "xemu/export_2026-02-10_17-00-23/nested/", actual)
})
t.Run("includes prefix for common prefix marker when request dir is empty", func(t *testing.T) {
actual := buildTruncatedNextMarker("", "foo", "", true, "bar")
assert.Equal(t, "foo/bar/", actual)
})
}
func TestAllowUnorderedParameterValidation(t *testing.T) {
// Test getListObjectsV1Args with allow-unordered parameter
t.Run("getListObjectsV1Args with allow-unordered", func(t *testing.T) {
// Test with allow-unordered=true
values := map[string][]string{
"allow-unordered": {"true"},
"delimiter": {"/"},
}
_, _, _, _, _, allowUnordered, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.True(t, allowUnordered, "allow-unordered should be true when set to 'true'")
// Test with allow-unordered=false
values = map[string][]string{
"allow-unordered": {"false"},
}
_, _, _, _, _, allowUnordered, errCode = getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.False(t, allowUnordered, "allow-unordered should be false when set to 'false'")
// Test without allow-unordered parameter
values = map[string][]string{}
_, _, _, _, _, allowUnordered, errCode = getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.False(t, allowUnordered, "allow-unordered should be false when not set")
})
// Test getListObjectsV2Args with allow-unordered parameter
t.Run("getListObjectsV2Args with allow-unordered", func(t *testing.T) {
// Test with allow-unordered=true
values := map[string][]string{
"allow-unordered": {"true"},
"delimiter": {"/"},
}
_, _, _, _, _, _, _, allowUnordered, errCode := getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.True(t, allowUnordered, "allow-unordered should be true when set to 'true'")
// Test with allow-unordered=false
values = map[string][]string{
"allow-unordered": {"false"},
}
_, _, _, _, _, _, _, allowUnordered, errCode = getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.False(t, allowUnordered, "allow-unordered should be false when set to 'false'")
// Test without allow-unordered parameter
values = map[string][]string{}
_, _, _, _, _, _, _, allowUnordered, errCode = getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.False(t, allowUnordered, "allow-unordered should be false when not set")
})
}
func TestDoListFilerEntries_BucketRootPrefixSlashDelimiterSlash_ListsDirectories(t *testing.T) {
// Regression test for a bug where doListFilerEntries returned early when
// prefix == "/" && delimiter == "/", causing bucket-root folder listings
// (e.g. Veeam v13) to return empty results.
s3a := &S3ApiServer{}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/test-bucket": {
{Name: "Veeam", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
},
},
}
cursor := &ListingCursor{maxKeys: 1000}
seen := make([]string, 0)
_, err := s3a.doListFilerEntries(client, "/buckets/test-bucket", "/", cursor, "", "/", false, "test-bucket", func(dir string, entry *filer_pb.Entry) {
if entry.IsDirectory {
seen = append(seen, entry.Name)
}
})
assert.NoError(t, err)
assert.Contains(t, seen, "Veeam")
}
func TestDoListFilerEntries_ExclusiveStartSkipsMarkerEcho(t *testing.T) {
s3a := &S3ApiServer{}
client := &markerEchoFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/test-bucket": {
{Name: "file.txt", Attributes: &filer_pb.FuseAttributes{}},
{Name: "test.txt", Attributes: &filer_pb.FuseAttributes{}},
},
},
}
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
nextMarker, err := s3a.doListFilerEntries(client, "/buckets/test-bucket", "", cursor, "test.txt", "", false, "test-bucket", func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
})
assert.NoError(t, err)
assert.Empty(t, seen, "marker entry should not be returned in exclusive mode")
assert.Equal(t, "", nextMarker, "next marker should be empty when only marker echo is returned")
}
func TestDoListFilerEntries_ExclusiveStartSkipsMarkerEchoWithSubsequentEntries(t *testing.T) {
s3a := &S3ApiServer{}
client := &markerEchoFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/test-bucket": {
{Name: "file.txt", Attributes: &filer_pb.FuseAttributes{}},
{Name: "test.txt", Attributes: &filer_pb.FuseAttributes{}},
{Name: "zebra.txt", Attributes: &filer_pb.FuseAttributes{}},
},
},
returnFollowing: true,
}
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
nextMarker, err := s3a.doListFilerEntries(client, "/buckets/test-bucket", "", cursor, "test.txt", "", false, "test-bucket", func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
})
assert.NoError(t, err)
assert.Equal(t, []string{"zebra.txt"}, seen, "marker should be skipped while subsequent entries are returned")
assert.Equal(t, "zebra.txt", nextMarker)
}
func TestAllowUnorderedWithDelimiterValidation(t *testing.T) {
t.Run("should return error when allow-unordered=true and delimiter are both present", func(t *testing.T) {
// Create a request with both allow-unordered=true and delimiter
req := httptest.NewRequest("GET", "/bucket?allow-unordered=true&delimiter=/", nil)
// Extract query parameters like the handler would
values := req.URL.Query()
// Test ListObjectsV1Args
_, _, delimiter, _, _, allowUnordered, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.True(t, allowUnordered, "allow-unordered should be true")
assert.Equal(t, "/", delimiter, "delimiter should be '/'")
// The validation should catch this combination
if allowUnordered && delimiter != "" {
assert.True(t, true, "Validation correctly detected invalid combination")
} else {
assert.Fail(t, "Validation should have detected invalid combination")
}
// Test ListObjectsV2Args
_, _, delimiter2, _, _, _, _, allowUnordered2, errCode2 := getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrNone, errCode2, "should not return error for valid parameters")
assert.True(t, allowUnordered2, "allow-unordered should be true")
assert.Equal(t, "/", delimiter2, "delimiter should be '/'")
// The validation should catch this combination
if allowUnordered2 && delimiter2 != "" {
assert.True(t, true, "Validation correctly detected invalid combination")
} else {
assert.Fail(t, "Validation should have detected invalid combination")
}
})
t.Run("should allow allow-unordered=true without delimiter", func(t *testing.T) {
// Create a request with only allow-unordered=true
req := httptest.NewRequest("GET", "/bucket?allow-unordered=true", nil)
values := req.URL.Query()
// Test ListObjectsV1Args
_, _, delimiter, _, _, allowUnordered, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.True(t, allowUnordered, "allow-unordered should be true")
assert.Equal(t, "", delimiter, "delimiter should be empty")
// This combination should be valid
if allowUnordered && delimiter != "" {
assert.Fail(t, "This should be a valid combination")
} else {
assert.True(t, true, "Valid combination correctly allowed")
}
})
t.Run("should allow delimiter without allow-unordered", func(t *testing.T) {
// Create a request with only delimiter
req := httptest.NewRequest("GET", "/bucket?delimiter=/", nil)
values := req.URL.Query()
// Test ListObjectsV1Args
_, _, delimiter, _, _, allowUnordered, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "should not return error for valid parameters")
assert.False(t, allowUnordered, "allow-unordered should be false")
assert.Equal(t, "/", delimiter, "delimiter should be '/'")
// This combination should be valid
if allowUnordered && delimiter != "" {
assert.Fail(t, "This should be a valid combination")
} else {
assert.True(t, true, "Valid combination correctly allowed")
}
})
}
func TestSanitizeV1MarkerEcho_NoProgressGuard(t *testing.T) {
response := ListBucketResult{
Marker: "test.txt",
NextMarker: "test.txt",
IsTruncated: true,
Contents: []ListEntry{
{Key: "test.txt"},
},
}
sanitizeV1MarkerEcho(&response, "test.txt", false)
assert.Empty(t, response.Contents)
assert.Equal(t, "", response.NextMarker)
assert.False(t, response.IsTruncated)
response2 := ListBucketResult{
Marker: "test file.txt",
NextMarker: "test%20file.txt",
IsTruncated: true,
Contents: []ListEntry{
{Key: "test%20file.txt"},
},
}
sanitizeV1MarkerEcho(&response2, "test file.txt", true)
assert.Empty(t, response2.Contents)
assert.Equal(t, "", response2.NextMarker)
assert.False(t, response2.IsTruncated)
}
// TestMaxKeysParameterValidation tests the validation of max-keys parameter
func TestMaxKeysParameterValidation(t *testing.T) {
t.Run("valid max-keys values should work", func(t *testing.T) {
// Test valid numeric values
values := map[string][]string{
"max-keys": {"100"},
}
_, _, _, _, _, _, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "valid max-keys should not return error")
_, _, _, _, _, _, _, _, errCode = getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "valid max-keys should not return error")
})
t.Run("invalid max-keys values should return error", func(t *testing.T) {
// Test non-numeric value
values := map[string][]string{
"max-keys": {"blah"},
}
_, _, _, _, _, _, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrInvalidMaxKeys, errCode, "non-numeric max-keys should return ErrInvalidMaxKeys")
_, _, _, _, _, _, _, _, errCode = getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrInvalidMaxKeys, errCode, "non-numeric max-keys should return ErrInvalidMaxKeys")
})
t.Run("empty max-keys should use default", func(t *testing.T) {
// Test empty max-keys
values := map[string][]string{}
_, _, _, _, maxkeys, _, errCode := getListObjectsV1Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "empty max-keys should not return error")
assert.Equal(t, int16(1000), maxkeys, "empty max-keys should use default value")
_, _, _, _, _, _, maxkeys2, _, errCode := getListObjectsV2Args(values)
assert.Equal(t, s3err.ErrNone, errCode, "empty max-keys should not return error")
assert.Equal(t, uint16(1000), maxkeys2, "empty max-keys should use default value")
})
}
// TestDelimiterWithDirectoryKeyObjects tests that directory key objects (like "0/") are properly
// grouped into common prefixes when using delimiters, matching AWS S3 behavior.
//
// This test addresses the issue found in test_bucket_list_delimiter_not_skip_special where
// directory key objects were incorrectly returned as individual keys instead of being
// grouped into common prefixes when a delimiter was specified.
func TestDelimiterWithDirectoryKeyObjects(t *testing.T) {
// This test simulates the failing test scenario:
// Objects: ['0/'] + ['0/1000', '0/1001', ..., '0/1998'] + ['1999', '1999#', '1999+', '2000']
// With delimiter='/', expect:
// - Keys: ['1999', '1999#', '1999+', '2000']
// - CommonPrefixes: ['0/']
t.Run("directory key object should be grouped into common prefix with delimiter", func(t *testing.T) {
// The fix ensures that when a delimiter is specified, directory key objects
// (entries that are both directories AND have MIME types set) undergo the same
// delimiter-based grouping logic as regular files.
// Before fix: '0/' would be returned as an individual key
// After fix: '0/' is grouped with '0/xxxx' objects into common prefix '0/'
// This matches AWS S3 behavior where all objects sharing a prefix up to the
// delimiter are grouped together, regardless of whether they are directory key objects.
assert.True(t, true, "Directory key objects should be grouped into common prefixes when delimiter is used")
})
t.Run("directory key object without delimiter should be individual key", func(t *testing.T) {
// When no delimiter is specified, directory key objects should still be
// returned as individual keys (existing behavior maintained).
assert.True(t, true, "Directory key objects should be individual keys when no delimiter is used")
})
}
// TestObjectLevelListPermissions tests that object-level List permissions work correctly
func TestObjectLevelListPermissions(t *testing.T) {
// Test the core functionality that was fixed for issue #7039
t.Run("Identity CanDo Object Level Permissions", func(t *testing.T) {
// Create identity with object-level List permission
identity := &Identity{
Name: "test-user",
Actions: []Action{
"List:test-bucket/allowed-prefix/*",
},
}
// Test cases for CanDo method
// Note: CanDo concatenates bucket + objectKey, so "test-bucket" + "/allowed-prefix/file.txt" = "test-bucket/allowed-prefix/file.txt"
testCases := []struct {
name string
action Action
bucket string
object string
shouldAllow bool
description string
}{
{
name: "allowed prefix exact match",
action: "List",
bucket: "test-bucket",
object: "/allowed-prefix/file.txt",
shouldAllow: true,
description: "Should allow access to objects under the allowed prefix",
},
{
name: "allowed prefix subdirectory",
action: "List",
bucket: "test-bucket",
object: "/allowed-prefix/subdir/file.txt",
shouldAllow: true,
description: "Should allow access to objects in subdirectories under the allowed prefix",
},
{
name: "denied different prefix",
action: "List",
bucket: "test-bucket",
object: "/other-prefix/file.txt",
shouldAllow: false,
description: "Should deny access to objects under a different prefix",
},
{
name: "denied different bucket",
action: "List",
bucket: "other-bucket",
object: "/allowed-prefix/file.txt",
shouldAllow: false,
description: "Should deny access to objects in a different bucket",
},
{
name: "denied root level",
action: "List",
bucket: "test-bucket",
object: "/file.txt",
shouldAllow: false,
description: "Should deny access to root-level objects when permission is prefix-specific",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := identity.CanDo(tc.action, tc.bucket, tc.object)
assert.Equal(t, tc.shouldAllow, result, tc.description)
})
}
})
t.Run("Bucket Level Permissions Still Work", func(t *testing.T) {
// Create identity with bucket-level List permission
identity := &Identity{
Name: "bucket-user",
Actions: []Action{
"List:test-bucket",
},
}
// Should allow access to any object in the bucket
testCases := []struct {
object string
}{
{"/file.txt"},
{"/prefix/file.txt"},
{"/deep/nested/path/file.txt"},
}
for _, tc := range testCases {
result := identity.CanDo("List", "test-bucket", tc.object)
assert.True(t, result, "Bucket-level permission should allow access to %s", tc.object)
}
// Should deny access to different buckets
result := identity.CanDo("List", "other-bucket", "/file.txt")
assert.False(t, result, "Should deny access to objects in different buckets")
})
t.Run("Empty Object With Prefix Logic", func(t *testing.T) {
// Test the middleware logic fix: when object is empty but prefix is provided,
// the object should be set to the prefix value for permission checking
// This simulates the fixed logic in auth_credentials.go:
// if (object == "/" || object == "") && prefix != "" {
// object = prefix
// }
testCases := []struct {
name string
object string
prefix string
expected string
}{
{
name: "empty object with prefix",
object: "",
prefix: "/allowed-prefix/",
expected: "/allowed-prefix/",
},
{
name: "slash object with prefix",
object: "/",
prefix: "/allowed-prefix/",
expected: "/allowed-prefix/",
},
{
name: "object already set",
object: "/existing-object",
prefix: "/some-prefix/",
expected: "/existing-object",
},
{
name: "no prefix provided",
object: "",
prefix: "",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the middleware logic
object := tc.object
prefix := tc.prefix
if (object == "/" || object == "") && prefix != "" {
object = prefix
}
assert.Equal(t, tc.expected, object, "Object should be correctly set based on prefix")
})
}
})
t.Run("Issue 7039 Scenario", func(t *testing.T) {
// Test the exact scenario from the GitHub issue
// User has permission: "List:bdaai-shared-bucket/txzl/*"
// They make request: GET /bdaai-shared-bucket?prefix=txzl/
identity := &Identity{
Name: "issue-user",
Actions: []Action{
"List:bdaai-shared-bucket/txzl/*",
},
}
// For a list request like "GET /bdaai-shared-bucket?prefix=txzl/":
// - bucket = "bdaai-shared-bucket"
// - object = "" (no object in URL path)
// - prefix = "/txzl/" (from query parameter)
// After our middleware fix, it should check permission for the prefix
// Simulate: action=ACTION_LIST && object=="" && prefix="/txzl/" → object="/txzl/"
result := identity.CanDo("List", "bdaai-shared-bucket", "/txzl/")
// This should be allowed because:
// target = "List:bdaai-shared-bucket/txzl/"
// permission = "List:bdaai-shared-bucket/txzl/*"
// wildcard match: "List:bdaai-shared-bucket/txzl/" starts with "List:bdaai-shared-bucket/txzl/"
assert.True(t, result, "User with 'List:bdaai-shared-bucket/txzl/*' should be able to list with prefix txzl/")
// Test that they can't list with a different prefix
result = identity.CanDo("List", "bdaai-shared-bucket", "/other-prefix/")
assert.False(t, result, "User should not be able to list with a different prefix")
// Test that they can't list a different bucket
result = identity.CanDo("List", "other-bucket", "/txzl/")
assert.False(t, result, "User should not be able to list a different bucket")
})
t.Log("This test validates the fix for issue #7039")
t.Log("Object-level List permissions like 'List:bucket/prefix/*' now work correctly")
t.Log("Middleware properly extracts prefix for permission validation")
}
func TestListObjectsV2_Regression(t *testing.T) {
// Reproduce issue: ListObjectsV2 without delimiter returns 0 objects even though files exist
// Structure: s3://reports/reports/[timestamp]/file
// Request: ListObjectsV2(Bucket='reports', Prefix='reports/')
s3a := &S3ApiServer{}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/reports": {
{Name: "reports", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
},
"/buckets/reports/reports": {
{Name: "01771152617961894200", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
},
"/buckets/reports/reports/01771152617961894200": {
{Name: "file1", IsDirectory: false, Attributes: &filer_pb.FuseAttributes{}},
},
},
}
// s3.list_objects_v2(Bucket='reports', Prefix='reports/')
// normalized: requestDir="", prefix="reports"
// doListFilerEntries called with dir="/buckets/reports", prefix="reports", delimiter=""
cursor := &ListingCursor{maxKeys: 1000, prefixEndsOnDelimiter: true} // set based on "reports/" original prefix
var results []string
// Call doListFilerEntries directly to unit test listing logic in isolation,
// simulating parameters passed from listFilerEntries for prefix "reports/".
_, err := s3a.doListFilerEntries(client, "/buckets/reports", "reports", cursor, "", "", false, "reports", func(dir string, entry *filer_pb.Entry) {
if !entry.IsDirectory {
results = append(results, entry.Name)
}
})
assert.NoError(t, err)
assert.Contains(t, results, "file1", "Should return the nested file")
}
func TestListObjectsV2_Regression_Sorting(t *testing.T) {
// Verify that listing logic correctly finds the target directory even when
// other entries with a similar prefix are returned first by the filer,
// a scenario where the removed Limit=1 optimization would fail.
s3a := &S3ApiServer{}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/reports": {
{Name: "reports-archive", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
{Name: "reports", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
},
"/buckets/reports/reports": {
{Name: "01771152617961894200", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
},
"/buckets/reports/reports/01771152617961894200": {
{Name: "file1", IsDirectory: false, Attributes: &filer_pb.FuseAttributes{}},
},
},
}
// This cursor setup mimics what happens in listFilerEntries
cursor := &ListingCursor{maxKeys: 1000, prefixEndsOnDelimiter: true}
var results []string
// Without the fix, Limit=1 would cause the lister to stop after "reports-archive",
// missing the intended "reports" directory.
_, err := s3a.doListFilerEntries(client, "/buckets/reports", "reports", cursor, "", "", false, "reports", func(dir string, entry *filer_pb.Entry) {
if !entry.IsDirectory {
results = append(results, entry.Name)
}
})
assert.NoError(t, err)
// With Limit=1, this fails because it only sees "reports-archive"
// With fix, it sees both and processes "reports"
assert.Contains(t, results, "file1", "Should return the nested file even if 'reports' directory is not the first match")
}