From d6fb9547b8390696274e680346e89669fe0f6c25 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Tue, 21 Apr 2026 02:00:00 +0400 Subject: [PATCH] fix: correct 206 Partial Content response status for ranged GetObject and HeadObject Fixes #2052 Fixes #2056 Fixes #2057 Previously, GetObject and HeadObject used the request's `Range` header to determine the response status code, which caused incorrect 206 responses for invalid Range header values. The status is now driven by whether res.ContentRange is set in the response, rather than by the presence of a range in the request. Backends (posix and azure) now set Content-Range for PartNumber=1 on non-multipart objects, skipping zero-size objects where no range applies. HeadObject was also fixed to return 206 when Content-Range is present, and to only return checksums when the full object is requested. --- backend/azure/azure.go | 32 ++- backend/posix/posix.go | 14 +- s3api/controllers/object-get.go | 2 +- s3api/controllers/object-get_test.go | 46 +++- s3api/controllers/object-head.go | 7 + s3api/controllers/object-head_test.go | 46 ++++ tests/integration/GetObject.go | 346 ++++++++++++++++++++++++-- tests/integration/HeadObject.go | 303 +++++++++++++++++++++- tests/integration/group-tests.go | 16 ++ 9 files changed, 772 insertions(+), 40 deletions(-) diff --git a/backend/azure/azure.go b/backend/azure/azure.go index 847b03e5..d5f57ce1 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -491,6 +491,11 @@ func (az *Azure) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.G } } + var objSize int64 + if resp.ContentLength != nil { + objSize = *resp.ContentLength + } + var opts *azblob.DownloadStreamOptions var partsCount *int32 var contentRange *string @@ -530,14 +535,15 @@ func (az *Azure) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.G } } else if *input.PartNumber > 1 { return nil, s3err.GetAPIError(s3err.ErrInvalidPartNumberRange) + } else { + // partNumber=1 on a non-multipart object: fall through and serve the + // full object without a range (opts remains nil) + if objSize != 0 { + // if object size is 0, the whole object is served, no content range should be set + contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes 0-%d/%d", objSize-1, objSize)) + } } - // partNumber=1 on a non-multipart object: fall through and serve the - // full object without a range (opts remains nil, contentRange stays nil). } else if *input.Range != "" { - var objSize int64 - if resp.ContentLength != nil { - objSize = *resp.ContentLength - } offset, count, isValid, err := backend.ParseObjectRange(objSize, *input.Range) if err != nil { return nil, err @@ -612,7 +618,7 @@ func (az *Azure) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3 size = *resp.ContentLength } - var contentRange string + var contentRange *string var length int64 var partsCount *int32 @@ -638,13 +644,17 @@ func (az *Azure) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3 startOffset = mpMeta.Parts[partNum-2] } length = mpMeta.Parts[partNum-1] - startOffset - contentRange = fmt.Sprintf("bytes %d-%d/%d", startOffset, startOffset+length-1, size) + contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes %d-%d/%d", startOffset, startOffset+length-1, size)) } else if *input.PartNumber > 1 { return nil, s3err.GetAPIError(s3err.ErrInvalidPartNumberRange) } else { // partNumber=1 on a non-multipart object: return full object size, // no Content-Range, no PartsCount. length = size + if length != 0 { + // if object size is 0, the whole object is served, no content range should be set + contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes 0-%d/%d", size-1, size)) + } } } else { startOffset, lgth, isValid, err := backend.ParseObjectRange(size, getString(input.Range)) @@ -653,13 +663,13 @@ func (az *Azure) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3 } length = lgth if isValid { - contentRange = fmt.Sprintf("bytes %v-%v/%v", - startOffset, startOffset+length-1, size) + contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes %v-%v/%v", + startOffset, startOffset+length-1, size)) } } result := &s3.HeadObjectOutput{ - ContentRange: &contentRange, + ContentRange: contentRange, AcceptRanges: backend.GetPtrFromString("bytes"), ContentLength: &length, PartsCount: partsCount, diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 397babb1..d2c8a8ab 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -4446,6 +4446,10 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge return nil, s3err.GetAPIError(s3err.ErrInvalidPartNumberRange) } length = objSize + if objSize != 0 { + // if object size is 0, the whole object is served, no content range should be set + contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes 0-%d/%d", objSize-1, objSize)) + } } else { return nil, fmt.Errorf("retrieve mp metadata: %w", metaErr) } @@ -4474,7 +4478,7 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge } var checksums s3response.Checksum - // Skip the checksums retreival if object isn't requested fully + // Return checksums only when the full object is requested if input.ChecksumMode == types.ChecksumModeEnabled && length-startOffset == objSize { checksums, err = p.retrieveChecksums(f, bucket, object) if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { @@ -4681,6 +4685,10 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3. return nil, s3err.GetAPIError(s3err.ErrInvalidPartNumberRange) } length = size + if length != 0 { + // if object size is 0, the whole object is served, no content range should be set + contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes 0-%d/%d", length-1, length)) + } } else { return nil, fmt.Errorf("retrieve mp metadata: %w", metaErr) } @@ -4690,7 +4698,6 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3. return nil, err } startOffset, length = start, lgth - if isValid { contentRange = backend.GetPtrFromString(fmt.Sprintf("bytes %v-%v/%v", start, start+lgth-1, size)) } @@ -4718,7 +4725,8 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3. } var checksums s3response.Checksum - if input.ChecksumMode == types.ChecksumModeEnabled { + // Return checksums only when the full object is requested + if input.ChecksumMode == types.ChecksumModeEnabled && length-startOffset == size { checksums, err = p.retrieveChecksums(nil, bucket, object) if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { return nil, fmt.Errorf("get object checksums: %w", err) diff --git a/s3api/controllers/object-get.go b/s3api/controllers/object-get.go index 5aaea3f1..bae2e96e 100644 --- a/s3api/controllers/object-get.go +++ b/s3api/controllers/object-get.go @@ -516,7 +516,7 @@ func (c S3ApiController) GetObject(ctx *fiber.Ctx) (*Response, error) { utils.SetMetaHeaders(ctx, res.Metadata) status := http.StatusOK - if acceptRange != "" { + if res.ContentRange != nil && *res.ContentRange != "" { status = http.StatusPartialContent } diff --git a/s3api/controllers/object-get_test.go b/s3api/controllers/object-get_test.go index 06950f0e..6eedcad6 100644 --- a/s3api/controllers/object-get_test.go +++ b/s3api/controllers/object-get_test.go @@ -813,12 +813,56 @@ func TestS3ApiController_GetObject(t *testing.T) { }, MetaOpts: &MetaOptions{ BucketOwner: "root", - Status: http.StatusPartialContent, + Status: http.StatusOK, ContentLength: cLength, }, }, }, }, + { + name: "successful partial response", + input: testInput{ + locals: defaultLocals, + beRes: &s3.GetObjectOutput{ + ContentRange: utils.GetStringPtr("bytes 2-4/15"), + }, + }, + output: testOutput{ + response: &Response{ + Headers: map[string]*string{ + "ETag": nil, + "x-amz-restore": nil, + "accept-ranges": nil, + "Content-Range": utils.GetStringPtr("bytes 2-4/15"), + "Content-Disposition": nil, + "Content-Encoding": nil, + "Content-Language": nil, + "Cache-Control": nil, + "Expires": nil, + "x-amz-checksum-crc32": nil, + "x-amz-checksum-crc64nvme": nil, + "x-amz-checksum-crc32c": nil, + "x-amz-checksum-sha1": nil, + "x-amz-checksum-sha256": nil, + "x-amz-version-id": nil, + "x-amz-mp-parts-count": nil, + "x-amz-object-lock-mode": nil, + "x-amz-object-lock-legal-hold": nil, + "x-amz-storage-class": nil, + "x-amz-checksum-type": nil, + "x-amz-object-lock-retain-until-date": nil, + "Last-Modified": nil, + "x-amz-tagging-count": nil, + "Content-Type": nil, + "Content-Length": nil, + }, + MetaOpts: &MetaOptions{ + BucketOwner: "root", + Status: http.StatusPartialContent, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/s3api/controllers/object-head.go b/s3api/controllers/object-head.go index e4c63951..755cc1cf 100644 --- a/s3api/controllers/object-head.go +++ b/s3api/controllers/object-head.go @@ -16,6 +16,7 @@ package controllers import ( "fmt" + "net/http" "strings" "time" @@ -163,6 +164,11 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) { // Set the metadata headers utils.SetMetaHeaders(ctx, res.Metadata) + status := http.StatusOK + if res.ContentRange != nil && *res.ContentRange != "" { + status = http.StatusPartialContent + } + return &Response{ Headers: map[string]*string{ "Content-Range": res.ContentRange, @@ -193,6 +199,7 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) { }, MetaOpts: &MetaOptions{ BucketOwner: parsedAcl.Owner, + Status: status, }, }, nil } diff --git a/s3api/controllers/object-head_test.go b/s3api/controllers/object-head_test.go index f62859d1..7cc5d097 100644 --- a/s3api/controllers/object-head_test.go +++ b/s3api/controllers/object-head_test.go @@ -16,6 +16,7 @@ package controllers import ( "context" + "net/http" "testing" "time" @@ -202,6 +203,51 @@ func TestS3ApiController_HeadObject(t *testing.T) { }, MetaOpts: &MetaOptions{ BucketOwner: "root", + Status: http.StatusOK, + }, + }, + }, + }, + { + name: "successful partial response", + input: testInput{ + locals: defaultLocals, + beRes: &s3.HeadObjectOutput{ + ContentRange: utils.GetStringPtr("bytes 10-20/100"), + }, + }, + output: testOutput{ + response: &Response{ + Headers: map[string]*string{ + "ETag": nil, + "x-amz-restore": nil, + "accept-ranges": nil, + "Content-Range": utils.GetStringPtr("bytes 10-20/100"), + "Content-Disposition": nil, + "Content-Encoding": nil, + "Content-Language": nil, + "Cache-Control": nil, + "Expires": nil, + "x-amz-checksum-crc32": nil, + "x-amz-checksum-crc64nvme": nil, + "x-amz-checksum-crc32c": nil, + "x-amz-checksum-sha1": nil, + "x-amz-checksum-sha256": nil, + "x-amz-version-id": nil, + "x-amz-mp-parts-count": nil, + "x-amz-object-lock-mode": nil, + "x-amz-object-lock-legal-hold": nil, + "x-amz-storage-class": nil, + "x-amz-checksum-type": nil, + "x-amz-object-lock-retain-until-date": nil, + "Last-Modified": nil, + "x-amz-tagging-count": nil, + "Content-Type": nil, + "Content-Length": nil, + }, + MetaOpts: &MetaOptions{ + BucketOwner: "root", + Status: http.StatusPartialContent, }, }, }, diff --git a/tests/integration/GetObject.go b/tests/integration/GetObject.go index 1a5be2ca..71b3cd6f 100644 --- a/tests/integration/GetObject.go +++ b/tests/integration/GetObject.go @@ -25,6 +25,7 @@ import ( "strings" "time" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/versity/versitygw/s3err" @@ -775,8 +776,9 @@ func GetObject_directory_success(s *S3Conf) error { func GetObject_by_range_resp_status(s *S3Conf) error { testName := "GetObject_by_range_resp_status" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - obj, dLen := "my-obj", int64(4000) - _, err := putObjectWithData(dLen, &s3.PutObjectInput{ + obj := "my-obj" + objLength := int64(100) + _, err := putObjectWithData(objLength, &s3.PutObjectInput{ Bucket: &bucket, Key: &obj, }, s3client) @@ -784,32 +786,119 @@ func GetObject_by_range_resp_status(s *S3Conf) error { return err } - req, err := createSignedReq( - http.MethodGet, - s.endpoint, - fmt.Sprintf("%v/%v", bucket, obj), - s.awsID, - s.awsSecret, - "s3", - s.awsRegion, - nil, - time.Now(), - map[string]string{ - "Range": "bytes=100-200", - }, - ) + checkRangeStatus := func(rng string, expectedStatus int) error { + req, err := createSignedReq( + http.MethodGet, + s.endpoint, + fmt.Sprintf("%v/%v", bucket, obj), + s.awsID, + s.awsSecret, + "s3", + s.awsRegion, + nil, + time.Now(), + map[string]string{"Range": rng}, + ) + if err != nil { + return err + } + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + if resp.StatusCode != expectedStatus { + return fmt.Errorf("range %q: expected status %d, instead got %d", rng, expectedStatus, resp.StatusCode) + } + return nil + } + + for _, tc := range []struct { + rng string + expectedStatus int + }{ + // Invalid/ignored ranges → full object, no Content-Range → 200 + {"bytes=,", http.StatusOK}, + {"bytes= -1", http.StatusOK}, + {"bytes=--1", http.StatusOK}, + {"bytes=0 -1", http.StatusOK}, + {"bytes=0--1", http.StatusOK}, + {"bytes=10-5", http.StatusOK}, + {"bytes=abc", http.StatusOK}, + {"bytes=a-z", http.StatusOK}, + {"foo=0-1", http.StatusOK}, + {"bytes=abc-xyz", http.StatusOK}, + {"bytes=100-x", http.StatusOK}, + {"bytes=0-0,1-2", http.StatusOK}, + {fmt.Sprintf("bytes=%v-%v", objLength+2, objLength-100), http.StatusOK}, + + // Valid ranges → partial content, non-empty Content-Range → 206 + {"bytes=00-01", http.StatusPartialContent}, + {"bytes=-1", http.StatusPartialContent}, + {"bytes=-2", http.StatusPartialContent}, + {"bytes=-10", http.StatusPartialContent}, + {"bytes=-100", http.StatusPartialContent}, + {"bytes=-101", http.StatusPartialContent}, + {"bytes=0-0", http.StatusPartialContent}, + {"bytes=0-99", http.StatusPartialContent}, + {"bytes=0-100", http.StatusPartialContent}, + {"bytes=0-999999", http.StatusPartialContent}, + {"bytes=1-99", http.StatusPartialContent}, + {"bytes=50-99", http.StatusPartialContent}, + {"bytes=50-", http.StatusPartialContent}, + {"bytes=0-", http.StatusPartialContent}, + {"bytes=99-99", http.StatusPartialContent}, + } { + if err := checkRangeStatus(tc.rng, tc.expectedStatus); err != nil { + return err + } + } + + return nil + }) +} + +func GetObject_not_enabled_checksum_mode(s *S3Conf) error { + testName := "GetObject_not_enabled_checksum_mode" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-obj" + + _, err := putObjectWithData(500, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &obj, + ChecksumAlgorithm: types.ChecksumAlgorithmSha1, + }, s3client) if err != nil { return err } - resp, err := s.httpClient.Do(req) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, + Key: &obj, + }, func(o *s3.Options) { + // config sdk to not automatically set the `x-amz-checksum-mode: ENABLED` header + o.RequestChecksumCalculation = aws.RequestChecksumCalculationUnset + o.ResponseChecksumValidation = aws.ResponseChecksumValidationUnset + }) + cancel() if err != nil { return err } - if resp.StatusCode != http.StatusPartialContent { - return fmt.Errorf("expected response status to be %v, instead got %v", - http.StatusPartialContent, resp.StatusCode) + if res.ChecksumCRC32 != nil { + return fmt.Errorf("expected nil crc32 checksum, instead got %v", *res.ChecksumCRC32) + } + if res.ChecksumCRC32C != nil { + return fmt.Errorf("expected nil crc32c checksum, instead got %v", *res.ChecksumCRC32C) + } + if res.ChecksumSHA1 != nil { + return fmt.Errorf("expected nil sha1 checksum, instead got %v", *res.ChecksumSHA1) + } + if res.ChecksumSHA256 != nil { + return fmt.Errorf("expected nil sha256 checksum, instead got %v", *res.ChecksumSHA256) + } + if res.ChecksumCRC64NVME != nil { + return fmt.Errorf("expected nil crc64nvme checksum, instead got %v", *res.ChecksumCRC64NVME) } return nil @@ -1474,9 +1563,10 @@ func GetObject_non_mp_part_number_1_success(s *S3Conf) error { cancel() return fmt.Errorf("expected ContentLength %d, got %v", objSize, res.ContentLength) } - if getString(res.ContentRange) != "" { + expectedCRange := fmt.Sprintf("bytes 0-%d/%d", objSize-1, objSize) + if getString(res.ContentRange) != expectedCRange { cancel() - return fmt.Errorf("expected empty Content-Range for non-multipart object, got %q", getString(res.ContentRange)) + return fmt.Errorf("expected Content-Range to be %s, instead got %s", expectedCRange, getString(res.ContentRange)) } if res.PartsCount != nil { cancel() @@ -1500,3 +1590,215 @@ func GetObject_non_mp_part_number_1_success(s *S3Conf) error { return nil }) } + +func GetObject_empty_object_part_number_1(s *S3Conf) error { + testName := "GetObject_empty_object_part_number_1" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "empty-obj" + + out, err := putObjectWithData(0, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &obj, + ChecksumAlgorithm: types.ChecksumAlgorithmSha256, + }, s3client) + if err != nil { + return err + } + + pn := int32(1) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, + Key: &obj, + PartNumber: &pn, + ChecksumMode: types.ChecksumModeEnabled, + }) + cancel() + if err != nil { + return err + } + + if res.ContentRange != nil { + return fmt.Errorf("expected nil Content-Range for empty object with partNumber=1, got %q", *res.ContentRange) + } + if res.ContentLength == nil || *res.ContentLength != 0 { + return fmt.Errorf("expected ContentLength 0, got %v", res.ContentLength) + } + if getString(res.ChecksumSHA256) != getString(out.res.ChecksumSHA256) { + return fmt.Errorf("expected sha256 checksum %v, got %v", + getString(out.res.ChecksumSHA256), getString(res.ChecksumSHA256)) + } + + return nil + }) +} + +func GetObject_mp_part_number_resp_status(s *S3Conf) error { + testName := "GetObject_mp_part_number_resp_status" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-obj" + out, err := createMp(s3client, bucket, obj) + if err != nil { + return err + } + + const partCount = int64(2) + parts, _, err := uploadParts(s3client, partCount*5*1024*1024, partCount, bucket, obj, *out.UploadId) + if err != nil { + return err + } + + compParts := make([]types.CompletedPart, len(parts)) + for i, p := range parts { + compParts[i] = types.CompletedPart{ + ETag: p.ETag, + PartNumber: p.PartNumber, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + Bucket: &bucket, + Key: &obj, + UploadId: out.UploadId, + MultipartUpload: &types.CompletedMultipartUpload{ + Parts: compParts, + }, + }) + cancel() + if err != nil { + return err + } + + req, err := createSignedReq( + http.MethodGet, + s.endpoint, + fmt.Sprintf("%v/%v?partNumber=1", bucket, obj), + s.awsID, + s.awsSecret, + "s3", + s.awsRegion, + nil, + time.Now(), + nil, + ) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("expected response status to be %v, instead got %v", + http.StatusPartialContent, resp.StatusCode) + } + + return nil + }) +} + +func GetObject_ranged_with_checksum_mode(s *S3Conf) error { + testName := "GetObject_ranged_with_checksum_mode" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + checkNoChecksums := func(res *s3.GetObjectOutput) error { + if res.ChecksumCRC32 != nil { + return fmt.Errorf("expected nil crc32 checksum, instead got %v", *res.ChecksumCRC32) + } + if res.ChecksumCRC32C != nil { + return fmt.Errorf("expected nil crc32c checksum, instead got %v", *res.ChecksumCRC32C) + } + if res.ChecksumSHA1 != nil { + return fmt.Errorf("expected nil sha1 checksum, instead got %v", *res.ChecksumSHA1) + } + if res.ChecksumSHA256 != nil { + return fmt.Errorf("expected nil sha256 checksum, instead got %v", *res.ChecksumSHA256) + } + if res.ChecksumCRC64NVME != nil { + return fmt.Errorf("expected nil crc64nvme checksum, instead got %v", *res.ChecksumCRC64NVME) + } + return nil + } + + // Sub-test 1: regular object with Range header and ChecksumMode enabled + regularObj := "regular-obj" + _, err := putObjectWithData(500, &s3.PutObjectInput{ + Bucket: &bucket, + Key: ®ularObj, + ChecksumAlgorithm: types.ChecksumAlgorithmSha256, + }, s3client) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, + Key: ®ularObj, + Range: getPtr("bytes=0-99"), + ChecksumMode: types.ChecksumModeEnabled, + }) + cancel() + if err != nil { + return fmt.Errorf("ranged GET on regular object: %w", err) + } + if err := checkNoChecksums(res); err != nil { + return fmt.Errorf("ranged GET on regular object: %w", err) + } + + // Sub-test 2: multipart object with partNumber and ChecksumMode enabled + mpObj := "mp-obj" + mpOut, err := createMp(s3client, bucket, mpObj) + if err != nil { + return err + } + + const partCount = int64(2) + parts, _, err := uploadParts(s3client, partCount*5*1024*1024, partCount, bucket, mpObj, *mpOut.UploadId) + if err != nil { + return err + } + + compParts := make([]types.CompletedPart, len(parts)) + for i, p := range parts { + compParts[i] = types.CompletedPart{ + ETag: p.ETag, + PartNumber: p.PartNumber, + } + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + Bucket: &bucket, + Key: &mpObj, + UploadId: mpOut.UploadId, + MultipartUpload: &types.CompletedMultipartUpload{ + Parts: compParts, + }, + }) + cancel() + if err != nil { + return err + } + + pn := int32(1) + ctx, cancel = context.WithTimeout(context.Background(), longTimeout) + mpRes, err := s3client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, + Key: &mpObj, + PartNumber: &pn, + ChecksumMode: types.ChecksumModeEnabled, + }) + cancel() + if err != nil { + return fmt.Errorf("partNumber GET on MP object: %w", err) + } + if err := checkNoChecksums(mpRes); err != nil { + return fmt.Errorf("partNumber GET on MP object: %w", err) + } + + return nil + }) +} diff --git a/tests/integration/HeadObject.go b/tests/integration/HeadObject.go index 5164288b..bbd6c997 100644 --- a/tests/integration/HeadObject.go +++ b/tests/integration/HeadObject.go @@ -1097,8 +1097,9 @@ func HeadObject_non_mp_part_number_1_success(s *S3Conf) error { if res.ContentLength == nil || *res.ContentLength != objSize { return fmt.Errorf("expected ContentLength %d, got %v", objSize, res.ContentLength) } - if getString(res.ContentRange) != "" { - return fmt.Errorf("expected empty Content-Range for non-multipart object, got %q", getString(res.ContentRange)) + expectedCRange := fmt.Sprintf("bytes 0-%d/%d", objSize-1, objSize) + if getString(res.ContentRange) != expectedCRange { + return fmt.Errorf("expected Content-Range to be %s, instead got %s", expectedCRange, getString(res.ContentRange)) } if res.PartsCount != nil { return fmt.Errorf("expected nil PartsCount for non-multipart object, got %d", *res.PartsCount) @@ -1111,6 +1112,304 @@ func HeadObject_non_mp_part_number_1_success(s *S3Conf) error { }) } +func HeadObject_by_range_resp_status(s *S3Conf) error { + testName := "HeadObject_by_range_resp_status" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-obj" + objLength := int64(100) + _, err := putObjectWithData(objLength, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &obj, + }, s3client) + if err != nil { + return err + } + + checkRangeStatus := func(rng string, expectedStatus int) error { + req, err := createSignedReq( + http.MethodHead, + s.endpoint, + fmt.Sprintf("%v/%v", bucket, obj), + s.awsID, + s.awsSecret, + "s3", + s.awsRegion, + nil, + time.Now(), + map[string]string{"Range": rng}, + ) + if err != nil { + return err + } + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != expectedStatus { + return fmt.Errorf("range %q: expected status %d, instead got %d", rng, expectedStatus, resp.StatusCode) + } + return nil + } + + for _, tc := range []struct { + rng string + expectedStatus int + }{ + // Invalid/ignored ranges → full object, no Content-Range → 200 + {"bytes=,", http.StatusOK}, + {"bytes= -1", http.StatusOK}, + {"bytes=--1", http.StatusOK}, + {"bytes=0 -1", http.StatusOK}, + {"bytes=0--1", http.StatusOK}, + {"bytes=10-5", http.StatusOK}, + {"bytes=abc", http.StatusOK}, + {"bytes=a-z", http.StatusOK}, + {"foo=0-1", http.StatusOK}, + {"bytes=abc-xyz", http.StatusOK}, + {"bytes=100-x", http.StatusOK}, + {"bytes=0-0,1-2", http.StatusOK}, + {fmt.Sprintf("bytes=%v-%v", objLength+2, objLength-100), http.StatusOK}, + + // Valid ranges → partial content, non-empty Content-Range → 206 + {"bytes=00-01", http.StatusPartialContent}, + {"bytes=-1", http.StatusPartialContent}, + {"bytes=-2", http.StatusPartialContent}, + {"bytes=-10", http.StatusPartialContent}, + {"bytes=-100", http.StatusPartialContent}, + {"bytes=-101", http.StatusPartialContent}, + {"bytes=0-0", http.StatusPartialContent}, + {"bytes=0-99", http.StatusPartialContent}, + {"bytes=0-100", http.StatusPartialContent}, + {"bytes=0-999999", http.StatusPartialContent}, + {"bytes=1-99", http.StatusPartialContent}, + {"bytes=50-99", http.StatusPartialContent}, + {"bytes=50-", http.StatusPartialContent}, + {"bytes=0-", http.StatusPartialContent}, + {"bytes=99-99", http.StatusPartialContent}, + } { + if err := checkRangeStatus(tc.rng, tc.expectedStatus); err != nil { + return err + } + } + + return nil + }) +} + +func HeadObject_empty_object_part_number_1(s *S3Conf) error { + testName := "HeadObject_empty_object_part_number_1" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "empty-obj" + + out, err := putObjectWithData(0, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &obj, + ChecksumAlgorithm: types.ChecksumAlgorithmSha256, + }, s3client) + if err != nil { + return err + } + + pn := int32(1) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &obj, + PartNumber: &pn, + ChecksumMode: types.ChecksumModeEnabled, + }) + cancel() + if err != nil { + return err + } + + if res.ContentRange != nil { + return fmt.Errorf("expected nil Content-Range for empty object with partNumber=1, got %q", *res.ContentRange) + } + if res.ContentLength == nil || *res.ContentLength != 0 { + return fmt.Errorf("expected ContentLength 0, got %v", res.ContentLength) + } + if getString(res.ChecksumSHA256) != getString(out.res.ChecksumSHA256) { + return fmt.Errorf("expected sha256 checksum %v, got %v", + getString(out.res.ChecksumSHA256), getString(res.ChecksumSHA256)) + } + + return nil + }) +} + +func HeadObject_mp_part_number_resp_status(s *S3Conf) error { + testName := "HeadObject_mp_part_number_resp_status" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-obj" + out, err := createMp(s3client, bucket, obj) + if err != nil { + return err + } + + const partCount = int64(2) + parts, _, err := uploadParts(s3client, partCount*5*1024*1024, partCount, bucket, obj, *out.UploadId) + if err != nil { + return err + } + + compParts := make([]types.CompletedPart, len(parts)) + for i, p := range parts { + compParts[i] = types.CompletedPart{ + ETag: p.ETag, + PartNumber: p.PartNumber, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + Bucket: &bucket, + Key: &obj, + UploadId: out.UploadId, + MultipartUpload: &types.CompletedMultipartUpload{ + Parts: compParts, + }, + }) + cancel() + if err != nil { + return err + } + + req, err := createSignedReq( + http.MethodHead, + s.endpoint, + fmt.Sprintf("%v/%v?partNumber=1", bucket, obj), + s.awsID, + s.awsSecret, + "s3", + s.awsRegion, + nil, + time.Now(), + nil, + ) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + + if resp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("expected response status to be %v, instead got %v", + http.StatusPartialContent, resp.StatusCode) + } + + return nil + }) +} + +func HeadObject_ranged_with_checksum_mode(s *S3Conf) error { + testName := "HeadObject_ranged_with_checksum_mode" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + checkNoChecksums := func(res *s3.HeadObjectOutput) error { + if res.ChecksumCRC32 != nil { + return fmt.Errorf("expected nil crc32 checksum, instead got %v", *res.ChecksumCRC32) + } + if res.ChecksumCRC32C != nil { + return fmt.Errorf("expected nil crc32c checksum, instead got %v", *res.ChecksumCRC32C) + } + if res.ChecksumSHA1 != nil { + return fmt.Errorf("expected nil sha1 checksum, instead got %v", *res.ChecksumSHA1) + } + if res.ChecksumSHA256 != nil { + return fmt.Errorf("expected nil sha256 checksum, instead got %v", *res.ChecksumSHA256) + } + if res.ChecksumCRC64NVME != nil { + return fmt.Errorf("expected nil crc64nvme checksum, instead got %v", *res.ChecksumCRC64NVME) + } + return nil + } + + // Sub-test 1: regular object with Range header and ChecksumMode enabled + regularObj := "regular-obj" + _, err := putObjectWithData(500, &s3.PutObjectInput{ + Bucket: &bucket, + Key: ®ularObj, + ChecksumAlgorithm: types.ChecksumAlgorithmSha256, + }, s3client) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: ®ularObj, + Range: getPtr("bytes=0-99"), + ChecksumMode: types.ChecksumModeEnabled, + }) + cancel() + if err != nil { + return fmt.Errorf("ranged HEAD on regular object: %w", err) + } + if err := checkNoChecksums(res); err != nil { + return fmt.Errorf("ranged HEAD on regular object: %w", err) + } + + // Sub-test 2: multipart object with partNumber and ChecksumMode enabled + mpObj := "mp-obj" + mpOut, err := createMp(s3client, bucket, mpObj) + if err != nil { + return err + } + + const partCount = int64(2) + parts, _, err := uploadParts(s3client, partCount*5*1024*1024, partCount, bucket, mpObj, *mpOut.UploadId) + if err != nil { + return err + } + + compParts := make([]types.CompletedPart, len(parts)) + for i, p := range parts { + compParts[i] = types.CompletedPart{ + ETag: p.ETag, + PartNumber: p.PartNumber, + } + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + Bucket: &bucket, + Key: &mpObj, + UploadId: mpOut.UploadId, + MultipartUpload: &types.CompletedMultipartUpload{ + Parts: compParts, + }, + }) + cancel() + if err != nil { + return err + } + + pn := int32(1) + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + mpRes, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &mpObj, + PartNumber: &pn, + ChecksumMode: types.ChecksumModeEnabled, + }) + cancel() + if err != nil { + return fmt.Errorf("partNumber HEAD on MP object: %w", err) + } + if err := checkNoChecksums(mpRes); err != nil { + return fmt.Errorf("partNumber HEAD on MP object: %w", err) + } + + return nil + }) +} + func HeadObject_overrides_fail_public(s *S3Conf) error { testName := "HeadObject_overrides_fail_public" 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 f943719b..beb8399e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -205,6 +205,7 @@ func TestHeadObject(ts *TestState) { ts.Run(HeadObject_non_existing_dir_object) ts.Run(HeadObject_invalid_parent_dir) ts.Run(HeadObject_with_range) + ts.Run(HeadObject_by_range_resp_status) ts.Run(HeadObject_zero_len_with_range) ts.Run(HeadObject_dir_with_range) ts.Run(HeadObject_conditional_reads) @@ -212,6 +213,7 @@ func TestHeadObject(ts *TestState) { if !ts.conf.azureTests { ts.Run(HeadObject_not_enabled_checksum_mode) ts.Run(HeadObject_checksums) + ts.Run(HeadObject_ranged_with_checksum_mode) } ts.Run(HeadObject_success) ts.Run(HeadObject_overrides_success) @@ -220,7 +222,9 @@ func TestHeadObject(ts *TestState) { ts.Run(HeadObject_range_and_part_number) ts.Run(HeadObject_mp_part_number_exceeds_parts_count) ts.Run(HeadObject_mp_part_number_success) + ts.Run(HeadObject_mp_part_number_resp_status) ts.Run(HeadObject_non_mp_part_number_1_success) + ts.Run(HeadObject_empty_object_part_number_1) } func TestGetObjectAttributes(ts *TestState) { @@ -248,8 +252,10 @@ func TestGetObject(ts *TestState) { ts.Run(GetObject_conditional_reads) //TODO: remove the condition after implementing checksums in azure if !ts.conf.azureTests { + ts.Run(GetObject_not_enabled_checksum_mode) ts.Run(GetObject_checksums) ts.Run(GetObject_dir_object_checksum) + ts.Run(GetObject_ranged_with_checksum_mode) } ts.Run(GetObject_success) ts.Run(GetObject_directory_success) @@ -262,7 +268,9 @@ func TestGetObject(ts *TestState) { ts.Run(GetObject_range_and_part_number) ts.Run(GetObject_mp_part_number_exceeds_parts_count) ts.Run(GetObject_mp_part_number_success) + ts.Run(GetObject_mp_part_number_resp_status) ts.Run(GetObject_non_mp_part_number_1_success) + ts.Run(GetObject_empty_object_part_number_1) } func TestListObjects(ts *TestState) { @@ -1395,11 +1403,13 @@ func GetIntTests() IntTests { "HeadObject_name_too_long": HeadObject_name_too_long, "HeadObject_invalid_parent_dir": HeadObject_invalid_parent_dir, "HeadObject_with_range": HeadObject_with_range, + "HeadObject_by_range_resp_status": HeadObject_by_range_resp_status, "HeadObject_zero_len_with_range": HeadObject_zero_len_with_range, "HeadObject_dir_with_range": HeadObject_dir_with_range, "HeadObject_conditional_reads": HeadObject_conditional_reads, "HeadObject_not_enabled_checksum_mode": HeadObject_not_enabled_checksum_mode, "HeadObject_checksums": HeadObject_checksums, + "HeadObject_ranged_with_checksum_mode": HeadObject_ranged_with_checksum_mode, "HeadObject_success": HeadObject_success, "HeadObject_overrides_success": HeadObject_overrides_success, "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, @@ -1407,7 +1417,9 @@ func GetIntTests() IntTests { "HeadObject_range_and_part_number": HeadObject_range_and_part_number, "HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count, "HeadObject_mp_part_number_success": HeadObject_mp_part_number_success, + "HeadObject_mp_part_number_resp_status": HeadObject_mp_part_number_resp_status, "HeadObject_non_mp_part_number_1_success": HeadObject_non_mp_part_number_1_success, + "HeadObject_empty_object_part_number_1": HeadObject_empty_object_part_number_1, "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, "GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs, @@ -1424,8 +1436,10 @@ func GetIntTests() IntTests { "GetObject_invalid_parent": GetObject_invalid_parent, "GetObject_large_object": GetObject_large_object, "GetObject_conditional_reads": GetObject_conditional_reads, + "GetObject_not_enabled_checksum_mode": GetObject_not_enabled_checksum_mode, "GetObject_checksums": GetObject_checksums, "GetObject_dir_object_checksum": GetObject_dir_object_checksum, + "GetObject_ranged_with_checksum_mode": GetObject_ranged_with_checksum_mode, "GetObject_success": GetObject_success, "GetObject_directory_success": GetObject_directory_success, "GetObject_by_range_resp_status": GetObject_by_range_resp_status, @@ -1437,7 +1451,9 @@ func GetIntTests() IntTests { "GetObject_range_and_part_number": GetObject_range_and_part_number, "GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count, "GetObject_mp_part_number_success": GetObject_mp_part_number_success, + "GetObject_mp_part_number_resp_status": GetObject_mp_part_number_resp_status, "GetObject_non_mp_part_number_1_success": GetObject_non_mp_part_number_1_success, + "GetObject_empty_object_part_number_1": GetObject_empty_object_part_number_1, "ListObjects_non_existing_bucket": ListObjects_non_existing_bucket, "ListObjects_with_prefix": ListObjects_with_prefix, "ListObjects_truncated": ListObjects_truncated,