diff --git a/weed/s3api/policy_engine/types.go b/weed/s3api/policy_engine/types.go index 766294ee3..f6d35e6c6 100644 --- a/weed/s3api/policy_engine/types.go +++ b/weed/s3api/policy_engine/types.go @@ -100,6 +100,11 @@ func NewStringOrStringSlicePtr(values ...string) *StringOrStringSlice { return &StringOrStringSlice{values: values} } +// CloneStringOrStringSlice returns a copy with its own backing slice. +func CloneStringOrStringSlice(value StringOrStringSlice) StringOrStringSlice { + return StringOrStringSlice{values: append([]string(nil), value.values...)} +} + // PolicyConditions represents policy conditions with proper typing type PolicyConditions map[string]map[string]StringOrStringSlice diff --git a/weed/s3api/policy_engine/types_test.go b/weed/s3api/policy_engine/types_test.go new file mode 100644 index 000000000..576828ea7 --- /dev/null +++ b/weed/s3api/policy_engine/types_test.go @@ -0,0 +1,17 @@ +package policy_engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCloneStringOrStringSliceCopiesBackingSlice(t *testing.T) { + original := NewStringOrStringSlice("s3:GetObject", "s3:PutObject") + + cloned := CloneStringOrStringSlice(original) + cloned.values[0] = "s3:DeleteObject" + + assert.Equal(t, []string{"s3:GetObject", "s3:PutObject"}, original.Strings()) + assert.Equal(t, []string{"s3:DeleteObject", "s3:PutObject"}, cloned.Strings()) +} diff --git a/weed/s3api/s3api_bucket_config.go b/weed/s3api/s3api_bucket_config.go index 185c1adb7..c9c108df5 100644 --- a/weed/s3api/s3api_bucket_config.go +++ b/weed/s3api/s3api_bucket_config.go @@ -425,44 +425,62 @@ func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketC return errCode } + nextConfig := cloneBucketConfig(config) + if nextConfig == nil { + glog.Errorf("updateBucketConfig: failed to clone config for bucket %s", bucket) + return s3err.ErrInternalError + } + // Apply update function - if err := updateFn(config); err != nil { + if err := updateFn(nextConfig); err != nil { glog.Errorf("updateBucketConfig: update function failed for bucket %s: %v", bucket, err) return s3err.ErrInternalError } // Prepare extended attributes - if config.Entry.Extended == nil { - config.Entry.Extended = make(map[string][]byte) + if nextConfig.Entry == nil { + glog.Errorf("updateBucketConfig: missing bucket entry for %s", bucket) + return s3err.ErrInternalError + } + if nextConfig.Entry.Extended == nil { + nextConfig.Entry.Extended = make(map[string][]byte) } // Update extended attributes - if config.Versioning != "" { - config.Entry.Extended[s3_constants.ExtVersioningKey] = []byte(config.Versioning) - } - if config.Ownership != "" { - config.Entry.Extended[s3_constants.ExtOwnershipKey] = []byte(config.Ownership) - } - if config.ACL != nil { - config.Entry.Extended[s3_constants.ExtAmzAclKey] = config.ACL - } - if config.Owner != "" { - config.Entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(config.Owner) + if nextConfig.Versioning != "" { + nextConfig.Entry.Extended[s3_constants.ExtVersioningKey] = []byte(nextConfig.Versioning) + } else { + delete(nextConfig.Entry.Extended, s3_constants.ExtVersioningKey) + } + if nextConfig.Ownership != "" { + nextConfig.Entry.Extended[s3_constants.ExtOwnershipKey] = []byte(nextConfig.Ownership) + } else { + delete(nextConfig.Entry.Extended, s3_constants.ExtOwnershipKey) + } + if nextConfig.ACL != nil { + nextConfig.Entry.Extended[s3_constants.ExtAmzAclKey] = nextConfig.ACL + } else { + delete(nextConfig.Entry.Extended, s3_constants.ExtAmzAclKey) + } + if nextConfig.Owner != "" { + nextConfig.Entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(nextConfig.Owner) + } else { + delete(nextConfig.Entry.Extended, s3_constants.ExtAmzOwnerKey) } // Update Object Lock configuration - if config.ObjectLockConfig != nil { - glog.V(3).Infof("updateBucketConfig: storing Object Lock config for bucket %s: %+v", bucket, config.ObjectLockConfig) - if err := StoreObjectLockConfigurationInExtended(config.Entry, config.ObjectLockConfig); err != nil { + if nextConfig.ObjectLockConfig != nil { + glog.V(3).Infof("updateBucketConfig: storing Object Lock config for bucket %s: %+v", bucket, nextConfig.ObjectLockConfig) + if err := StoreObjectLockConfigurationInExtended(nextConfig.Entry, nextConfig.ObjectLockConfig); err != nil { glog.Errorf("updateBucketConfig: failed to store Object Lock configuration for bucket %s: %v", bucket, err) return s3err.ErrInternalError } glog.V(3).Infof("updateBucketConfig: stored Object Lock config in extended attributes for bucket %s, key=%s, value=%s", - bucket, s3_constants.ExtObjectLockEnabledKey, string(config.Entry.Extended[s3_constants.ExtObjectLockEnabledKey])) + bucket, s3_constants.ExtObjectLockEnabledKey, string(nextConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey])) } // Save to filer glog.V(3).Infof("updateBucketConfig: saving entry to filer for bucket %s", bucket) - err := s3a.updateEntry(s3a.bucketRoot(bucket), config.Entry) + err := s3a.updateEntry(s3a.bucketRoot(bucket), nextConfig.Entry) if err != nil { glog.Errorf("updateBucketConfig: failed to update bucket entry for %s: %v", bucket, err) return s3err.ErrInternalError @@ -470,11 +488,140 @@ func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketC glog.V(3).Infof("updateBucketConfig: saved entry to filer for bucket %s", bucket) // Update cache - s3a.bucketConfigCache.Set(bucket, config) + s3a.bucketConfigCache.Set(bucket, nextConfig) return s3err.ErrNone } +func cloneBucketConfig(config *BucketConfig) *BucketConfig { + if config == nil { + return nil + } + + cloned := *config + if config.ACL != nil { + cloned.ACL = append([]byte(nil), config.ACL...) + } + if config.Entry != nil { + cloned.Entry = proto.Clone(config.Entry).(*filer_pb.Entry) + } + if config.CORS != nil { + cloned.CORS = cloneCORSConfiguration(config.CORS) + } + if config.ObjectLockConfig != nil { + cloned.ObjectLockConfig = cloneObjectLockConfiguration(config.ObjectLockConfig) + } + if config.BucketPolicy != nil { + cloned.BucketPolicy = cloneBucketPolicy(config.BucketPolicy) + } + + return &cloned +} + +func cloneCORSConfiguration(config *cors.CORSConfiguration) *cors.CORSConfiguration { + if config == nil { + return nil + } + + cloned := &cors.CORSConfiguration{ + CORSRules: make([]cors.CORSRule, len(config.CORSRules)), + } + for i, rule := range config.CORSRules { + cloned.CORSRules[i] = cors.CORSRule{ + AllowedHeaders: append([]string(nil), rule.AllowedHeaders...), + AllowedMethods: append([]string(nil), rule.AllowedMethods...), + AllowedOrigins: append([]string(nil), rule.AllowedOrigins...), + ExposeHeaders: append([]string(nil), rule.ExposeHeaders...), + ID: rule.ID, + } + if rule.MaxAgeSeconds != nil { + maxAge := *rule.MaxAgeSeconds + cloned.CORSRules[i].MaxAgeSeconds = &maxAge + } + } + + return cloned +} + +func cloneObjectLockConfiguration(config *ObjectLockConfiguration) *ObjectLockConfiguration { + if config == nil { + return nil + } + + cloned := &ObjectLockConfiguration{ + XMLNS: config.XMLNS, + XMLName: config.XMLName, + ObjectLockEnabled: config.ObjectLockEnabled, + } + if config.Rule != nil { + cloned.Rule = &ObjectLockRule{ + XMLName: config.Rule.XMLName, + } + if config.Rule.DefaultRetention != nil { + cloned.Rule.DefaultRetention = &DefaultRetention{ + XMLName: config.Rule.DefaultRetention.XMLName, + Mode: config.Rule.DefaultRetention.Mode, + Days: config.Rule.DefaultRetention.Days, + Years: config.Rule.DefaultRetention.Years, + DaysSet: config.Rule.DefaultRetention.DaysSet, + YearsSet: config.Rule.DefaultRetention.YearsSet, + } + } + } + + return cloned +} + +func cloneBucketPolicy(policyDoc *policy_engine.PolicyDocument) *policy_engine.PolicyDocument { + if policyDoc == nil { + return nil + } + + cloned := &policy_engine.PolicyDocument{ + Version: policyDoc.Version, + Statement: make([]policy_engine.PolicyStatement, len(policyDoc.Statement)), + } + for i, statement := range policyDoc.Statement { + cloned.Statement[i] = clonePolicyStatement(statement) + } + + return cloned +} + +func clonePolicyStatement(statement policy_engine.PolicyStatement) policy_engine.PolicyStatement { + cloned := policy_engine.PolicyStatement{ + Sid: statement.Sid, + Effect: statement.Effect, + Action: cloneStringOrStringSlice(statement.Action), + NotResource: cloneStringOrStringSlicePtr(statement.NotResource), + Principal: cloneStringOrStringSlicePtr(statement.Principal), + Resource: cloneStringOrStringSlicePtr(statement.Resource), + } + if statement.Condition != nil { + cloned.Condition = make(policy_engine.PolicyConditions, len(statement.Condition)) + for operator, operands := range statement.Condition { + copiedOperands := make(map[string]policy_engine.StringOrStringSlice, len(operands)) + for key, value := range operands { + copiedOperands[key] = cloneStringOrStringSlice(value) + } + cloned.Condition[operator] = copiedOperands + } + } + return cloned +} + +func cloneStringOrStringSlice(value policy_engine.StringOrStringSlice) policy_engine.StringOrStringSlice { + return policy_engine.CloneStringOrStringSlice(value) +} + +func cloneStringOrStringSlicePtr(value *policy_engine.StringOrStringSlice) *policy_engine.StringOrStringSlice { + if value == nil { + return nil + } + cloned := policy_engine.CloneStringOrStringSlice(*value) + return &cloned +} + // isVersioningEnabled checks if versioning is enabled for a bucket (with caching) func (s3a *S3ApiServer) isVersioningEnabled(bucket string) (bool, error) { config, errCode := s3a.getBucketConfig(bucket) diff --git a/weed/s3api/s3api_bucket_config_update_test.go b/weed/s3api/s3api_bucket_config_update_test.go new file mode 100644 index 000000000..048c0f506 --- /dev/null +++ b/weed/s3api/s3api_bucket_config_update_test.go @@ -0,0 +1,47 @@ +package s3api + +import ( + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateBucketConfigDoesNotMutateCacheOnPersistFailure(t *testing.T) { + const bucket = "cleanup-test-net" + + s3a := newTestS3ApiServerWithMemoryIAM(t, nil) + s3a.option = &S3ApiServerOption{ + BucketsPath: "/buckets", + } + s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) + s3a.bucketConfigCache.Set(bucket, &BucketConfig{ + Name: bucket, + Versioning: "", + Entry: &filer_pb.Entry{ + Name: bucket, + IsDirectory: true, + Extended: map[string][]byte{}, + }, + }) + + // This test server only has in-memory IAM state and no filer connection, so + // updateBucketConfig is expected to fail during the persist step with an + // internal error. The assertion below verifies that the cached config stays + // unchanged when that write path fails. + errCode := s3a.updateBucketConfig(bucket, func(config *BucketConfig) error { + config.Versioning = s3_constants.VersioningEnabled + return nil + }) + + require.Equal(t, s3err.ErrInternalError, errCode) + + config, found := s3a.bucketConfigCache.Get(bucket) + require.True(t, found) + assert.Empty(t, config.Versioning) + assert.NotContains(t, config.Entry.Extended, s3_constants.ExtVersioningKey) +} diff --git a/weed/s3api/s3api_bucket_handlers.go b/weed/s3api/s3api_bucket_handlers.go index d3ea00904..a88ef563e 100644 --- a/weed/s3api/s3api_bucket_handlers.go +++ b/weed/s3api/s3api_bucket_handlers.go @@ -7,6 +7,7 @@ import ( "encoding/xml" "errors" "fmt" + "io" "math" "net/http" "sort" @@ -815,6 +816,14 @@ func (s3a *S3ApiServer) GetBucketLifecycleConfigurationHandler(w http.ResponseWr s3err.WriteErrorResponse(w, r, err) return } + if lifecycleXML, transitionMinimumObjectSize, found, errCode := s3a.getStoredBucketLifecycleConfiguration(bucket); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } else if found { + w.Header().Set(bucketLifecycleTransitionMinimumObjectSizeHeader, transitionMinimumObjectSize) + writeSuccessResponseXMLBytes(w, r, lifecycleXML) + return + } // ReadFilerConfFromFilers provides multi-filer failover fc, err := filer.ReadFilerConfFromFilers(s3a.option.Filers, s3a.option.GrpcDialOption, nil) if err != nil { @@ -855,6 +864,9 @@ func (s3a *S3ApiServer) GetBucketLifecycleConfigurationHandler(w http.ResponseWr }) } + if len(response.Rules) > 0 { + w.Header().Set(bucketLifecycleTransitionMinimumObjectSizeHeader, defaultLifecycleTransitionMinimumObjectSize) + } writeSuccessResponseXML(w, r, response) } @@ -892,8 +904,21 @@ func (s3a *S3ApiServer) PutBucketLifecycleConfigurationHandler(w http.ResponseWr return } + r.Body = http.MaxBytesReader(w, r.Body, maxBucketLifecycleConfigurationSize) + lifecycleXML, err := io.ReadAll(r.Body) + if err != nil { + glog.Warningf("PutBucketLifecycleConfigurationHandler read body: %s", err) + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge) + return + } + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + lifeCycleConfig := Lifecycle{} - if err := xmlDecoder(r.Body, &lifeCycleConfig, r.ContentLength); err != nil { + if err := xmlDecoder(bytes.NewReader(lifecycleXML), &lifeCycleConfig, int64(len(lifecycleXML))); err != nil { glog.Warningf("PutBucketLifecycleConfigurationHandler xml decode: %s", err) s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) return @@ -988,6 +1013,11 @@ func (s3a *S3ApiServer) PutBucketLifecycleConfigurationHandler(w http.ResponseWr } } + if errCode := s3a.storeBucketLifecycleConfiguration(bucket, lifecycleXML, r.Header.Get(bucketLifecycleTransitionMinimumObjectSizeHeader)); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + writeSuccessResponseEmpty(w, r) } @@ -1038,6 +1068,11 @@ func (s3a *S3ApiServer) DeleteBucketLifecycleHandler(w http.ResponseWriter, r *h } } + if errCode := s3a.clearStoredBucketLifecycleConfiguration(bucket); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + s3err.WriteEmptyResponse(w, r, http.StatusNoContent) } diff --git a/weed/s3api/s3api_bucket_lifecycle_config.go b/weed/s3api/s3api_bucket_lifecycle_config.go new file mode 100644 index 000000000..d1601db60 --- /dev/null +++ b/weed/s3api/s3api_bucket_lifecycle_config.go @@ -0,0 +1,75 @@ +package s3api + +import ( + "fmt" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +const ( + bucketLifecycleConfigurationXMLKey = "s3-bucket-lifecycle-configuration-xml" + bucketLifecycleTransitionMinimumObjectSizeKey = "s3-bucket-lifecycle-transition-default-minimum-object-size" + bucketLifecycleTransitionMinimumObjectSizeHeader = "X-Amz-Transition-Default-Minimum-Object-Size" + defaultLifecycleTransitionMinimumObjectSize = "all_storage_classes_128K" + maxBucketLifecycleConfigurationSize = 1 << 20 +) + +func normalizeBucketLifecycleTransitionMinimumObjectSize(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return defaultLifecycleTransitionMinimumObjectSize + } + return value +} + +func (s3a *S3ApiServer) getStoredBucketLifecycleConfiguration(bucket string) ([]byte, string, bool, s3err.ErrorCode) { + config, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + return nil, "", false, errCode + } + if config.Entry == nil || config.Entry.Extended == nil { + return nil, "", false, s3err.ErrNone + } + + lifecycleXML, found := config.Entry.Extended[bucketLifecycleConfigurationXMLKey] + if !found || len(lifecycleXML) == 0 { + return nil, "", false, s3err.ErrNone + } + + transitionMinimumObjectSize := normalizeBucketLifecycleTransitionMinimumObjectSize( + string(config.Entry.Extended[bucketLifecycleTransitionMinimumObjectSizeKey]), + ) + + return append([]byte(nil), lifecycleXML...), transitionMinimumObjectSize, true, s3err.ErrNone +} + +func (s3a *S3ApiServer) storeBucketLifecycleConfiguration(bucket string, lifecycleXML []byte, transitionMinimumObjectSize string) s3err.ErrorCode { + return s3a.updateBucketConfig(bucket, func(config *BucketConfig) error { + if config.Entry == nil { + return fmt.Errorf("bucket %s is missing its filer entry", bucket) + } + if config.Entry.Extended == nil { + config.Entry.Extended = make(map[string][]byte) + } + + config.Entry.Extended[bucketLifecycleConfigurationXMLKey] = append([]byte(nil), lifecycleXML...) + config.Entry.Extended[bucketLifecycleTransitionMinimumObjectSizeKey] = []byte( + normalizeBucketLifecycleTransitionMinimumObjectSize(transitionMinimumObjectSize), + ) + + return nil + }) +} + +func (s3a *S3ApiServer) clearStoredBucketLifecycleConfiguration(bucket string) s3err.ErrorCode { + return s3a.updateBucketConfig(bucket, func(config *BucketConfig) error { + if config.Entry == nil { + return fmt.Errorf("bucket %s is missing its filer entry", bucket) + } + + delete(config.Entry.Extended, bucketLifecycleConfigurationXMLKey) + delete(config.Entry.Extended, bucketLifecycleTransitionMinimumObjectSizeKey) + return nil + }) +} diff --git a/weed/s3api/s3api_bucket_lifecycle_response_test.go b/weed/s3api/s3api_bucket_lifecycle_response_test.go new file mode 100644 index 000000000..df7e4d397 --- /dev/null +++ b/weed/s3api/s3api_bucket_lifecycle_response_test.go @@ -0,0 +1,126 @@ +package s3api + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetBucketLifecycleConfigurationHandlerUsesStoredLifecycleConfig(t *testing.T) { + const bucket = "cleanup-test-net" + const lifecycleXML = `rotation1Enabled` + + s3a := newTestS3ApiServerWithMemoryIAM(t, nil) + s3a.option = &S3ApiServerOption{BucketsPath: "/buckets"} + s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) + s3a.bucketConfigCache.Set(bucket, &BucketConfig{ + Name: bucket, + Entry: &filer_pb.Entry{ + Extended: map[string][]byte{ + bucketLifecycleConfigurationXMLKey: []byte(lifecycleXML), + bucketLifecycleTransitionMinimumObjectSizeKey: []byte("varies_by_storage_class"), + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/"+bucket+"?lifecycle", nil) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket}) + resp := httptest.NewRecorder() + + s3a.GetBucketLifecycleConfigurationHandler(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "varies_by_storage_class", resp.Header().Get(bucketLifecycleTransitionMinimumObjectSizeHeader)) + assert.Equal(t, lifecycleXML, resp.Body.String()) +} + +func TestGetBucketLifecycleConfigurationHandlerDefaultsTransitionMinimumObjectSize(t *testing.T) { + const bucket = "cleanup-test-net" + const lifecycleXML = `rotation1Enabled` + + s3a := newTestS3ApiServerWithMemoryIAM(t, nil) + s3a.option = &S3ApiServerOption{BucketsPath: "/buckets"} + s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) + s3a.bucketConfigCache.Set(bucket, &BucketConfig{ + Name: bucket, + Entry: &filer_pb.Entry{ + Extended: map[string][]byte{ + bucketLifecycleConfigurationXMLKey: []byte(lifecycleXML), + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/"+bucket+"?lifecycle", nil) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket}) + resp := httptest.NewRecorder() + + s3a.GetBucketLifecycleConfigurationHandler(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, defaultLifecycleTransitionMinimumObjectSize, resp.Header().Get(bucketLifecycleTransitionMinimumObjectSizeHeader)) + assert.Equal(t, lifecycleXML, resp.Body.String()) +} + +func TestPutBucketLifecycleConfigurationHandlerRejectsOversizedBody(t *testing.T) { + const bucket = "cleanup-test-net" + + s3a := newTestS3ApiServerWithMemoryIAM(t, nil) + s3a.option = &S3ApiServerOption{BucketsPath: "/buckets"} + s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) + s3a.bucketConfigCache.Set(bucket, &BucketConfig{ + Name: bucket, + Entry: &filer_pb.Entry{}, + }) + + req := httptest.NewRequest(http.MethodPut, "/"+bucket+"?lifecycle", strings.NewReader(strings.Repeat("x", maxBucketLifecycleConfigurationSize+1))) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket}) + resp := httptest.NewRecorder() + + s3a.PutBucketLifecycleConfigurationHandler(resp, req) + + require.Equal(t, s3err.GetAPIError(s3err.ErrEntityTooLarge).HTTPStatusCode, resp.Code) + assert.Contains(t, resp.Body.String(), "EntityTooLarge") +} + +func TestPutBucketLifecycleConfigurationHandlerMapsReadErrorsToInvalidRequest(t *testing.T) { + const bucket = "cleanup-test-net" + + s3a := newTestS3ApiServerWithMemoryIAM(t, nil) + s3a.option = &S3ApiServerOption{BucketsPath: "/buckets"} + s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) + s3a.bucketConfigCache.Set(bucket, &BucketConfig{ + Name: bucket, + Entry: &filer_pb.Entry{}, + }) + + req := httptest.NewRequest(http.MethodPut, "/"+bucket+"?lifecycle", nil) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket}) + req.Body = failingReadCloser{err: errors.New("read failed")} + resp := httptest.NewRecorder() + + s3a.PutBucketLifecycleConfigurationHandler(resp, req) + + require.Equal(t, s3err.GetAPIError(s3err.ErrInvalidRequest).HTTPStatusCode, resp.Code) + assert.Contains(t, resp.Body.String(), "InvalidRequest") +} + +type failingReadCloser struct { + err error +} + +func (f failingReadCloser) Read(_ []byte) (int, error) { + return 0, f.err +} + +func (f failingReadCloser) Close() error { + return nil +} diff --git a/weed/s3api/s3api_handlers.go b/weed/s3api/s3api_handlers.go index 00c8834b0..d74741944 100644 --- a/weed/s3api/s3api_handlers.go +++ b/weed/s3api/s3api_handlers.go @@ -100,6 +100,11 @@ func writeSuccessResponseXML(w http.ResponseWriter, r *http.Request, response in s3err.PostLog(r, http.StatusOK, s3err.ErrNone) } +func writeSuccessResponseXMLBytes(w http.ResponseWriter, r *http.Request, response []byte) { + s3err.WriteResponse(w, r, http.StatusOK, response, s3err.MimeXML) + s3err.PostLog(r, http.StatusOK, s3err.ErrNone) +} + func writeSuccessResponseEmpty(w http.ResponseWriter, r *http.Request) { s3err.WriteEmptyResponse(w, r, http.StatusOK) }