From 592e91b10cda10c808b873fec423206b6845589d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 2 Oct 2026 16:41:50 +0200 Subject: [PATCH 1/2] fix: compute IsTruncated from the absolute index in ListMultipartUploads With an upload-id-marker the lister ranges over Uploads[startIndex:], but passed the index of that subslice to hasMoreAfter, which indexes the full list. A page that ended on the last remaining upload was still reported as truncated, with next markers pointing at that upload, so a client had to make one more request to get an empty page. Iterate over the full list from startIndex, so hasMoreAfter gets the real position of the next upload. --- backend/mp-lister.go | 3 +- tests/integration/ListMultipartUploads.go | 54 +++++++++++++++++++++++ tests/integration/group-tests.go | 2 + 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/backend/mp-lister.go b/backend/mp-lister.go index b4c444c4..b372ea23 100644 --- a/backend/mp-lister.go +++ b/backend/mp-lister.go @@ -113,7 +113,8 @@ func (l *MultipartUploadLister) Run() (*ListMultipartUploadsPage, error) { return emitted == l.MaxUploads } - for i, up := range l.Uploads[startIndex:] { + for i := startIndex; i < len(l.Uploads); i++ { + up := l.Uploads[i] if l.Delimiter != "" { // delimiter check suffix := strings.TrimPrefix(up.Key, l.Prefix) diff --git a/tests/integration/ListMultipartUploads.go b/tests/integration/ListMultipartUploads.go index 91605f3f..45d63b95 100644 --- a/tests/integration/ListMultipartUploads.go +++ b/tests/integration/ListMultipartUploads.go @@ -537,6 +537,60 @@ func ListMultipartUploads_delimiter_truncated(s *S3Conf) error { }) } +func ListMultipartUploads_upload_id_marker_truncated(s *S3Conf) error { + testName := "ListMultipartUploads_upload_id_marker_truncated" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + uploads := make([]types.MultipartUpload, 0, 3) + for _, key := range []string{"bar", "baz", "foo"} { + out, err := createMp(s3client, bucket, key) + if err != nil { + return err + } + uploads = append(uploads, types.MultipartUpload{ + Key: out.Key, + UploadId: out.UploadId, + StorageClass: types.StorageClassStandard, + }) + } + + // the listing starts after the first upload, so a page of 1 + // leaves one upload behind and a page of 2 reaches the end + for _, test := range []struct { + maxUploads int32 + expected []types.MultipartUpload + truncated bool + }{ + {1, uploads[1:2], true}, + {2, uploads[1:], false}, + } { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := s3client.ListMultipartUploads(ctx, &s3.ListMultipartUploadsInput{ + Bucket: &bucket, + KeyMarker: getPtr("ba"), + UploadIdMarker: uploads[0].UploadId, + MaxUploads: &test.maxUploads, + }) + cancel() + if err != nil { + return err + } + + if !compareMultipartUploads(test.expected, out.Uploads) { + return fmt.Errorf("expected the mp list to be %v, instead got %v", test.expected, out.Uploads) + } + if out.IsTruncated == nil { + return fmt.Errorf("unexpected nil is-truncated") + } + if *out.IsTruncated != test.truncated { + return fmt.Errorf("expected is-truncated to be %v with max-uploads %v, instead got %v", + test.truncated, test.maxUploads, *out.IsTruncated) + } + } + + return nil + }) +} + func ListMultipartUploads_prefix(s *S3Conf) error { testName := "ListMultipartUploads_prefix" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index fb996805..b4503121 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -553,6 +553,7 @@ func TestListMultipartUploads(ts *TestState) { ts.Run(ListMultipartUploads_keyMarker_not_from_list) ts.Run(ListMultipartUploads_upload_id_marker_with_key_marker) ts.Run(ListMultipartUploads_delimiter_truncated) + ts.Run(ListMultipartUploads_upload_id_marker_truncated) ts.Run(ListMultipartUploads_prefix) ts.Run(ListMultipartUploads_both_delimiter_and_prefix) ts.Run(ListMultipartUploads_delimiter_no_matches) @@ -3293,6 +3294,7 @@ func GetIntTests() IntTests { "ListMultipartUploads_keyMarker_not_from_list": ListMultipartUploads_keyMarker_not_from_list, "ListMultipartUploads_upload_id_marker_with_key_marker": ListMultipartUploads_upload_id_marker_with_key_marker, "ListMultipartUploads_delimiter_truncated": ListMultipartUploads_delimiter_truncated, + "ListMultipartUploads_upload_id_marker_truncated": ListMultipartUploads_upload_id_marker_truncated, "ListMultipartUploads_prefix": ListMultipartUploads_prefix, "ListMultipartUploads_both_delimiter_and_prefix": ListMultipartUploads_both_delimiter_and_prefix, "ListMultipartUploads_with_upload_id": ListMultipartUploads_with_upload_id, From 69fb147bcb9148a747278e59a02442d4e06035ae Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 5 Oct 2026 10:03:26 +0200 Subject: [PATCH 2/2] test: use the upload's own key as key-marker in the truncation test An upload-id-marker has to belong to the key-marker itself, S3 answers key-marker=ba with an upload of bar with 400 Invalid uploadId marker. --- tests/integration/ListMultipartUploads.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/ListMultipartUploads.go b/tests/integration/ListMultipartUploads.go index 45d63b95..18aa1592 100644 --- a/tests/integration/ListMultipartUploads.go +++ b/tests/integration/ListMultipartUploads.go @@ -566,7 +566,7 @@ func ListMultipartUploads_upload_id_marker_truncated(s *S3Conf) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) out, err := s3client.ListMultipartUploads(ctx, &s3.ListMultipartUploadsInput{ Bucket: &bucket, - KeyMarker: getPtr("ba"), + KeyMarker: uploads[0].Key, UploadIdMarker: uploads[0].UploadId, MaxUploads: &test.maxUploads, })