// Copyright (c) 2015-2026 MinIO, Inc. // // This file is part of MinIO Object Storage stack // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package cmd import ( "bytes" "encoding/xml" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "github.com/minio/minio/internal/auth" xhttp "github.com/minio/minio/internal/http" ) type attributesPartsPage struct { ObjectParts struct { IsTruncated bool MaxParts int NextPartNumberMarker int PartNumberMarker int PartsCount int Parts []struct { PartNumber int Size int64 } `xml:"Part"` } } // TestAPIGetObjectAttributesPartsPagination asserts that ObjectParts pagination // terminates for sparse part numbers: truncation is decided by whether parts // remain, not by comparing the last part number with the part count. func TestAPIGetObjectAttributesPartsPagination(t *testing.T) { defer DetectTestLeak(t)() ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ t: t, objAPITest: testAPIGetObjectAttributesPartsPagination, }) } func testAPIGetObjectAttributesPartsPagination(_ ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, credentials auth.Credentials, t *testing.T, ) { signedRequest := func(method, target string, body []byte, headers map[string]string) *httptest.ResponseRecorder { t.Helper() req, err := newTestSignedRequestV4(method, target, int64(len(body)), bytes.NewReader(body), credentials.AccessKey, credentials.SecretKey, headers) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() apiRouter.ServeHTTP(rec, req) return rec } const partSize = 5 * 1024 * 1024 // upload completes a multipart object with the given part numbers. Part // numbers must be strictly increasing, but gaps are allowed, so the last // part number need not equal the number of parts. upload := func(object string, partNumbers []int) { t.Helper() initRec := signedRequest(http.MethodPost, getNewMultipartURL("", bucketName, object), nil, nil) if initRec.Code != http.StatusOK { t.Fatalf("%s INIT: %d %s", instanceType, initRec.Code, initRec.Body.String()) } var initiated struct { UploadID string `xml:"UploadId"` } if err := xml.Unmarshal(initRec.Body.Bytes(), &initiated); err != nil { t.Fatal(err) } var complete bytes.Buffer complete.WriteString("") for i, number := range partNumbers { body := bytes.Repeat([]byte("abcd"), partSize/4) if i == len(partNumbers)-1 { body = bytes.Repeat([]byte("12345"), 103) } put := signedRequest(http.MethodPut, getPutObjectPartURL("", bucketName, object, initiated.UploadID, strconv.Itoa(number)), body, nil) if put.Code != http.StatusOK { t.Fatalf("%s PART %d: %d %s", instanceType, number, put.Code, put.Body.String()) } fmt.Fprintf(&complete, "%d%s", number, put.Header()[xhttp.ETag][0]) } complete.WriteString("") finish := signedRequest(http.MethodPost, getCompleteMultipartUploadURL("", bucketName, object, initiated.UploadID), complete.Bytes(), nil) if finish.Code != http.StatusOK { t.Fatalf("%s COMPLETE: %d %s", instanceType, finish.Code, finish.Body.String()) } } attributes := func(object string, marker, maxParts int) attributesPartsPage { t.Helper() headers := map[string]string{xhttp.AmzObjectAttributes: "ObjectParts"} if marker > 0 { headers[xhttp.AmzPartNumberMarker] = strconv.Itoa(marker) } if maxParts > 0 { headers[xhttp.AmzMaxParts] = strconv.Itoa(maxParts) } rec := signedRequest(http.MethodGet, getGetObjectURL("", bucketName, object)+"?attributes", nil, headers) if rec.Code != http.StatusOK { t.Fatalf("%s ATTRIBUTES marker=%d max=%d: %d %s", instanceType, marker, maxParts, rec.Code, rec.Body.String()) } var page attributesPartsPage if err := xml.Unmarshal(rec.Body.Bytes(), &page); err != nil { t.Fatal(err) } return page } check := func(name string, page attributesPartsPage, wantParts []int, wantTruncated bool, wantNext, wantCount int) { t.Helper() got := make([]int, 0, len(page.ObjectParts.Parts)) for _, p := range page.ObjectParts.Parts { got = append(got, p.PartNumber) } if fmt.Sprint(got) != fmt.Sprint(wantParts) { t.Errorf("%s %s: parts=%v want=%v", instanceType, name, got, wantParts) } if page.ObjectParts.IsTruncated != wantTruncated { t.Errorf("%s %s: IsTruncated=%v want=%v", instanceType, name, page.ObjectParts.IsTruncated, wantTruncated) } if page.ObjectParts.NextPartNumberMarker != wantNext { t.Errorf("%s %s: NextPartNumberMarker=%d want=%d", instanceType, name, page.ObjectParts.NextPartNumberMarker, wantNext) } if page.ObjectParts.PartsCount != wantCount { t.Errorf("%s %s: PartsCount=%d want=%d", instanceType, name, page.ObjectParts.PartsCount, wantCount) } } sparse := "review/attributes-pagination-sparse" upload(sparse, []int{1, 3, 5}) // A single page holding every sparse part is complete. check("sparse full page", attributes(sparse, 0, 0), []int{1, 3, 5}, false, 0, 3) // One part per page walks 1, 3, 5 and stops. Page 2 returns part 3, // whose number equals the part count, and must still be truncated: // the old comparison declared that page final and silently dropped // part 5. check("sparse max-parts=1 page 1", attributes(sparse, 0, 1), []int{1}, true, 1, 3) check("sparse max-parts=1 page 2", attributes(sparse, 1, 1), []int{3}, true, 3, 3) check("sparse max-parts=1 page 3", attributes(sparse, 3, 1), []int{5}, false, 0, 3) // A page that exactly holds the remainder is not truncated, so no // empty trailing page is requested. check("sparse max-parts=2 page 2", attributes(sparse, 1, 2), []int{3, 5}, false, 0, 3) // A marker at or past the last part ends the listing instead of // looping back through NextPartNumberMarker=0. check("sparse marker at last part", attributes(sparse, 5, 0), nil, false, 0, 3) check("sparse marker past last part", attributes(sparse, 9, 0), nil, false, 0, 3) // Contiguous numbering is affected too: the empty page past the end // used to report IsTruncated=true with NextPartNumberMarker=0. contiguous := "review/attributes-pagination-contiguous" upload(contiguous, []int{1, 2}) check("contiguous full page", attributes(contiguous, 0, 0), []int{1, 2}, false, 0, 2) check("contiguous max-parts=1 page 1", attributes(contiguous, 0, 1), []int{1}, true, 1, 2) check("contiguous max-parts=1 page 2", attributes(contiguous, 1, 1), []int{2}, false, 0, 2) check("contiguous marker at last part", attributes(contiguous, 2, 0), nil, false, 0, 2) }