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,