fix: Changes GetObjectAttributes action xml encoding root element to GetObjectAttributesResponse. Adds input validation for x-amz-object-attributes header. Adds x-amz-delete-marker and x-maz-version-id headers for GetObjectAttributes action. Adds VersionId in HeadObject response, if it's not specified in the request

This commit is contained in:
jonaustin09
2024-10-30 15:42:15 -04:00
parent 98eda968eb
commit 06e2f2183d
13 changed files with 389 additions and 121 deletions

View File

@@ -512,20 +512,22 @@ func (az *Azure) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3
return result, nil return result, nil
} }
func (az *Azure) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { func (az *Azure) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
data, err := az.HeadObject(ctx, &s3.HeadObjectInput{ data, err := az.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: input.Bucket, Bucket: input.Bucket,
Key: input.Key, Key: input.Key,
}) })
if err != nil { if err != nil {
return s3response.GetObjectAttributesResult{}, err return s3response.GetObjectAttributesResponse{}, err
} }
return s3response.GetObjectAttributesResult{ return s3response.GetObjectAttributesResponse{
ETag: data.ETag, ETag: data.ETag,
LastModified: data.LastModified,
ObjectSize: data.ContentLength, ObjectSize: data.ContentLength,
StorageClass: data.StorageClass, StorageClass: data.StorageClass,
LastModified: data.LastModified,
VersionId: data.VersionId,
DeleteMarker: data.DeleteMarker,
}, nil }, nil
} }

View File

@@ -61,7 +61,7 @@ type Backend interface {
HeadObject(context.Context, *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) HeadObject(context.Context, *s3.HeadObjectInput) (*s3.HeadObjectOutput, error)
GetObject(context.Context, *s3.GetObjectInput) (*s3.GetObjectOutput, error) GetObject(context.Context, *s3.GetObjectInput) (*s3.GetObjectOutput, error)
GetObjectAcl(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) GetObjectAcl(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error)
GetObjectAttributes(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) GetObjectAttributes(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error)
CopyObject(context.Context, *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) CopyObject(context.Context, *s3.CopyObjectInput) (*s3.CopyObjectOutput, error)
ListObjects(context.Context, *s3.ListObjectsInput) (s3response.ListObjectsResult, error) ListObjects(context.Context, *s3.ListObjectsInput) (s3response.ListObjectsResult, error)
ListObjectsV2(context.Context, *s3.ListObjectsV2Input) (s3response.ListObjectsV2Result, error) ListObjectsV2(context.Context, *s3.ListObjectsV2Input) (s3response.ListObjectsV2Result, error)
@@ -185,8 +185,8 @@ func (BackendUnsupported) GetObject(context.Context, *s3.GetObjectInput) (*s3.Ge
func (BackendUnsupported) GetObjectAcl(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) { func (BackendUnsupported) GetObjectAcl(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) {
return nil, s3err.GetAPIError(s3err.ErrNotImplemented) return nil, s3err.GetAPIError(s3err.ErrNotImplemented)
} }
func (BackendUnsupported) GetObjectAttributes(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { func (BackendUnsupported) GetObjectAttributes(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
return s3response.GetObjectAttributesResult{}, s3err.GetAPIError(s3err.ErrNotImplemented) return s3response.GetObjectAttributesResponse{}, s3err.GetAPIError(s3err.ErrNotImplemented)
} }
func (BackendUnsupported) CopyObject(context.Context, *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) { func (BackendUnsupported) CopyObject(context.Context, *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
return nil, s3err.GetAPIError(s3err.ErrNotImplemented) return nil, s3err.GetAPIError(s3err.ErrNotImplemented)

View File

@@ -2961,8 +2961,9 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
if input.Key == nil { if input.Key == nil {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
} }
versionId := backend.GetStringFromPtr(input.VersionId)
if !p.versioningEnabled() && *input.VersionId != "" { if !p.versioningEnabled() && versionId != "" {
//TODO: Maybe we need to return our custom error here? //TODO: Maybe we need to return our custom error here?
return nil, s3err.GetAPIError(s3err.ErrInvalidVersionId) return nil, s3err.GetAPIError(s3err.ErrInvalidVersionId)
} }
@@ -3022,7 +3023,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
return nil, fmt.Errorf("stat bucket: %w", err) return nil, fmt.Errorf("stat bucket: %w", err)
} }
if *input.VersionId != "" { if versionId != "" {
vId, err := p.meta.RetrieveAttribute(nil, bucket, object, versionIdKey) vId, err := p.meta.RetrieveAttribute(nil, bucket, object, versionIdKey)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
@@ -3032,12 +3033,12 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
} }
if errors.Is(err, meta.ErrNoSuchKey) { if errors.Is(err, meta.ErrNoSuchKey) {
bucket = filepath.Join(p.versioningDir, bucket) bucket = filepath.Join(p.versioningDir, bucket)
object = filepath.Join(genObjVersionKey(object), *input.VersionId) object = filepath.Join(genObjVersionKey(object), versionId)
} }
if string(vId) != *input.VersionId { if string(vId) != versionId {
bucket = filepath.Join(p.versioningDir, bucket) bucket = filepath.Join(p.versioningDir, bucket)
object = filepath.Join(genObjVersionKey(object), *input.VersionId) object = filepath.Join(genObjVersionKey(object), versionId)
} }
} }
@@ -3045,7 +3046,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
fi, err := os.Stat(objPath) fi, err := os.Stat(objPath)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
if *input.VersionId != "" { if versionId != "" {
return nil, s3err.GetAPIError(s3err.ErrInvalidVersionId) return nil, s3err.GetAPIError(s3err.ErrInvalidVersionId)
} }
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
@@ -3063,7 +3064,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
} }
if *input.VersionId != "" { if p.versioningEnabled() {
isDelMarker, err := p.isObjDeleteMarker(bucket, object) isDelMarker, err := p.isObjDeleteMarker(bucket, object)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -3078,6 +3079,15 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
} }
} }
if p.versioningEnabled() && versionId == "" {
vId, err := p.meta.RetrieveAttribute(nil, bucket, object, versionIdKey)
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
return nil, fmt.Errorf("get object versionId: %v", err)
}
versionId = string(vId)
}
userMetaData := make(map[string]string) userMetaData := make(map[string]string)
contentType, contentEncoding := p.loadUserMetaData(bucket, object, userMetaData) contentType, contentEncoding := p.loadUserMetaData(bucket, object, userMetaData)
@@ -3094,7 +3104,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
size := fi.Size() size := fi.Size()
var objectLockLegalHoldStatus types.ObjectLockLegalHoldStatus var objectLockLegalHoldStatus types.ObjectLockLegalHoldStatus
status, err := p.GetObjectLegalHold(ctx, bucket, object, *input.VersionId) status, err := p.GetObjectLegalHold(ctx, bucket, object, versionId)
if err == nil { if err == nil {
if *status { if *status {
objectLockLegalHoldStatus = types.ObjectLockLegalHoldStatusOn objectLockLegalHoldStatus = types.ObjectLockLegalHoldStatusOn
@@ -3105,7 +3115,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
var objectLockMode types.ObjectLockMode var objectLockMode types.ObjectLockMode
var objectLockRetainUntilDate *time.Time var objectLockRetainUntilDate *time.Time
retention, err := p.GetObjectRetention(ctx, bucket, object, *input.VersionId) retention, err := p.GetObjectRetention(ctx, bucket, object, versionId)
if err == nil { if err == nil {
var config types.ObjectLockRetention var config types.ObjectLockRetention
if err := json.Unmarshal(retention, &config); err == nil { if err := json.Unmarshal(retention, &config); err == nil {
@@ -3114,8 +3124,6 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
} }
} }
//TODO: the method must handle multipart upload case
return &s3.HeadObjectOutput{ return &s3.HeadObjectOutput{
ContentLength: &size, ContentLength: &size,
ContentType: &contentType, ContentType: &contentType,
@@ -3127,25 +3135,34 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
ObjectLockMode: objectLockMode, ObjectLockMode: objectLockMode,
ObjectLockRetainUntilDate: objectLockRetainUntilDate, ObjectLockRetainUntilDate: objectLockRetainUntilDate,
StorageClass: types.StorageClassStandard, StorageClass: types.StorageClassStandard,
VersionId: input.VersionId, VersionId: &versionId,
}, nil }, nil
} }
func (p *Posix) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { func (p *Posix) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
data, err := p.HeadObject(ctx, &s3.HeadObjectInput{ data, err := p.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: input.Bucket, Bucket: input.Bucket,
Key: input.Key, Key: input.Key,
VersionId: input.VersionId, VersionId: input.VersionId,
}) })
if err != nil { if err != nil {
return s3response.GetObjectAttributesResult{}, nil if errors.Is(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)) && data != nil {
return s3response.GetObjectAttributesResponse{
DeleteMarker: data.DeleteMarker,
VersionId: data.VersionId,
}, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
return s3response.GetObjectAttributesResponse{}, err
} }
return s3response.GetObjectAttributesResult{ return s3response.GetObjectAttributesResponse{
ETag: data.ETag, ETag: data.ETag,
LastModified: data.LastModified,
ObjectSize: data.ContentLength, ObjectSize: data.ContentLength,
StorageClass: data.StorageClass, StorageClass: data.StorageClass,
LastModified: data.LastModified,
VersionId: data.VersionId,
DeleteMarker: data.DeleteMarker,
}, nil }, nil
} }

View File

@@ -392,7 +392,7 @@ func (s *S3Proxy) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.
return output, nil return output, nil
} }
func (s *S3Proxy) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { func (s *S3Proxy) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
out, err := s.client.GetObjectAttributes(ctx, input) out, err := s.client.GetObjectAttributes(ctx, input)
parts := s3response.ObjectParts{} parts := s3response.ObjectParts{}
@@ -419,12 +419,11 @@ func (s *S3Proxy) GetObjectAttributes(ctx context.Context, input *s3.GetObjectAt
} }
} }
return s3response.GetObjectAttributesResult{ return s3response.GetObjectAttributesResponse{
ETag: out.ETag, ETag: out.ETag,
LastModified: out.LastModified, LastModified: out.LastModified,
ObjectSize: out.ObjectSize, ObjectSize: out.ObjectSize,
StorageClass: out.StorageClass, StorageClass: out.StorageClass,
VersionId: out.VersionId,
ObjectParts: &parts, ObjectParts: &parts,
}, handleError(err) }, handleError(err)
} }

View File

@@ -83,7 +83,7 @@ var _ backend.Backend = &BackendMock{}
// GetObjectAclFunc: func(contextMoqParam context.Context, getObjectAclInput *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) { // GetObjectAclFunc: func(contextMoqParam context.Context, getObjectAclInput *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) {
// panic("mock out the GetObjectAcl method") // panic("mock out the GetObjectAcl method")
// }, // },
// GetObjectAttributesFunc: func(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { // GetObjectAttributesFunc: func(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
// panic("mock out the GetObjectAttributes method") // panic("mock out the GetObjectAttributes method")
// }, // },
// GetObjectLegalHoldFunc: func(contextMoqParam context.Context, bucket string, object string, versionId string) (*bool, error) { // GetObjectLegalHoldFunc: func(contextMoqParam context.Context, bucket string, object string, versionId string) (*bool, error) {
@@ -244,7 +244,7 @@ type BackendMock struct {
GetObjectAclFunc func(contextMoqParam context.Context, getObjectAclInput *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) GetObjectAclFunc func(contextMoqParam context.Context, getObjectAclInput *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error)
// GetObjectAttributesFunc mocks the GetObjectAttributes method. // GetObjectAttributesFunc mocks the GetObjectAttributes method.
GetObjectAttributesFunc func(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) GetObjectAttributesFunc func(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error)
// GetObjectLegalHoldFunc mocks the GetObjectLegalHold method. // GetObjectLegalHoldFunc mocks the GetObjectLegalHold method.
GetObjectLegalHoldFunc func(contextMoqParam context.Context, bucket string, object string, versionId string) (*bool, error) GetObjectLegalHoldFunc func(contextMoqParam context.Context, bucket string, object string, versionId string) (*bool, error)
@@ -1518,7 +1518,7 @@ func (mock *BackendMock) GetObjectAclCalls() []struct {
} }
// GetObjectAttributes calls GetObjectAttributesFunc. // GetObjectAttributes calls GetObjectAttributesFunc.
func (mock *BackendMock) GetObjectAttributes(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { func (mock *BackendMock) GetObjectAttributes(contextMoqParam context.Context, getObjectAttributesInput *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
if mock.GetObjectAttributesFunc == nil { if mock.GetObjectAttributesFunc == nil {
panic("BackendMock.GetObjectAttributesFunc: method is nil but Backend.GetObjectAttributes was just called") panic("BackendMock.GetObjectAttributesFunc: method is nil but Backend.GetObjectAttributes was just called")
} }

View File

@@ -51,8 +51,9 @@ type S3ApiController struct {
} }
const ( const (
iso8601Format = "20060102T150405Z" iso8601Format = "20060102T150405Z"
defaultContentType = "binary/octet-stream" iso8601TimeFormatExtended = "Mon Jan _2 15:04:05 2006"
defaultContentType = "binary/octet-stream"
) )
func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs s3event.S3EventSender, mm *metrics.Manager, debug bool, readonly bool) S3ApiController { func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs s3event.S3EventSender, mm *metrics.Manager, debug bool, readonly bool) S3ApiController {
@@ -379,7 +380,19 @@ func (c S3ApiController) GetActions(ctx *fiber.Ctx) error {
BucketOwner: parsedAcl.Owner, BucketOwner: parsedAcl.Owner,
}) })
} }
attrs := utils.ParseObjectAttributes(ctx) attrs, err := utils.ParseObjectAttributes(ctx)
if err != nil {
if c.debug {
log.Printf("error parsing object attributes: %v", err)
}
return SendXMLResponse(ctx, nil, err,
&MetaOpts{
Logger: c.logger,
MetricsMng: c.mm,
Action: metrics.ActionGetObjectAttributes,
BucketOwner: parsedAcl.Owner,
})
}
res, err := c.be.GetObjectAttributes(ctx.Context(), res, err := c.be.GetObjectAttributes(ctx.Context(),
&s3.GetObjectAttributesInput{ &s3.GetObjectAttributesInput{
@@ -390,6 +403,22 @@ func (c S3ApiController) GetActions(ctx *fiber.Ctx) error {
VersionId: &versionId, VersionId: &versionId,
}) })
if err != nil { if err != nil {
hdrs := []utils.CustomHeader{}
if res.DeleteMarker != nil {
hdrs = append(hdrs, utils.CustomHeader{
Key: "x-amz-delete-marker",
Value: "true",
})
}
if getstring(res.VersionId) != "" {
hdrs = append(hdrs, utils.CustomHeader{
Key: "x-amz-version-id",
Value: getstring(res.VersionId),
})
}
utils.SetResponseHeaders(ctx, hdrs)
return SendXMLResponse(ctx, nil, err, return SendXMLResponse(ctx, nil, err,
&MetaOpts{ &MetaOpts{
Logger: c.logger, Logger: c.logger,
@@ -398,7 +427,31 @@ func (c S3ApiController) GetActions(ctx *fiber.Ctx) error {
BucketOwner: parsedAcl.Owner, BucketOwner: parsedAcl.Owner,
}) })
} }
return SendXMLResponse(ctx, utils.FilterObjectAttributes(attrs, res), err,
hdrs := []utils.CustomHeader{}
if getstring(res.VersionId) != "" {
hdrs = append(hdrs, utils.CustomHeader{
Key: "x-amz-version-id",
Value: getstring(res.VersionId),
})
}
if res.DeleteMarker != nil && *res.DeleteMarker {
hdrs = append(hdrs, utils.CustomHeader{
Key: "x-amz-delete-marker",
Value: "true",
})
}
if res.LastModified != nil {
hdrs = append(hdrs, utils.CustomHeader{
Key: "Last-Modified",
Value: res.LastModified.UTC().Format(iso8601TimeFormatExtended),
})
}
utils.SetResponseHeaders(ctx, hdrs)
return SendXMLResponse(ctx, utils.FilterObjectAttributes(attrs, res), nil,
&MetaOpts{ &MetaOpts{
Logger: c.logger, Logger: c.logger,
MetricsMng: c.mm, MetricsMng: c.mm,
@@ -2900,15 +2953,6 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) error {
BucketOwner: parsedAcl.Owner, BucketOwner: parsedAcl.Owner,
}) })
} }
if res == nil {
return SendResponse(ctx, fmt.Errorf("head object nil response"),
&MetaOpts{
Logger: c.logger,
MetricsMng: c.mm,
Action: metrics.ActionHeadObject,
BucketOwner: parsedAcl.Owner,
})
}
utils.SetMetaHeaders(ctx, res.Metadata) utils.SetMetaHeaders(ctx, res.Metadata)
headers := []utils.CustomHeader{ headers := []utils.CustomHeader{

View File

@@ -187,8 +187,8 @@ func TestS3ApiController_GetActions(t *testing.T) {
GetObjectAclFunc: func(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) { GetObjectAclFunc: func(context.Context, *s3.GetObjectAclInput) (*s3.GetObjectAclOutput, error) {
return &s3.GetObjectAclOutput{}, nil return &s3.GetObjectAclOutput{}, nil
}, },
GetObjectAttributesFunc: func(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResult, error) { GetObjectAttributesFunc: func(context.Context, *s3.GetObjectAttributesInput) (s3response.GetObjectAttributesResponse, error) {
return s3response.GetObjectAttributesResult{}, nil return s3response.GetObjectAttributesResponse{}, nil
}, },
GetObjectFunc: func(context.Context, *s3.GetObjectInput) (*s3.GetObjectOutput, error) { GetObjectFunc: func(context.Context, *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
return &s3.GetObjectOutput{ return &s3.GetObjectOutput{

View File

@@ -254,35 +254,47 @@ func ParseDeleteObjects(objs []types.ObjectIdentifier) (result []string) {
return return
} }
func FilterObjectAttributes(attrs map[types.ObjectAttributes]struct{}, output s3response.GetObjectAttributesResult) s3response.GetObjectAttributesResult { func FilterObjectAttributes(attrs map[s3response.ObjectAttributes]struct{}, output s3response.GetObjectAttributesResponse) s3response.GetObjectAttributesResponse {
if _, ok := attrs[types.ObjectAttributesEtag]; !ok { // These properties shouldn't appear in the final response body
output.LastModified = nil
output.VersionId = nil
output.DeleteMarker = nil
if _, ok := attrs[s3response.ObjectAttributesEtag]; !ok {
output.ETag = nil output.ETag = nil
} }
if _, ok := attrs[types.ObjectAttributesObjectParts]; !ok { if _, ok := attrs[s3response.ObjectAttributesObjectParts]; !ok {
output.ObjectParts = nil output.ObjectParts = nil
} }
if _, ok := attrs[types.ObjectAttributesObjectSize]; !ok { if _, ok := attrs[s3response.ObjectAttributesObjectSize]; !ok {
output.ObjectSize = nil output.ObjectSize = nil
} }
if _, ok := attrs[types.ObjectAttributesStorageClass]; !ok { if _, ok := attrs[s3response.ObjectAttributesStorageClass]; !ok {
output.StorageClass = "" output.StorageClass = ""
} }
fmt.Printf("%+v\n", output)
return output return output
} }
func ParseObjectAttributes(ctx *fiber.Ctx) map[types.ObjectAttributes]struct{} { func ParseObjectAttributes(ctx *fiber.Ctx) (map[s3response.ObjectAttributes]struct{}, error) {
attrs := map[types.ObjectAttributes]struct{}{} attrs := map[s3response.ObjectAttributes]struct{}{}
var err error
ctx.Request().Header.VisitAll(func(key, value []byte) { ctx.Request().Header.VisitAll(func(key, value []byte) {
if string(key) == "X-Amz-Object-Attributes" { if string(key) == "X-Amz-Object-Attributes" {
oattrs := strings.Split(string(value), ",") oattrs := strings.Split(string(value), ",")
for _, a := range oattrs { for _, a := range oattrs {
attrs[types.ObjectAttributes(a)] = struct{}{} attr := s3response.ObjectAttributes(a)
if !attr.IsValid() {
err = s3err.GetAPIError(s3err.ErrInvalidObjectAttributes)
break
}
attrs[attr] = struct{}{}
} }
} }
}) })
return attrs return attrs, err
} }
type objLockCfg struct { type objLockCfg struct {

View File

@@ -19,10 +19,12 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"testing" "testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3response" "github.com/versity/versitygw/s3response"
) )
@@ -283,47 +285,68 @@ func TestParseUint(t *testing.T) {
func TestFilterObjectAttributes(t *testing.T) { func TestFilterObjectAttributes(t *testing.T) {
type args struct { type args struct {
attrs map[types.ObjectAttributes]struct{} attrs map[s3response.ObjectAttributes]struct{}
output s3response.GetObjectAttributesResult output s3response.GetObjectAttributesResponse
} }
etag, objSize := "etag", int64(3222) etag, objSize := "etag", int64(3222)
delMarker := true
tests := []struct { tests := []struct {
name string name string
args args args args
want s3response.GetObjectAttributesResult want s3response.GetObjectAttributesResponse
}{ }{
{ {
name: "keep only ETag", name: "keep only ETag",
args: args{ args: args{
attrs: map[types.ObjectAttributes]struct{}{ attrs: map[s3response.ObjectAttributes]struct{}{
types.ObjectAttributesEtag: {}, s3response.ObjectAttributesEtag: {},
}, },
output: s3response.GetObjectAttributesResult{ output: s3response.GetObjectAttributesResponse{
ObjectSize: &objSize, ObjectSize: &objSize,
ETag: &etag, ETag: &etag,
}, },
}, },
want: s3response.GetObjectAttributesResult{ETag: &etag}, want: s3response.GetObjectAttributesResponse{ETag: &etag},
}, },
{ {
name: "keep multiple props", name: "keep multiple props",
args: args{ args: args{
attrs: map[types.ObjectAttributes]struct{}{ attrs: map[s3response.ObjectAttributes]struct{}{
types.ObjectAttributesEtag: {}, s3response.ObjectAttributesEtag: {},
types.ObjectAttributesObjectSize: {}, s3response.ObjectAttributesObjectSize: {},
types.ObjectAttributesStorageClass: {}, s3response.ObjectAttributesStorageClass: {},
}, },
output: s3response.GetObjectAttributesResult{ output: s3response.GetObjectAttributesResponse{
ObjectSize: &objSize, ObjectSize: &objSize,
ETag: &etag, ETag: &etag,
ObjectParts: &s3response.ObjectParts{}, ObjectParts: &s3response.ObjectParts{},
VersionId: &etag, VersionId: &etag,
}, },
}, },
want: s3response.GetObjectAttributesResult{ want: s3response.GetObjectAttributesResponse{
ETag: &etag, ETag: &etag,
ObjectSize: &objSize, ObjectSize: &objSize,
VersionId: &etag, },
},
{
name: "make sure LastModified, DeleteMarker and VersionId are removed",
args: args{
attrs: map[s3response.ObjectAttributes]struct{}{
s3response.ObjectAttributesEtag: {},
},
output: s3response.GetObjectAttributesResponse{
ObjectSize: &objSize,
ETag: &etag,
ObjectParts: &s3response.ObjectParts{},
VersionId: &etag,
LastModified: backend.GetTimePtr(time.Now()),
DeleteMarker: &delMarker,
},
},
want: s3response.GetObjectAttributesResponse{
ETag: &etag,
}, },
}, },
} }

View File

@@ -70,6 +70,7 @@ const (
ErrInvalidMaxUploads ErrInvalidMaxUploads
ErrInvalidMaxParts ErrInvalidMaxParts
ErrInvalidPartNumberMarker ErrInvalidPartNumberMarker
ErrInvalidObjectAttributes
ErrInvalidPart ErrInvalidPart
ErrInvalidPartNumber ErrInvalidPartNumber
ErrInternalError ErrInternalError
@@ -219,6 +220,11 @@ var errorCodeResponse = map[ErrorCode]APIError{
Description: "Argument partNumberMarker must be an integer.", Description: "Argument partNumberMarker must be an integer.",
HTTPStatusCode: http.StatusBadRequest, HTTPStatusCode: http.StatusBadRequest,
}, },
ErrInvalidObjectAttributes: {
Code: "InvalidArgument",
Description: "Invalid attribute name specified.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrNoSuchBucket: { ErrNoSuchBucket: {
Code: "NoSuchBucket", Code: "NoSuchBucket",
Description: "The specified bucket does not exist.", Description: "The specified bucket does not exist.",

View File

@@ -78,30 +78,34 @@ type ListPartsResult struct {
Parts []Part `xml:"Part"` Parts []Part `xml:"Part"`
} }
type GetObjectAttributesResult struct { type ObjectAttributes string
ETag *string
LastModified *time.Time const (
ObjectSize *int64 ObjectAttributesEtag ObjectAttributes = "ETag"
StorageClass types.StorageClass ObjectAttributesChecksum ObjectAttributes = "Checksum"
VersionId *string ObjectAttributesObjectParts ObjectAttributes = "ObjectParts"
ObjectParts *ObjectParts ObjectAttributesStorageClass ObjectAttributes = "StorageClass"
ObjectAttributesObjectSize ObjectAttributes = "ObjectSize"
)
func (o ObjectAttributes) IsValid() bool {
return o == ObjectAttributesChecksum ||
o == ObjectAttributesEtag ||
o == ObjectAttributesObjectParts ||
o == ObjectAttributesObjectSize ||
o == ObjectAttributesStorageClass
} }
func (r GetObjectAttributesResult) MarshalXML(e *xml.Encoder, start xml.StartElement) error { type GetObjectAttributesResponse struct {
type Alias GetObjectAttributesResult ETag *string
aux := &struct { ObjectSize *int64
LastModified *string `xml:"LastModified"` StorageClass types.StorageClass `xml:",omitempty"`
*Alias ObjectParts *ObjectParts
}{
Alias: (*Alias)(&r),
}
if r.LastModified != nil { // Not included in the response body
formattedTime := r.LastModified.UTC().Format(iso8601TimeFormat) VersionId *string
aux.LastModified = &formattedTime LastModified *time.Time
} DeleteMarker *bool
return e.EncodeElement(aux, start)
} }
type ObjectParts struct { type ObjectParts struct {

View File

@@ -157,6 +157,7 @@ func TestHeadObject(s *S3Conf) {
func TestGetObjectAttributes(s *S3Conf) { func TestGetObjectAttributes(s *S3Conf) {
GetObjectAttributes_non_existing_bucket(s) GetObjectAttributes_non_existing_bucket(s)
GetObjectAttributes_non_existing_object(s) GetObjectAttributes_non_existing_object(s)
GetObjectAttributes_invalid_attrs(s)
GetObjectAttributes_existing_object(s) GetObjectAttributes_existing_object(s)
} }
@@ -565,6 +566,7 @@ func TestVersioning(s *S3Conf) {
// HeadObject action // HeadObject action
Versioning_HeadObject_invalid_versionId(s) Versioning_HeadObject_invalid_versionId(s)
Versioning_HeadObject_success(s) Versioning_HeadObject_success(s)
Versioning_HeadObject_without_versionId(s)
Versioning_HeadObject_delete_marker(s) Versioning_HeadObject_delete_marker(s)
// GetObject action // GetObject action
Versioning_GetObject_invalid_versionId(s) Versioning_GetObject_invalid_versionId(s)
@@ -572,6 +574,9 @@ func TestVersioning(s *S3Conf) {
Versioning_GetObject_delete_marker_without_versionId(s) Versioning_GetObject_delete_marker_without_versionId(s)
Versioning_GetObject_delete_marker(s) Versioning_GetObject_delete_marker(s)
Versioning_GetObject_null_versionId_obj(s) Versioning_GetObject_null_versionId_obj(s)
// GetObjectAttributes action
Versioning_GetObjectAttributes_object_version(s)
Versioning_GetObjectAttributes_delete_marker(s)
// DeleteObject(s) actions // DeleteObject(s) actions
Versioning_DeleteObject_delete_object_version(s) Versioning_DeleteObject_delete_object_version(s)
Versioning_DeleteObject_non_existing_object(s) Versioning_DeleteObject_non_existing_object(s)
@@ -720,6 +725,7 @@ func GetIntTests() IntTests {
"HeadObject_success": HeadObject_success, "HeadObject_success": HeadObject_success,
"GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket,
"GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object,
"GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs,
"GetObjectAttributes_existing_object": GetObjectAttributes_existing_object, "GetObjectAttributes_existing_object": GetObjectAttributes_existing_object,
"GetObject_non_existing_key": GetObject_non_existing_key, "GetObject_non_existing_key": GetObject_non_existing_key,
"GetObject_directory_object_noslash": GetObject_directory_object_noslash, "GetObject_directory_object_noslash": GetObject_directory_object_noslash,
@@ -960,12 +966,15 @@ func GetIntTests() IntTests {
"Versioning_CopyObject_special_chars": Versioning_CopyObject_special_chars, "Versioning_CopyObject_special_chars": Versioning_CopyObject_special_chars,
"Versioning_HeadObject_invalid_versionId": Versioning_HeadObject_invalid_versionId, "Versioning_HeadObject_invalid_versionId": Versioning_HeadObject_invalid_versionId,
"Versioning_HeadObject_success": Versioning_HeadObject_success, "Versioning_HeadObject_success": Versioning_HeadObject_success,
"Versioning_HeadObject_without_versionId": Versioning_HeadObject_without_versionId,
"Versioning_HeadObject_delete_marker": Versioning_HeadObject_delete_marker, "Versioning_HeadObject_delete_marker": Versioning_HeadObject_delete_marker,
"Versioning_GetObject_invalid_versionId": Versioning_GetObject_invalid_versionId, "Versioning_GetObject_invalid_versionId": Versioning_GetObject_invalid_versionId,
"Versioning_GetObject_success": Versioning_GetObject_success, "Versioning_GetObject_success": Versioning_GetObject_success,
"Versioning_GetObject_delete_marker_without_versionId": Versioning_GetObject_delete_marker_without_versionId, "Versioning_GetObject_delete_marker_without_versionId": Versioning_GetObject_delete_marker_without_versionId,
"Versioning_GetObject_delete_marker": Versioning_GetObject_delete_marker, "Versioning_GetObject_delete_marker": Versioning_GetObject_delete_marker,
"Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj, "Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj,
"Versioning_GetObjectAttributes_object_version": Versioning_GetObjectAttributes_object_version,
"Versioning_GetObjectAttributes_delete_marker": Versioning_GetObjectAttributes_delete_marker,
"Versioning_DeleteObject_delete_object_version": Versioning_DeleteObject_delete_object_version, "Versioning_DeleteObject_delete_object_version": Versioning_DeleteObject_delete_object_version,
"Versioning_DeleteObject_non_existing_object": Versioning_DeleteObject_non_existing_object, "Versioning_DeleteObject_non_existing_object": Versioning_DeleteObject_non_existing_object,
"Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker, "Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker,

View File

@@ -3375,9 +3375,11 @@ func GetObjectAttributes_non_existing_object(s *S3Conf) error {
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.GetObjectAttributes(ctx, &s3.GetObjectAttributesInput{ _, err := s3client.GetObjectAttributes(ctx, &s3.GetObjectAttributesInput{
Bucket: &bucket, Bucket: &bucket,
Key: getPtr("my-obj"), Key: getPtr("my-obj"),
ObjectAttributes: []types.ObjectAttributes{}, ObjectAttributes: []types.ObjectAttributes{
types.ObjectAttributesEtag,
},
}) })
cancel() cancel()
if err := checkSdkApiErr(err, "NoSuchKey"); err != nil { if err := checkSdkApiErr(err, "NoSuchKey"); err != nil {
@@ -3388,6 +3390,33 @@ func GetObjectAttributes_non_existing_object(s *S3Conf) error {
}) })
} }
func GetObjectAttributes_invalid_attrs(s *S3Conf) error {
testName := "GetObjectAttributes_invalid_attrs"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-obj"
_, err := putObjects(s3client, []string{obj}, bucket)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObjectAttributes(ctx, &s3.GetObjectAttributesInput{
Bucket: &bucket,
Key: &obj,
ObjectAttributes: []types.ObjectAttributes{
types.ObjectAttributesEtag,
types.ObjectAttributes("Invalid_argument"),
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidObjectAttributes)); err != nil {
return err
}
return nil
})
}
func GetObjectAttributes_existing_object(s *S3Conf) error { func GetObjectAttributes_existing_object(s *S3Conf) error {
testName := "GetObjectAttributes_existing_object" testName := "GetObjectAttributes_existing_object"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -3438,11 +3467,14 @@ func GetObjectAttributes_existing_object(s *S3Conf) error {
return fmt.Errorf("expected object size to be %v, instead got %v", data_len, *out.ObjectSize) return fmt.Errorf("expected object size to be %v, instead got %v", data_len, *out.ObjectSize)
} }
if out.Checksum != nil { if out.Checksum != nil {
return fmt.Errorf("expected checksum do be nil, instead got %v", *out.Checksum) return fmt.Errorf("expected checksum to be nil, instead got %v", *out.Checksum)
} }
if out.StorageClass != types.StorageClassStandard { if out.StorageClass != types.StorageClassStandard {
return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass)
} }
if out.LastModified == nil {
return fmt.Errorf("expected non nil LastModified")
}
return nil return nil
}) })
@@ -10848,19 +10880,6 @@ func PutObject_name_too_long(s *S3Conf) error {
}) })
} }
// Versioning tests
func PutBucketVersioning_non_existing_bucket(s *S3Conf) error {
testName := "PutBucketVersioning_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketVersioningStatus(s3client, getBucketName(), types.BucketVersioningStatusSuspended)
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)); err != nil {
return err
}
return nil
})
}
func HeadObject_name_too_long(s *S3Conf) error { func HeadObject_name_too_long(s *S3Conf) error {
testName := "HeadObject_name_too_long" testName := "HeadObject_name_too_long"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -10878,6 +10897,35 @@ func HeadObject_name_too_long(s *S3Conf) error {
}) })
} }
func DeleteObject_name_too_long(s *S3Conf) error {
testName := "DeleteObject_name_too_long"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: getPtr(genRandString(300)),
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrKeyTooLong)); err != nil {
return err
}
return nil
})
}
// Versioning tests
func PutBucketVersioning_non_existing_bucket(s *S3Conf) error {
testName := "PutBucketVersioning_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketVersioningStatus(s3client, getBucketName(), types.BucketVersioningStatusSuspended)
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)); err != nil {
return err
}
return nil
})
}
func PutBucketVersioning_invalid_status(s *S3Conf) error { func PutBucketVersioning_invalid_status(s *S3Conf) error {
testName := "PutBucketVersioning_invalid_status" testName := "PutBucketVersioning_invalid_status"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -11405,22 +11453,6 @@ func Versioning_HeadObject_invalid_versionId(s *S3Conf) error {
}) })
} }
func DeleteObject_name_too_long(s *S3Conf) error {
testName := "DeleteObject_name_too_long"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: getPtr(genRandString(300)),
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrKeyTooLong)); err != nil {
return err
}
return nil
})
}
func Versioning_HeadObject_success(s *S3Conf) error { func Versioning_HeadObject_success(s *S3Conf) error {
testName := "Versioning_HeadObject_success" testName := "Versioning_HeadObject_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -11456,6 +11488,35 @@ func Versioning_HeadObject_success(s *S3Conf) error {
}, withVersioning(types.BucketVersioningStatusEnabled)) }, withVersioning(types.BucketVersioningStatusEnabled))
} }
func Versioning_HeadObject_without_versionId(s *S3Conf) error {
testName := "Versioning_HeadObject_without_versionId"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-obj"
versions, err := createObjVersions(s3client, bucket, obj, 3)
if err != nil {
return err
}
lastVersion := versions[0]
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
if getString(res.VersionId) != *lastVersion.VersionId {
return fmt.Errorf("expected versionId to be %v, instead got %v", *lastVersion.VersionId, getString(res.VersionId))
}
return nil
}, withVersioning(types.BucketVersioningStatusEnabled))
}
func Versioning_HeadObject_delete_marker(s *S3Conf) error { func Versioning_HeadObject_delete_marker(s *S3Conf) error {
testName := "Versioning_HeadObject_delete_marker" testName := "Versioning_HeadObject_delete_marker"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -11727,6 +11788,97 @@ func Versioning_GetObject_null_versionId_obj(s *S3Conf) error {
}) })
} }
func Versioning_GetObjectAttributes_object_version(s *S3Conf) error {
testName := "Versioning_GetObjectAttributes_object_version"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-obj"
versions, err := createObjVersions(s3client, bucket, obj, 1)
if err != nil {
return err
}
version := versions[0]
getObjAttrs := func(versionId *string) (*s3.GetObjectAttributesOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetObjectAttributes(ctx, &s3.GetObjectAttributesInput{
Bucket: &bucket,
Key: &obj,
VersionId: versionId,
ObjectAttributes: []types.ObjectAttributes{
types.ObjectAttributesEtag,
},
})
cancel()
return res, err
}
// By specifying the versionId
res, err := getObjAttrs(version.VersionId)
if err != nil {
return err
}
if getString(res.ETag) != *version.ETag {
return fmt.Errorf("expected the uploaded object ETag to be %v, instead got %v", *version.ETag, getString(res.ETag))
}
if getString(res.VersionId) != *version.VersionId {
return fmt.Errorf("expected the uploaded versionId to be %v, instead got %v", *version.VersionId, getString(res.VersionId))
}
// Without versionId
res, err = getObjAttrs(nil)
if err != nil {
return err
}
if getString(res.ETag) != *version.ETag {
return fmt.Errorf("expected the uploaded object ETag to be %v, instead got %v", *version.ETag, getString(res.ETag))
}
if getString(res.VersionId) != *version.VersionId {
return fmt.Errorf("expected the uploaded object versionId to be %v, instead got %v", *version.VersionId, getString(res.VersionId))
}
return nil
}, withVersioning(types.BucketVersioningStatusEnabled))
}
func Versioning_GetObjectAttributes_delete_marker(s *S3Conf) error {
testName := "Versioning_GetObjectAttributes_delete_marker"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-obj"
_, err := createObjVersions(s3client, bucket, obj, 1)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObjectAttributes(ctx, &s3.GetObjectAttributesInput{
Bucket: &bucket,
Key: &obj,
VersionId: res.VersionId,
ObjectAttributes: []types.ObjectAttributes{
types.ObjectAttributesEtag,
},
})
cancel()
if err := checkSdkApiErr(err, "NoSuchKey"); err != nil {
return err
}
return nil
}, withVersioning(types.BucketVersioningStatusEnabled))
}
func Versioning_DeleteObject_delete_object_version(s *S3Conf) error { func Versioning_DeleteObject_delete_object_version(s *S3Conf) error {
testName := "Versioning_DeleteObject_delete_object_version" testName := "Versioning_DeleteObject_delete_object_version"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {