Merge pull request #2064 from versity/sis/ranged-get-headobject

fix: correct 206 Partial Content response status for ranged GetObject and HeadObject
This commit is contained in:
Ben McClelland
2026-04-20 16:22:24 -07:00
committed by GitHub
9 changed files with 772 additions and 40 deletions
+21 -11
View File
@@ -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,
+11 -3
View File
@@ -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)
+1 -1
View File
@@ -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
}
+45 -1
View File
@@ -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) {
+7
View File
@@ -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
}
+46
View File
@@ -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,
},
},
},
+324 -22
View File
@@ -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: &regularObj,
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: &regularObj,
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
})
}
+301 -2
View File
@@ -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: &regularObj,
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: &regularObj,
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 {
+16
View File
@@ -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,