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.
This commit is contained in:
niksis02
2026-04-21 02:13:04 +04:00
parent 8533bc1b60
commit d6fb9547b8
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,