diff --git a/weed/s3api/s3api_object_handlers_list.go b/weed/s3api/s3api_object_handlers_list.go index 731972f75..2dfc9aec7 100644 --- a/weed/s3api/s3api_object_handlers_list.go +++ b/weed/s3api/s3api_object_handlers_list.go @@ -351,25 +351,8 @@ func (s3a *S3ApiServer) listFilerEntries(bucket string, originalPrefix string, m } // Adjust nextMarker for CommonPrefixes to include trailing slash (AWS S3 compliance) - if cursor.isTruncated && lastEntryWasCommonPrefix && lastCommonPrefixName != "" { - // For CommonPrefixes, NextMarker should include the trailing slash - if requestDir != "" { - if prefix != "" { - nextMarker = requestDir + "/" + prefix + "/" + lastCommonPrefixName + "/" - } else { - nextMarker = requestDir + "/" + lastCommonPrefixName + "/" - } - } else { - nextMarker = lastCommonPrefixName + "/" - } - } else if cursor.isTruncated { - if requestDir != "" { - if prefix != "" { - nextMarker = requestDir + "/" + prefix + "/" + nextMarker - } else { - nextMarker = requestDir + "/" + nextMarker - } - } + if cursor.isTruncated { + nextMarker = buildTruncatedNextMarker(requestDir, prefix, nextMarker, lastEntryWasCommonPrefix, lastCommonPrefixName) } if cursor.isTruncated { @@ -480,6 +463,28 @@ func toParentAndDescendants(dirAndName string) (dir, name string) { return } +func buildTruncatedNextMarker(requestDir, prefix, nextMarker string, lastEntryWasCommonPrefix bool, lastCommonPrefixName string) string { + if lastEntryWasCommonPrefix && lastCommonPrefixName != "" { + // For CommonPrefixes, NextMarker should include the trailing slash + if requestDir != "" { + if prefix != "" { + return requestDir + "/" + prefix + "/" + lastCommonPrefixName + "/" + } + return requestDir + "/" + lastCommonPrefixName + "/" + } + if prefix != "" { + return prefix + "/" + lastCommonPrefixName + "/" + } + return lastCommonPrefixName + "/" + } + + if requestDir != "" { + return requestDir + "/" + nextMarker + } + + return nextMarker +} + func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, dir, prefix string, cursor *ListingCursor, marker, delimiter string, inclusiveStartFrom bool, bucket string, eachEntryFn func(dir string, entry *filer_pb.Entry)) (nextMarker string, err error) { // invariants // prefix and marker should be under dir, marker may contain "/" diff --git a/weed/s3api/s3api_object_handlers_list_test.go b/weed/s3api/s3api_object_handlers_list_test.go index cc08d9173..b8e1bb926 100644 --- a/weed/s3api/s3api_object_handlers_list_test.go +++ b/weed/s3api/s3api_object_handlers_list_test.go @@ -148,6 +148,26 @@ func Test_normalizePrefixMarker(t *testing.T) { } } +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) {