Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,9 @@ type ObjectDeletePreconditions struct {
func EvaluateObjectDeletePreconditions(etag string, modTime time.Time, size int64, preconditions ObjectDeletePreconditions) error {
etag = strings.Trim(etag, `"`)
ifMatch := preconditions.IfMatch
if ifMatch != nil && *ifMatch != etag {
// If-Match "*" matches any existing object; preconditions are only evaluated
// once the target object is known to exist, so "*" always passes here.
if ifMatch != nil && *ifMatch != "*" && *ifMatch != etag {
return s3err.GetPreconditionFailedErr(s3err.ConditionIfMatch)
}

Expand Down
15 changes: 7 additions & 8 deletions backend/s3proxy/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -1184,21 +1184,20 @@ func (s *S3Proxy) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAt
objParts := out.ObjectParts
if objParts != nil {
if objParts.PartNumberMarker != nil {
partNumberMarker, err := strconv.Atoi(*objParts.PartNumberMarker)
if err != nil {
parts.PartNumberMarker = partNumberMarker
if partNumberMarker, err := strconv.Atoi(*objParts.PartNumberMarker); err == nil {
parts.PartNumberMarker = &partNumberMarker
}
if objParts.NextPartNumberMarker != nil {
nextPartNumberMarker, err := strconv.Atoi(*objParts.NextPartNumberMarker)
if err != nil {
parts.NextPartNumberMarker = nextPartNumberMarker
if nextPartNumberMarker, err := strconv.Atoi(*objParts.NextPartNumberMarker); err == nil {
parts.NextPartNumberMarker = &nextPartNumberMarker
}
}
if objParts.IsTruncated != nil {
parts.IsTruncated = *objParts.IsTruncated
parts.IsTruncated = objParts.IsTruncated
}
if objParts.MaxParts != nil {
parts.MaxParts = int(*objParts.MaxParts)
mp := int(*objParts.MaxParts)
parts.MaxParts = &mp
}
parts.Parts = objParts.Parts
}
Expand Down
55 changes: 55 additions & 0 deletions s3api/controllers/bucket-get.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
package controllers

import (
"net/url"
"strings"

"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/fil-forge/versitygw/auth"
"github.com/fil-forge/versitygw/s3api/utils"
"github.com/fil-forge/versitygw/s3err"
"github.com/fil-forge/versitygw/s3response"
"github.com/gofiber/fiber/v3"
)
Expand Down Expand Up @@ -549,6 +551,21 @@ func (c S3ApiController) ListMultipartUploads(ctx fiber.Ctx) (*Response, error)
}, err
}

// urlEncodeListKey applies EncodingType=url encoding to a list-response key or
// prefix: form-style escaping (space -> "+", "+" -> "%2B", ...) with "/" left
// intact, matching AWS.
func urlEncodeListKey(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "%2F", "/")
}

func encStrPtr(p *string) *string {
if p == nil {
return nil
}
v := urlEncodeListKey(*p)
return &v
}

func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) {
// url values
bucket := ctx.Params("bucket")
Expand Down Expand Up @@ -595,6 +612,17 @@ func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) {
}, err
}

// A present-but-empty continuation-token is invalid, not absent: AWS rejects
// it with InvalidArgument. ctx.Query collapses "absent" and "present, empty",
// so check the raw query args for the key's presence.
if cToken == "" && ctx.Request().URI().QueryArgs().Has("continuation-token") {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetInvalidArgumentErr(s3err.InvalidArgContinuationToken, "")
}

res, err := c.be.ListObjectsV2(ctx.RequestCtx(),
&s3.ListObjectsV2Input{
Bucket: &bucket,
Expand All @@ -613,6 +641,19 @@ func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) {
}, err
}

if strings.EqualFold(ctx.Query("encoding-type"), "url") {
res.EncodingType = types.EncodingTypeUrl
res.Prefix = encStrPtr(res.Prefix)
res.Delimiter = encStrPtr(res.Delimiter)
res.StartAfter = encStrPtr(res.StartAfter)
for i := range res.Contents {
res.Contents[i].Key = encStrPtr(res.Contents[i].Key)
}
for i := range res.CommonPrefixes {
res.CommonPrefixes[i].Prefix = encStrPtr(res.CommonPrefixes[i].Prefix)
}
}

return &Response{
Headers: map[string]*string{
"x-amz-bucket-region": &region,
Expand Down Expand Up @@ -685,6 +726,20 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) {
}, err
}

if strings.EqualFold(ctx.Query("encoding-type"), "url") {
res.EncodingType = types.EncodingTypeUrl
res.Prefix = encStrPtr(res.Prefix)
res.Delimiter = encStrPtr(res.Delimiter)
res.Marker = encStrPtr(res.Marker)
res.NextMarker = encStrPtr(res.NextMarker)
for i := range res.Contents {
res.Contents[i].Key = encStrPtr(res.Contents[i].Key)
}
for i := range res.CommonPrefixes {
res.CommonPrefixes[i].Prefix = encStrPtr(res.CommonPrefixes[i].Prefix)
}
}

return &Response{
Headers: map[string]*string{
"x-amz-bucket-region": &region,
Expand Down
17 changes: 17 additions & 0 deletions s3api/controllers/bucket-get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,23 @@ func TestS3ApiController_ListObjectsV2(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "empty continuation token",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"continuation-token": "",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgContinuationToken, ""),
},
},
{
name: "successful response",
input: testInput{
Expand Down
1 change: 1 addition & 0 deletions s3api/controllers/bucket-post.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) {
Bucket: &bucket,
Delete: &types.Delete{
Objects: dObj.Objects,
Quiet: &dObj.Quiet,
},
})
return &Response{
Expand Down
18 changes: 18 additions & 0 deletions s3api/controllers/bucket-put.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,16 @@ func (c S3ApiController) CreateBucket(ctx fiber.Ctx) (*Response, error) {
}, s3err.GetBucketErr(s3err.ErrInvalidBucketName, bucket)
}

// a well-formed name reserved by AWS (sthree- prefix, -s3alias / --ol-s3
// suffix) is rejected with AccessDenied rather than InvalidBucketName
if utils.IsReservedBucketName(bucket) {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: bucketOwner.Access,
},
}, s3err.GetAPIError(s3err.ErrAccessDenied)
}

// both bucket canned ACL and acl grants is not allowed
if acl != "" && grants != "" {
debuglogger.Logf("invalid request: %q (grants) %q (acl)", grants, acl)
Expand Down Expand Up @@ -708,6 +718,14 @@ func (c S3ApiController) CreateBucket(ctx fiber.Ctx) (*Response, error) {
Bucket: &bucket,
ObjectOwnership: objectOwnership,
ObjectLockEnabledForBucket: &lockEnabled,
// Forward the requested ACL so backends that do not model ACLs can
// reject a non-default request; backends that store ACLs use updAcl.
ACL: acl,
GrantFullControl: utils.GetStringPtr(grantFullControl),
GrantRead: utils.GetStringPtr(grantRead),
GrantReadACP: utils.GetStringPtr(grantReadACP),
GrantWrite: utils.GetStringPtr(grantWrite),
GrantWriteACP: utils.GetStringPtr(grantWriteACP),
CreateBucketConfiguration: &types.CreateBucketConfiguration{
Tags: body.TagSet,
},
Expand Down
5 changes: 5 additions & 0 deletions s3api/controllers/object-get.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,11 @@ func (c S3ApiController) GetObject(ctx fiber.Ctx) (*Response, error) {
headers = map[string]*string{
"x-amz-delete-marker": utils.GetStringPtr("true"),
"Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt),
// A 304 Not Modified must carry the object's ETag (RFC 7232
// §4.1). Backends surface it by returning a non-nil result
// alongside the not-modified error; nil ETag values are
// dropped when the response is written.
"ETag": res.ETag,
}
}
return &Response{
Expand Down
1 change: 1 addition & 0 deletions s3api/controllers/object-get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ func TestS3ApiController_GetObject(t *testing.T) {
Headers: map[string]*string{
"x-amz-delete-marker": utils.GetStringPtr("true"),
"Last-Modified": utils.GetStringPtr(tm.UTC().Format(timefmt)),
"ETag": nil,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
Expand Down
28 changes: 26 additions & 2 deletions s3api/controllers/object-post.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,23 @@ func (c S3ApiController) CreateMultipartUpload(ctx fiber.Ctx) (*Response, error)
}, err
}

acl := types.ObjectCannedACL(c.getAclHeaderValue(ctx, "X-Amz-Acl"))
grantFullControl := c.getAclHeaderValue(ctx, "X-Amz-Grant-Full-Control")
grantRead := c.getAclHeaderValue(ctx, "X-Amz-Grant-Read")
grantReadACP := c.getAclHeaderValue(ctx, "X-Amz-Grant-Read-Acp")
grantWriteACP := c.getAclHeaderValue(ctx, "X-Amz-Grant-Write-Acp")

res, err := c.be.CreateMultipartUpload(ctx.RequestCtx(),
s3response.CreateMultipartUploadInput{
Bucket: &bucket,
Key: &key,
Bucket: &bucket,
Key: &key,
// Forward the requested ACL so backends that do not model ACLs can
// reject a request that sets one; backends that store ACLs use it.
ACL: acl,
GrantFullControl: utils.GetStringPtr(grantFullControl),
GrantRead: utils.GetStringPtr(grantRead),
GrantReadACP: utils.GetStringPtr(grantReadACP),
GrantWriteACP: utils.GetStringPtr(grantWriteACP),
Tagging: &tagging,
ContentType: &contentType,
ContentEncoding: &contentEncoding,
Expand Down Expand Up @@ -299,6 +312,17 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro
}, err
}

// An empty request body is rejected as InvalidRequest (AWS), distinct from
// the MalformedXML returned for a present-but-unparseable body.
if len(ctx.BodyRaw()) == 0 {
debuglogger.Logf("empty body provided for complete multipart upload")
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
}

var body s3response.CompleteMultipartUploadRequestBody
err = xml.Unmarshal(ctx.BodyRaw(), &body)
if err != nil {
Expand Down
46 changes: 41 additions & 5 deletions s3api/controllers/object-put.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,8 +620,16 @@ func (c S3ApiController) CopyObject(ctx fiber.Ctx) (*Response, error) {

res, err := c.be.CopyObject(ctx.RequestCtx(),
s3response.CopyObjectInput{
Bucket: &bucket,
Key: &key,
Bucket: &bucket,
Key: &key,
// Forward the requested ACL so backends that do not model ACLs can
// reject a request that sets one. Absent grant headers stay nil
// (GetStringPtr) to distinguish "unset" from an explicit empty value.
ACL: types.ObjectCannedACL(c.getAclHeaderValue(ctx, "X-Amz-Acl")),
GrantFullControl: utils.GetStringPtr(c.getAclHeaderValue(ctx, "X-Amz-Grant-Full-Control")),
GrantRead: utils.GetStringPtr(c.getAclHeaderValue(ctx, "X-Amz-Grant-Read")),
GrantReadACP: utils.GetStringPtr(c.getAclHeaderValue(ctx, "X-Amz-Grant-Read-Acp")),
GrantWriteACP: utils.GetStringPtr(c.getAclHeaderValue(ctx, "X-Amz-Grant-Write-Acp")),
ContentType: &contentType,
ContentDisposition: &contentDisposition,
ContentEncoding: &contentEncoding,
Expand Down Expand Up @@ -797,10 +805,23 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) {

ifMatch, ifNoneMatch := utils.ParsePreconditionMatchHeaders(ctx)

acl := types.ObjectCannedACL(c.getAclHeaderValue(ctx, "X-Amz-Acl"))
grantFullControl := c.getAclHeaderValue(ctx, "X-Amz-Grant-Full-Control")
grantRead := c.getAclHeaderValue(ctx, "X-Amz-Grant-Read")
grantReadACP := c.getAclHeaderValue(ctx, "X-Amz-Grant-Read-Acp")
grantWriteACP := c.getAclHeaderValue(ctx, "X-Amz-Grant-Write-Acp")

res, err := c.be.PutObject(ctx.RequestCtx(),
s3response.PutObjectInput{
Bucket: &bucket,
Key: &key,
Bucket: &bucket,
Key: &key,
// Forward the requested ACL so backends that do not model ACLs can
// reject a request that sets one; backends that store ACLs use it.
ACL: acl,
GrantFullControl: utils.GetStringPtr(grantFullControl),
GrantRead: utils.GetStringPtr(grantRead),
GrantReadACP: utils.GetStringPtr(grantReadACP),
GrantWriteACP: utils.GetStringPtr(grantWriteACP),
ContentLength: &contentLength,
ContentType: &contentType,
ContentEncoding: &contentEncoding,
Expand Down Expand Up @@ -843,7 +864,7 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) {
"x-amz-checksum-xxhash3": res.ChecksumXXHASH3,
"x-amz-checksum-xxhash128": res.ChecksumXXHASH128,
"x-amz-checksum-type": utils.ConvertToStringPtr(res.ChecksumType),
"x-amz-version-id": &res.VersionID,
"x-amz-version-id": putObjectVersionIdHeader(res.VersionID),
"x-amz-object-size": utils.ConvertPtrToStringPtr(res.Size),
},
MetaOpts: &MetaOptions{
Expand All @@ -855,3 +876,18 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) {
},
}, err
}

// nullVersionId is the version id of an object written while bucket versioning
// is off or suspended.
const nullVersionId = "null"

// putObjectVersionIdHeader returns the x-amz-version-id value for a PutObject
// response. AWS only sends the header for a version it generated: a PUT into a
// bucket with versioning suspended stores the "null" version and the response
// carries no x-amz-version-id at all.
func putObjectVersionIdHeader(versionId string) *string {
if versionId == nullVersionId {
return nil
}
return &versionId
}
53 changes: 53 additions & 0 deletions s3api/controllers/object-put_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,59 @@ func TestS3ApiController_PutObject(t *testing.T) {
},
},
},
{
name: "suspended bucket null version omits x-amz-version-id",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyIsRoot: true,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "root",
},
utils.ContextKeyAccount: auth.Account{
Access: "root",
Role: auth.RoleAdmin,
},
utils.ContextKeyBodyReader: strings.NewReader("something"),
},
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
headers: map[string]string{
"Content-Length": "3",
},
body: []byte("aaa"),
beRes: s3response.PutObjectOutput{
ETag: "ETag",
VersionID: "null",
Size: &objSize,
},
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"ETag": utils.GetStringPtr("ETag"),
"x-amz-checksum-crc32": nil,
"x-amz-checksum-crc32c": nil,
"x-amz-checksum-crc64nvme": nil,
"x-amz-checksum-sha1": nil,
"x-amz-checksum-sha256": nil,
"x-amz-checksum-sha512": nil,
"x-amz-checksum-md5": nil,
"x-amz-checksum-xxhash64": nil,
"x-amz-checksum-xxhash3": nil,
"x-amz-checksum-xxhash128": nil,
"x-amz-checksum-type": utils.GetStringPtr(""),
"x-amz-version-id": nil,
"x-amz-object-size": utils.ConvertToStringPtr(objSize),
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ObjectETag: utils.GetStringPtr("ETag"),
EventName: s3event.EventObjectCreatedPut,
ContentLength: 3,
ObjectSize: 3,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
Loading
Loading