diff options
Diffstat (limited to 'weed/s3api')
| -rw-r--r-- | weed/s3api/s3_constants/extend_key.go | 21 | ||||
| -rw-r--r-- | weed/s3api/s3api_object_handlers_delete.go | 71 | ||||
| -rw-r--r-- | weed/s3api/s3api_object_handlers_put.go | 7 | ||||
| -rw-r--r-- | weed/s3api/s3api_object_handlers_retention.go | 356 | ||||
| -rw-r--r-- | weed/s3api/s3api_object_handlers_skip.go | 26 | ||||
| -rw-r--r-- | weed/s3api/s3api_object_retention.go | 598 | ||||
| -rw-r--r-- | weed/s3api/s3api_object_retention_test.go | 726 | ||||
| -rw-r--r-- | weed/s3api/s3api_server.go | 10 | ||||
| -rw-r--r-- | weed/s3api/s3err/s3api_errors.go | 12 |
9 files changed, 1786 insertions, 41 deletions
diff --git a/weed/s3api/s3_constants/extend_key.go b/weed/s3api/s3_constants/extend_key.go index 9806d899e..79fcbb239 100644 --- a/weed/s3api/s3_constants/extend_key.go +++ b/weed/s3api/s3_constants/extend_key.go @@ -11,4 +11,25 @@ const ( ExtETagKey = "Seaweed-X-Amz-ETag" ExtLatestVersionIdKey = "Seaweed-X-Amz-Latest-Version-Id" ExtLatestVersionFileNameKey = "Seaweed-X-Amz-Latest-Version-File-Name" + + // Object Retention and Legal Hold + ExtObjectLockModeKey = "Seaweed-X-Amz-Object-Lock-Mode" + ExtRetentionUntilDateKey = "Seaweed-X-Amz-Retention-Until-Date" + ExtLegalHoldKey = "Seaweed-X-Amz-Legal-Hold" + ExtObjectLockEnabledKey = "Seaweed-X-Amz-Object-Lock-Enabled" + ExtObjectLockConfigKey = "Seaweed-X-Amz-Object-Lock-Config" +) + +// Object Lock and Retention Constants +const ( + // Retention modes + RetentionModeGovernance = "GOVERNANCE" + RetentionModeCompliance = "COMPLIANCE" + + // Legal hold status + LegalHoldOn = "ON" + LegalHoldOff = "OFF" + + // Object lock enabled status + ObjectLockEnabled = "Enabled" ) diff --git a/weed/s3api/s3api_object_handlers_delete.go b/weed/s3api/s3api_object_handlers_delete.go index d7457fabe..35c842e6c 100644 --- a/weed/s3api/s3api_object_handlers_delete.go +++ b/weed/s3api/s3api_object_handlers_delete.go @@ -49,6 +49,16 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone) } + // Check object lock permissions before deletion (only for versioned buckets) + if versioningEnabled { + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + if err := s3a.checkObjectLockPermissions(bucket, object, versionId, bypassGovernance); err != nil { + glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + } + if versioningEnabled { // Handle versioned delete if versionId != "" { @@ -117,9 +127,10 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } -// / ObjectIdentifier carries key name for the object to delete. +// ObjectIdentifier represents an object to be deleted with its key name and optional version ID. type ObjectIdentifier struct { - ObjectName string `xml:"Key"` + Key string `xml:"Key"` + VersionId string `xml:"VersionId,omitempty"` } // DeleteObjectsRequest - xml carrying the object key names which needs to be deleted. @@ -132,9 +143,10 @@ type DeleteObjectsRequest struct { // DeleteError structure. type DeleteError struct { - Code string - Message string - Key string + Code string `xml:"Code"` + Message string `xml:"Message"` + Key string `xml:"Key"` + VersionId string `xml:"VersionId,omitempty"` } // DeleteObjectsResponse container for multiple object deletes. @@ -180,18 +192,48 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h if s3err.Logger != nil { auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone) } + + // Check for bypass governance retention header + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + + // Check if versioning is enabled for the bucket (needed for object lock checks) + versioningEnabled, err := s3a.isVersioningEnabled(bucket) + if err != nil { + if err == filer_pb.ErrNotFound { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { // delete file entries for _, object := range deleteObjects.Objects { - if object.ObjectName == "" { + if object.Key == "" { continue } - lastSeparator := strings.LastIndex(object.ObjectName, "/") - parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.ObjectName, true, false - if lastSeparator > 0 && lastSeparator+1 < len(object.ObjectName) { - entryName = object.ObjectName[lastSeparator+1:] - parentDirectoryPath = "/" + object.ObjectName[:lastSeparator] + + // Check object lock permissions before deletion (only for versioned buckets) + if versioningEnabled { + if err := s3a.checkObjectLockPermissions(bucket, object.Key, object.VersionId, bypassGovernance); err != nil { + glog.V(2).Infof("DeleteMultipleObjectsHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object.Key, object.VersionId, err) + deleteErrors = append(deleteErrors, DeleteError{ + Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, + Message: s3err.GetAPIError(s3err.ErrAccessDenied).Description, + Key: object.Key, + VersionId: object.VersionId, + }) + continue + } + } + lastSeparator := strings.LastIndex(object.Key, "/") + parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.Key, true, false + if lastSeparator > 0 && lastSeparator+1 < len(object.Key) { + entryName = object.Key[lastSeparator+1:] + parentDirectoryPath = "/" + object.Key[:lastSeparator] } parentDirectoryPath = fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, parentDirectoryPath) @@ -204,9 +246,10 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h } else { delete(directoriesWithDeletion, parentDirectoryPath) deleteErrors = append(deleteErrors, DeleteError{ - Code: "", - Message: err.Error(), - Key: object.ObjectName, + Code: "", + Message: err.Error(), + Key: object.Key, + VersionId: object.VersionId, }) } if auditLog != nil { diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 8b85a049a..371ab870f 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -85,6 +85,13 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request) glog.V(1).Infof("PutObjectHandler: bucket %s, object %s, versioningEnabled=%v", bucket, object, versioningEnabled) + // Check object lock permissions before PUT operation (only for versioned buckets) + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + if err := s3a.checkObjectLockPermissionsForPut(bucket, object, bypassGovernance, versioningEnabled); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + if versioningEnabled { // Handle versioned PUT glog.V(1).Infof("PutObjectHandler: using versioned PUT for %s/%s", bucket, object) diff --git a/weed/s3api/s3api_object_handlers_retention.go b/weed/s3api/s3api_object_handlers_retention.go new file mode 100644 index 000000000..e92e821c8 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_retention.go @@ -0,0 +1,356 @@ +package s3api + +import ( + "encoding/xml" + "errors" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" +) + +// PutObjectRetentionHandler Put object Retention +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html +func (s3a *S3ApiServer) PutObjectRetentionHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectRetentionHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectRetentionHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Check for bypass governance retention header + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + + // Parse retention configuration from request body + retention, err := parseObjectRetention(r) + if err != nil { + glog.Errorf("PutObjectRetentionHandler: failed to parse retention config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate retention configuration + if err := validateRetention(retention); err != nil { + glog.Errorf("PutObjectRetentionHandler: invalid retention config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set retention on the object + if err := s3a.setObjectRetention(bucket, object, versionId, retention, bypassGovernance); err != nil { + glog.Errorf("PutObjectRetentionHandler: failed to set retention: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) || errors.Is(err, ErrLatestVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrComplianceModeActive) || errors.Is(err, ErrGovernanceModeActive) { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectRetentionHandler: successfully set retention for %s/%s", bucket, object) +} + +// GetObjectRetentionHandler Get object Retention +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectRetention.html +func (s3a *S3ApiServer) GetObjectRetentionHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectRetentionHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectRetentionHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Get retention configuration for the object + retention, err := s3a.getObjectRetention(bucket, object, versionId) + if err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to get retention: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrNoRetentionConfiguration) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Marshal retention configuration to XML + retentionXML, err := xml.Marshal(retention) + if err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to marshal retention: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(retentionXML); err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to write retention XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectRetentionHandler: successfully retrieved retention for %s/%s", bucket, object) +} + +// PutObjectLegalHoldHandler Put object Legal Hold +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html +func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectLegalHoldHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLegalHoldHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Parse legal hold configuration from request body + legalHold, err := parseObjectLegalHold(r) + if err != nil { + glog.Errorf("PutObjectLegalHoldHandler: failed to parse legal hold config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate legal hold configuration + if err := validateLegalHold(legalHold); err != nil { + glog.Errorf("PutObjectLegalHoldHandler: invalid legal hold config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set legal hold on the object + if err := s3a.setObjectLegalHold(bucket, object, versionId, legalHold); err != nil { + glog.Errorf("PutObjectLegalHoldHandler: failed to set legal hold: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectLegalHoldHandler: successfully set legal hold for %s/%s", bucket, object) +} + +// GetObjectLegalHoldHandler Get object Legal Hold +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLegalHold.html +func (s3a *S3ApiServer) GetObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectLegalHoldHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectLegalHoldHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Get legal hold configuration for the object + legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId) + if err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to get legal hold: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrNoLegalHoldConfiguration) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLegalHold) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Marshal legal hold configuration to XML + legalHoldXML, err := xml.Marshal(legalHold) + if err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to marshal legal hold: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(legalHoldXML); err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to write legal hold XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectLegalHoldHandler: successfully retrieved legal hold for %s/%s", bucket, object) +} + +// PutObjectLockConfigurationHandler Put object Lock configuration +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html +func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { + bucket, _ := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectLockConfigurationHandler %s", bucket) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLockConfigurationHandler") { + return + } + + // Parse object lock configuration from request body + config, err := parseObjectLockConfiguration(r) + if err != nil { + glog.Errorf("PutObjectLockConfigurationHandler: failed to parse object lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate object lock configuration + if err := validateObjectLockConfiguration(config); err != nil { + glog.Errorf("PutObjectLockConfigurationHandler: invalid object lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set object lock configuration on the bucket + errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error { + if bucketConfig.Entry.Extended == nil { + bucketConfig.Entry.Extended = make(map[string][]byte) + } + + // Store the configuration as JSON in extended attributes + configXML, err := xml.Marshal(config) + if err != nil { + return err + } + + bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] = configXML + + if config.ObjectLockEnabled != "" { + bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey] = []byte(config.ObjectLockEnabled) + } + + return nil + }) + + if errCode != s3err.ErrNone { + glog.Errorf("PutObjectLockConfigurationHandler: failed to set object lock config: %v", errCode) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectLockConfigurationHandler: successfully set object lock config for %s", bucket) +} + +// GetObjectLockConfigurationHandler Get object Lock configuration +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLockConfiguration.html +func (s3a *S3ApiServer) GetObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { + bucket, _ := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectLockConfigurationHandler %s", bucket) + + // Get bucket configuration + bucketConfig, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + glog.Errorf("GetObjectLockConfigurationHandler: failed to get bucket config: %v", errCode) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Check if object lock configuration exists + if bucketConfig.Entry.Extended == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + configXML, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] + if !exists { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(configXML); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write config XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectLockConfigurationHandler: successfully retrieved object lock config for %s", bucket) +} diff --git a/weed/s3api/s3api_object_handlers_skip.go b/weed/s3api/s3api_object_handlers_skip.go index 160d02475..0b74a0ec7 100644 --- a/weed/s3api/s3api_object_handlers_skip.go +++ b/weed/s3api/s3api_object_handlers_skip.go @@ -4,7 +4,7 @@ import ( "net/http" ) -// GetObjectAclHandler Put object ACL +// GetObjectAclHandler Get object ACL // https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAcl.html func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Request) { @@ -19,27 +19,3 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } - -// PutObjectRetentionHandler Put object Retention -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html -func (s3a *S3ApiServer) PutObjectRetentionHandler(w http.ResponseWriter, r *http.Request) { - - w.WriteHeader(http.StatusNoContent) - -} - -// PutObjectLegalHoldHandler Put object Legal Hold -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html -func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { - - w.WriteHeader(http.StatusNoContent) - -} - -// PutObjectLockConfigurationHandler Put object Lock configuration -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html -func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { - - w.WriteHeader(http.StatusNoContent) - -} diff --git a/weed/s3api/s3api_object_retention.go b/weed/s3api/s3api_object_retention.go new file mode 100644 index 000000000..bedf693ef --- /dev/null +++ b/weed/s3api/s3api_object_retention.go @@ -0,0 +1,598 @@ +package s3api + +import ( + "encoding/xml" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// Sentinel errors for proper error handling instead of string matching +var ( + ErrNoRetentionConfiguration = errors.New("no retention configuration found") + ErrNoLegalHoldConfiguration = errors.New("no legal hold configuration found") + ErrBucketNotFound = errors.New("bucket not found") + ErrObjectNotFound = errors.New("object not found") + ErrVersionNotFound = errors.New("version not found") + ErrLatestVersionNotFound = errors.New("latest version not found") + ErrComplianceModeActive = errors.New("object is under COMPLIANCE mode retention and cannot be deleted or modified") + ErrGovernanceModeActive = errors.New("object is under GOVERNANCE mode retention and cannot be deleted or modified without bypass") +) + +const ( + // Maximum retention period limits according to AWS S3 specifications + MaxRetentionDays = 36500 // Maximum number of days for object retention (100 years) + MaxRetentionYears = 100 // Maximum number of years for object retention +) + +// ObjectRetention represents S3 Object Retention configuration +type ObjectRetention struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ Retention"` + Mode string `xml:"Mode,omitempty"` + RetainUntilDate *time.Time `xml:"RetainUntilDate,omitempty"` +} + +// ObjectLegalHold represents S3 Object Legal Hold configuration +type ObjectLegalHold struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ LegalHold"` + Status string `xml:"Status,omitempty"` +} + +// ObjectLockConfiguration represents S3 Object Lock Configuration +type ObjectLockConfiguration struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ObjectLockConfiguration"` + ObjectLockEnabled string `xml:"ObjectLockEnabled,omitempty"` + Rule *ObjectLockRule `xml:"Rule,omitempty"` +} + +// ObjectLockRule represents an Object Lock Rule +type ObjectLockRule struct { + XMLName xml.Name `xml:"Rule"` + DefaultRetention *DefaultRetention `xml:"DefaultRetention,omitempty"` +} + +// DefaultRetention represents default retention settings +type DefaultRetention struct { + XMLName xml.Name `xml:"DefaultRetention"` + Mode string `xml:"Mode,omitempty"` + Days int `xml:"Days,omitempty"` + Years int `xml:"Years,omitempty"` +} + +// Custom time unmarshalling for AWS S3 ISO8601 format +func (or *ObjectRetention) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type Alias ObjectRetention + aux := &struct { + *Alias + RetainUntilDate *string `xml:"RetainUntilDate,omitempty"` + }{ + Alias: (*Alias)(or), + } + + if err := d.DecodeElement(aux, &start); err != nil { + return err + } + + if aux.RetainUntilDate != nil { + t, err := time.Parse(time.RFC3339, *aux.RetainUntilDate) + if err != nil { + return err + } + or.RetainUntilDate = &t + } + + return nil +} + +// parseXML is a generic helper function to parse XML from an HTTP request body. +// It uses xml.Decoder for streaming XML parsing, which is more memory-efficient +// and avoids loading the entire request body into memory. +// +// The function assumes: +// - The request body is not nil (returns error if it is) +// - The request body will be closed after parsing (deferred close) +// - The XML content matches the structure of the provided result type T +// +// This approach is optimized for small XML payloads typical in S3 API requests +// (retention configurations, legal hold settings, etc.) where the overhead of +// streaming parsing is acceptable for the memory efficiency benefits. +func parseXML[T any](r *http.Request, result *T) error { + if r.Body == nil { + return fmt.Errorf("error parsing XML: empty request body") + } + defer r.Body.Close() + + decoder := xml.NewDecoder(r.Body) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("error parsing XML: %v", err) + } + + return nil +} + +// parseObjectRetention parses XML retention configuration from request body +func parseObjectRetention(r *http.Request) (*ObjectRetention, error) { + var retention ObjectRetention + if err := parseXML(r, &retention); err != nil { + return nil, err + } + return &retention, nil +} + +// parseObjectLegalHold parses XML legal hold configuration from request body +func parseObjectLegalHold(r *http.Request) (*ObjectLegalHold, error) { + var legalHold ObjectLegalHold + if err := parseXML(r, &legalHold); err != nil { + return nil, err + } + return &legalHold, nil +} + +// parseObjectLockConfiguration parses XML object lock configuration from request body +func parseObjectLockConfiguration(r *http.Request) (*ObjectLockConfiguration, error) { + var config ObjectLockConfiguration + if err := parseXML(r, &config); err != nil { + return nil, err + } + return &config, nil +} + +// validateRetention validates retention configuration +func validateRetention(retention *ObjectRetention) error { + // AWS requires both Mode and RetainUntilDate for PutObjectRetention + if retention.Mode == "" { + return fmt.Errorf("retention configuration must specify Mode") + } + + if retention.RetainUntilDate == nil { + return fmt.Errorf("retention configuration must specify RetainUntilDate") + } + + if retention.Mode != s3_constants.RetentionModeGovernance && retention.Mode != s3_constants.RetentionModeCompliance { + return fmt.Errorf("invalid retention mode: %s", retention.Mode) + } + + if retention.RetainUntilDate.Before(time.Now()) { + return fmt.Errorf("retain until date must be in the future") + } + + return nil +} + +// validateLegalHold validates legal hold configuration +func validateLegalHold(legalHold *ObjectLegalHold) error { + if legalHold.Status != s3_constants.LegalHoldOn && legalHold.Status != s3_constants.LegalHoldOff { + return fmt.Errorf("invalid legal hold status: %s", legalHold.Status) + } + + return nil +} + +// validateObjectLockConfiguration validates object lock configuration +func validateObjectLockConfiguration(config *ObjectLockConfiguration) error { + // ObjectLockEnabled is required for bucket-level configuration + if config.ObjectLockEnabled == "" { + return fmt.Errorf("object lock configuration must specify ObjectLockEnabled") + } + + // Validate ObjectLockEnabled value + if config.ObjectLockEnabled != s3_constants.ObjectLockEnabled { + return fmt.Errorf("invalid object lock enabled value: %s", config.ObjectLockEnabled) + } + + // Validate Rule if present + if config.Rule != nil { + if config.Rule.DefaultRetention == nil { + return fmt.Errorf("rule configuration must specify DefaultRetention") + } + return validateDefaultRetention(config.Rule.DefaultRetention) + } + + return nil +} + +// validateDefaultRetention validates default retention configuration +func validateDefaultRetention(retention *DefaultRetention) error { + // Mode is required + if retention.Mode == "" { + return fmt.Errorf("default retention must specify Mode") + } + + // Mode must be valid + if retention.Mode != s3_constants.RetentionModeGovernance && retention.Mode != s3_constants.RetentionModeCompliance { + return fmt.Errorf("invalid default retention mode: %s", retention.Mode) + } + + // Exactly one of Days or Years must be specified + if retention.Days == 0 && retention.Years == 0 { + return fmt.Errorf("default retention must specify either Days or Years") + } + + if retention.Days > 0 && retention.Years > 0 { + return fmt.Errorf("default retention cannot specify both Days and Years") + } + + // Validate ranges + if retention.Days < 0 || retention.Days > MaxRetentionDays { + return fmt.Errorf("default retention days must be between 0 and %d", MaxRetentionDays) + } + + if retention.Years < 0 || retention.Years > MaxRetentionYears { + return fmt.Errorf("default retention years must be between 0 and %d", MaxRetentionYears) + } + + return nil +} + +// getObjectEntry retrieves the appropriate object entry based on versioning and versionId +func (s3a *S3ApiServer) getObjectEntry(bucket, object, versionId string) (*filer_pb.Entry, error) { + var entry *filer_pb.Entry + var err error + + if versionId != "" { + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + } else { + // Check if versioning is enabled + versioningEnabled, vErr := s3a.isVersioningEnabled(bucket) + if vErr != nil { + return nil, fmt.Errorf("error checking versioning: %v", vErr) + } + + if versioningEnabled { + entry, err = s3a.getLatestObjectVersion(bucket, object) + } else { + bucketDir := s3a.option.BucketsPath + "/" + bucket + entry, err = s3a.getEntry(bucketDir, object) + } + } + + if err != nil { + return nil, fmt.Errorf("failed to retrieve object %s/%s: %w", bucket, object, ErrObjectNotFound) + } + + return entry, nil +} + +// getObjectRetention retrieves retention configuration from object metadata +func (s3a *S3ApiServer) getObjectRetention(bucket, object, versionId string) (*ObjectRetention, error) { + entry, err := s3a.getObjectEntry(bucket, object, versionId) + if err != nil { + return nil, err + } + + if entry.Extended == nil { + return nil, ErrNoRetentionConfiguration + } + + retention := &ObjectRetention{} + + if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists { + retention.Mode = string(modeBytes) + } + + if dateBytes, exists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; exists { + if timestamp, err := strconv.ParseInt(string(dateBytes), 10, 64); err == nil { + t := time.Unix(timestamp, 0) + retention.RetainUntilDate = &t + } else { + return nil, fmt.Errorf("failed to parse retention timestamp for %s/%s: corrupted timestamp data", bucket, object) + } + } + + if retention.Mode == "" || retention.RetainUntilDate == nil { + return nil, ErrNoRetentionConfiguration + } + + return retention, nil +} + +// setObjectRetention sets retention configuration on object metadata +func (s3a *S3ApiServer) setObjectRetention(bucket, object, versionId string, retention *ObjectRetention, bypassGovernance bool) error { + var entry *filer_pb.Entry + var err error + var entryPath string + + if versionId != "" { + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + if err != nil { + return fmt.Errorf("failed to get version %s for object %s/%s: %w", versionId, bucket, object, ErrVersionNotFound) + } + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } else { + // Check if versioning is enabled + versioningEnabled, vErr := s3a.isVersioningEnabled(bucket) + if vErr != nil { + return fmt.Errorf("error checking versioning: %v", vErr) + } + + if versioningEnabled { + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + return fmt.Errorf("failed to get latest version for object %s/%s: %w", bucket, object, ErrLatestVersionNotFound) + } + // Extract version ID from entry metadata + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + versionId = string(versionIdBytes) + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } + } + } else { + bucketDir := s3a.option.BucketsPath + "/" + bucket + entry, err = s3a.getEntry(bucketDir, object) + if err != nil { + return fmt.Errorf("failed to get object %s/%s: %w", bucket, object, ErrObjectNotFound) + } + entryPath = object + } + } + + // Check if object is already under retention + if entry.Extended != nil { + if existingMode, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists { + if string(existingMode) == s3_constants.RetentionModeCompliance && !bypassGovernance { + return fmt.Errorf("cannot modify retention on object under COMPLIANCE mode") + } + + if existingDateBytes, dateExists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; dateExists { + if timestamp, err := strconv.ParseInt(string(existingDateBytes), 10, 64); err == nil { + existingDate := time.Unix(timestamp, 0) + if existingDate.After(time.Now()) && string(existingMode) == s3_constants.RetentionModeGovernance && !bypassGovernance { + return fmt.Errorf("cannot modify retention on object under GOVERNANCE mode without bypass") + } + } + } + } + } + + // Update retention metadata + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + if retention.Mode != "" { + entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte(retention.Mode) + } + + if retention.RetainUntilDate != nil { + entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte(strconv.FormatInt(retention.RetainUntilDate.Unix(), 10)) + + // Also update the existing WORM fields for compatibility + entry.WormEnforcedAtTsNs = time.Now().UnixNano() + } + + // Update the entry + // NOTE: Potential race condition exists if concurrent calls to PutObjectRetention + // and PutObjectLegalHold update the same object simultaneously, as they might + // overwrite each other's Extended map changes. This is mitigated by the fact + // that mkFile operations are typically serialized at the filer level, but + // future implementations might consider using atomic update operations or + // entry-level locking for complete safety. + bucketDir := s3a.option.BucketsPath + "/" + bucket + return s3a.mkFile(bucketDir, entryPath, entry.Chunks, func(updatedEntry *filer_pb.Entry) { + updatedEntry.Extended = entry.Extended + updatedEntry.WormEnforcedAtTsNs = entry.WormEnforcedAtTsNs + }) +} + +// getObjectLegalHold retrieves legal hold configuration from object metadata +func (s3a *S3ApiServer) getObjectLegalHold(bucket, object, versionId string) (*ObjectLegalHold, error) { + entry, err := s3a.getObjectEntry(bucket, object, versionId) + if err != nil { + return nil, err + } + + if entry.Extended == nil { + return nil, ErrNoLegalHoldConfiguration + } + + legalHold := &ObjectLegalHold{} + + if statusBytes, exists := entry.Extended[s3_constants.ExtLegalHoldKey]; exists { + legalHold.Status = string(statusBytes) + } else { + return nil, ErrNoLegalHoldConfiguration + } + + return legalHold, nil +} + +// setObjectLegalHold sets legal hold configuration on object metadata +func (s3a *S3ApiServer) setObjectLegalHold(bucket, object, versionId string, legalHold *ObjectLegalHold) error { + var entry *filer_pb.Entry + var err error + var entryPath string + + if versionId != "" { + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + if err != nil { + return fmt.Errorf("failed to get version %s for object %s/%s: %w", versionId, bucket, object, ErrVersionNotFound) + } + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } else { + // Check if versioning is enabled + versioningEnabled, vErr := s3a.isVersioningEnabled(bucket) + if vErr != nil { + return fmt.Errorf("error checking versioning: %v", vErr) + } + + if versioningEnabled { + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + return fmt.Errorf("failed to get latest version for object %s/%s: %w", bucket, object, ErrLatestVersionNotFound) + } + // Extract version ID from entry metadata + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + versionId = string(versionIdBytes) + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } + } + } else { + bucketDir := s3a.option.BucketsPath + "/" + bucket + entry, err = s3a.getEntry(bucketDir, object) + if err != nil { + return fmt.Errorf("failed to get object %s/%s: %w", bucket, object, ErrObjectNotFound) + } + entryPath = object + } + } + + // Update legal hold metadata + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + entry.Extended[s3_constants.ExtLegalHoldKey] = []byte(legalHold.Status) + + // Update the entry + // NOTE: Potential race condition exists if concurrent calls to PutObjectRetention + // and PutObjectLegalHold update the same object simultaneously, as they might + // overwrite each other's Extended map changes. This is mitigated by the fact + // that mkFile operations are typically serialized at the filer level, but + // future implementations might consider using atomic update operations or + // entry-level locking for complete safety. + bucketDir := s3a.option.BucketsPath + "/" + bucket + return s3a.mkFile(bucketDir, entryPath, entry.Chunks, func(updatedEntry *filer_pb.Entry) { + updatedEntry.Extended = entry.Extended + }) +} + +// isObjectRetentionActive checks if an object is currently under retention +func (s3a *S3ApiServer) isObjectRetentionActive(bucket, object, versionId string) (bool, error) { + retention, err := s3a.getObjectRetention(bucket, object, versionId) + if err != nil { + // If no retention found, object is not under retention + if errors.Is(err, ErrNoRetentionConfiguration) { + return false, nil + } + return false, err + } + + if retention.RetainUntilDate != nil && retention.RetainUntilDate.After(time.Now()) { + return true, nil + } + + return false, nil +} + +// getObjectRetentionWithStatus retrieves retention configuration and returns both the data and active status +// This is an optimization to avoid duplicate fetches when both retention data and status are needed +func (s3a *S3ApiServer) getObjectRetentionWithStatus(bucket, object, versionId string) (*ObjectRetention, bool, error) { + retention, err := s3a.getObjectRetention(bucket, object, versionId) + if err != nil { + // If no retention found, object is not under retention + if errors.Is(err, ErrNoRetentionConfiguration) { + return nil, false, nil + } + return nil, false, err + } + + // Check if retention is currently active + isActive := retention.RetainUntilDate != nil && retention.RetainUntilDate.After(time.Now()) + return retention, isActive, nil +} + +// isObjectLegalHoldActive checks if an object is currently under legal hold +func (s3a *S3ApiServer) isObjectLegalHoldActive(bucket, object, versionId string) (bool, error) { + legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId) + if err != nil { + // If no legal hold found, object is not under legal hold + if errors.Is(err, ErrNoLegalHoldConfiguration) { + return false, nil + } + return false, err + } + + return legalHold.Status == s3_constants.LegalHoldOn, nil +} + +// checkObjectLockPermissions checks if an object can be deleted or modified +func (s3a *S3ApiServer) checkObjectLockPermissions(bucket, object, versionId string, bypassGovernance bool) error { + // Get retention configuration and status in a single call to avoid duplicate fetches + retention, retentionActive, err := s3a.getObjectRetentionWithStatus(bucket, object, versionId) + if err != nil { + glog.Warningf("Error checking retention for %s/%s: %v", bucket, object, err) + } + + // Check if object is under legal hold + legalHoldActive, err := s3a.isObjectLegalHoldActive(bucket, object, versionId) + if err != nil { + glog.Warningf("Error checking legal hold for %s/%s: %v", bucket, object, err) + } + + // If object is under legal hold, it cannot be deleted or modified + if legalHoldActive { + return fmt.Errorf("object is under legal hold and cannot be deleted or modified") + } + + // If object is under retention, check the mode + if retentionActive && retention != nil { + if retention.Mode == s3_constants.RetentionModeCompliance { + return ErrComplianceModeActive + } + + if retention.Mode == s3_constants.RetentionModeGovernance && !bypassGovernance { + return ErrGovernanceModeActive + } + } + + return nil +} + +// isObjectLockAvailable checks if Object Lock features are available for the bucket +// Object Lock requires versioning to be enabled (AWS S3 requirement) +func (s3a *S3ApiServer) isObjectLockAvailable(bucket string) error { + versioningEnabled, err := s3a.isVersioningEnabled(bucket) + if err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + return ErrBucketNotFound + } + return fmt.Errorf("error checking versioning status: %v", err) + } + + if !versioningEnabled { + return fmt.Errorf("object lock requires versioning to be enabled") + } + + return nil +} + +// checkObjectLockPermissionsForPut checks object lock permissions for PUT operations +// This is a shared helper to avoid code duplication in PUT handlers +func (s3a *S3ApiServer) checkObjectLockPermissionsForPut(bucket, object string, bypassGovernance bool, versioningEnabled bool) error { + // Object Lock only applies to versioned buckets (AWS S3 requirement) + if !versioningEnabled { + return nil + } + + // For PUT operations, we check permissions on the current object (empty versionId) + if err := s3a.checkObjectLockPermissions(bucket, object, "", bypassGovernance); err != nil { + glog.V(2).Infof("checkObjectLockPermissionsForPut: object lock check failed for %s/%s: %v", bucket, object, err) + return err + } + return nil +} + +// handleObjectLockAvailabilityCheck is a helper function to check object lock availability +// and write the appropriate error response if not available. This reduces code duplication +// across all retention handlers. +func (s3a *S3ApiServer) handleObjectLockAvailabilityCheck(w http.ResponseWriter, r *http.Request, bucket, handlerName string) bool { + if err := s3a.isObjectLockAvailable(bucket); err != nil { + glog.Errorf("%s: object lock not available for bucket %s: %v", handlerName, bucket, err) + if errors.Is(err, ErrBucketNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + } else { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + } + return false + } + return true +} diff --git a/weed/s3api/s3api_object_retention_test.go b/weed/s3api/s3api_object_retention_test.go new file mode 100644 index 000000000..0caa50b42 --- /dev/null +++ b/weed/s3api/s3api_object_retention_test.go @@ -0,0 +1,726 @@ +package s3api + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// TODO: If needed, re-implement TestPutObjectRetention with proper setup for buckets, objects, and versioning. + +func TestValidateRetention(t *testing.T) { + tests := []struct { + name string + retention *ObjectRetention + expectError bool + errorMsg string + }{ + { + name: "Valid GOVERNANCE retention", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeGovernance, + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: false, + }, + { + name: "Valid COMPLIANCE retention", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeCompliance, + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: false, + }, + { + name: "Missing Mode", + retention: &ObjectRetention{ + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: true, + errorMsg: "retention configuration must specify Mode", + }, + { + name: "Missing RetainUntilDate", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeGovernance, + }, + expectError: true, + errorMsg: "retention configuration must specify RetainUntilDate", + }, + { + name: "Invalid Mode", + retention: &ObjectRetention{ + Mode: "INVALID_MODE", + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: true, + errorMsg: "invalid retention mode", + }, + { + name: "Past RetainUntilDate", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeGovernance, + RetainUntilDate: timePtr(time.Now().Add(-24 * time.Hour)), + }, + expectError: true, + errorMsg: "retain until date must be in the future", + }, + { + name: "Empty retention", + retention: &ObjectRetention{}, + expectError: true, + errorMsg: "retention configuration must specify Mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRetention(tt.retention) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateLegalHold(t *testing.T) { + tests := []struct { + name string + legalHold *ObjectLegalHold + expectError bool + errorMsg string + }{ + { + name: "Valid ON status", + legalHold: &ObjectLegalHold{ + Status: s3_constants.LegalHoldOn, + }, + expectError: false, + }, + { + name: "Valid OFF status", + legalHold: &ObjectLegalHold{ + Status: s3_constants.LegalHoldOff, + }, + expectError: false, + }, + { + name: "Invalid status", + legalHold: &ObjectLegalHold{ + Status: "INVALID_STATUS", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + { + name: "Empty status", + legalHold: &ObjectLegalHold{ + Status: "", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + { + name: "Lowercase on", + legalHold: &ObjectLegalHold{ + Status: "on", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + { + name: "Lowercase off", + legalHold: &ObjectLegalHold{ + Status: "off", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateLegalHold(tt.legalHold) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestParseObjectRetention(t *testing.T) { + tests := []struct { + name string + xmlBody string + expectError bool + errorMsg string + expectedResult *ObjectRetention + }{ + { + name: "Valid retention XML", + xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <Mode>GOVERNANCE</Mode> + <RetainUntilDate>2024-12-31T23:59:59Z</RetainUntilDate> + </Retention>`, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "GOVERNANCE", + RetainUntilDate: timePtr(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)), + }, + }, + { + name: "Valid compliance retention XML", + xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <Mode>COMPLIANCE</Mode> + <RetainUntilDate>2025-01-01T00:00:00Z</RetainUntilDate> + </Retention>`, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "COMPLIANCE", + RetainUntilDate: timePtr(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + { + name: "Empty XML body", + xmlBody: "", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Invalid XML", + xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Mode>GOVERNANCE</Mode><RetainUntilDate>invalid-date</RetainUntilDate></Retention>`, + expectError: true, + errorMsg: "cannot parse", + }, + { + name: "Malformed XML", + xmlBody: "<Retention><Mode>GOVERNANCE</Mode><RetainUntilDate>2024-12-31T23:59:59Z</Retention>", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Missing Mode", + xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <RetainUntilDate>2024-12-31T23:59:59Z</RetainUntilDate> + </Retention>`, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "", + RetainUntilDate: timePtr(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)), + }, + }, + { + name: "Missing RetainUntilDate", + xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <Mode>GOVERNANCE</Mode> + </Retention>`, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "GOVERNANCE", + RetainUntilDate: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP request with XML body + req := &http.Request{ + Body: io.NopCloser(strings.NewReader(tt.xmlBody)), + } + + result, err := parseObjectRetention(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result == nil { + t.Errorf("Expected result but got nil") + } else { + if result.Mode != tt.expectedResult.Mode { + t.Errorf("Expected Mode %s, got %s", tt.expectedResult.Mode, result.Mode) + } + if tt.expectedResult.RetainUntilDate == nil { + if result.RetainUntilDate != nil { + t.Errorf("Expected RetainUntilDate to be nil, got %v", result.RetainUntilDate) + } + } else if result.RetainUntilDate == nil { + t.Errorf("Expected RetainUntilDate to be %v, got nil", tt.expectedResult.RetainUntilDate) + } else if !result.RetainUntilDate.Equal(*tt.expectedResult.RetainUntilDate) { + t.Errorf("Expected RetainUntilDate %v, got %v", tt.expectedResult.RetainUntilDate, result.RetainUntilDate) + } + } + } + }) + } +} + +func TestParseObjectLegalHold(t *testing.T) { + tests := []struct { + name string + xmlBody string + expectError bool + errorMsg string + expectedResult *ObjectLegalHold + }{ + { + name: "Valid legal hold ON", + xmlBody: `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <Status>ON</Status> + </LegalHold>`, + expectError: false, + expectedResult: &ObjectLegalHold{ + Status: "ON", + }, + }, + { + name: "Valid legal hold OFF", + xmlBody: `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <Status>OFF</Status> + </LegalHold>`, + expectError: false, + expectedResult: &ObjectLegalHold{ + Status: "OFF", + }, + }, + { + name: "Empty XML body", + xmlBody: "", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Invalid XML", + xmlBody: "<LegalHold><Status>ON</Status>", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Missing Status", + xmlBody: `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + </LegalHold>`, + expectError: false, + expectedResult: &ObjectLegalHold{ + Status: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP request with XML body + req := &http.Request{ + Body: io.NopCloser(strings.NewReader(tt.xmlBody)), + } + + result, err := parseObjectLegalHold(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result == nil { + t.Errorf("Expected result but got nil") + } else { + if result.Status != tt.expectedResult.Status { + t.Errorf("Expected Status %s, got %s", tt.expectedResult.Status, result.Status) + } + } + } + }) + } +} + +func TestParseObjectLockConfiguration(t *testing.T) { + tests := []struct { + name string + xmlBody string + expectError bool + errorMsg string + expectedResult *ObjectLockConfiguration + }{ + { + name: "Valid object lock configuration", + xmlBody: `<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <ObjectLockEnabled>Enabled</ObjectLockEnabled> + </ObjectLockConfiguration>`, + expectError: false, + expectedResult: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + }, + }, + { + name: "Valid object lock configuration with rule", + xmlBody: `<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <ObjectLockEnabled>Enabled</ObjectLockEnabled> + <Rule> + <DefaultRetention> + <Mode>GOVERNANCE</Mode> + <Days>30</Days> + </DefaultRetention> + </Rule> + </ObjectLockConfiguration>`, + expectError: false, + expectedResult: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + }, + }, + }, + }, + { + name: "Empty XML body", + xmlBody: "", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Invalid XML", + xmlBody: "<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled>", + expectError: true, + errorMsg: "error parsing XML", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP request with XML body + req := &http.Request{ + Body: io.NopCloser(strings.NewReader(tt.xmlBody)), + } + + result, err := parseObjectLockConfiguration(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result == nil { + t.Errorf("Expected result but got nil") + } else { + if result.ObjectLockEnabled != tt.expectedResult.ObjectLockEnabled { + t.Errorf("Expected ObjectLockEnabled %s, got %s", tt.expectedResult.ObjectLockEnabled, result.ObjectLockEnabled) + } + if tt.expectedResult.Rule == nil { + if result.Rule != nil { + t.Errorf("Expected Rule to be nil, got %v", result.Rule) + } + } else if result.Rule == nil { + t.Errorf("Expected Rule to be non-nil") + } else { + if result.Rule.DefaultRetention == nil { + t.Errorf("Expected DefaultRetention to be non-nil") + } else { + if result.Rule.DefaultRetention.Mode != tt.expectedResult.Rule.DefaultRetention.Mode { + t.Errorf("Expected DefaultRetention Mode %s, got %s", tt.expectedResult.Rule.DefaultRetention.Mode, result.Rule.DefaultRetention.Mode) + } + if result.Rule.DefaultRetention.Days != tt.expectedResult.Rule.DefaultRetention.Days { + t.Errorf("Expected DefaultRetention Days %d, got %d", tt.expectedResult.Rule.DefaultRetention.Days, result.Rule.DefaultRetention.Days) + } + } + } + } + } + }) + } +} + +func TestValidateObjectLockConfiguration(t *testing.T) { + tests := []struct { + name string + config *ObjectLockConfiguration + expectError bool + errorMsg string + }{ + { + name: "Valid config with ObjectLockEnabled only", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + }, + expectError: false, + }, + { + name: "Missing ObjectLockEnabled", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "", + }, + expectError: true, + errorMsg: "object lock configuration must specify ObjectLockEnabled", + }, + { + name: "Valid config with rule and days", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + }, + }, + }, + expectError: false, + }, + { + name: "Valid config with rule and years", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "COMPLIANCE", + Years: 1, + }, + }, + }, + expectError: false, + }, + { + name: "Invalid ObjectLockEnabled value", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "InvalidValue", + }, + expectError: true, + errorMsg: "invalid object lock enabled value", + }, + { + name: "Invalid rule - missing mode", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Days: 30, + }, + }, + }, + expectError: true, + errorMsg: "default retention must specify Mode", + }, + { + name: "Invalid rule - both days and years", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + Years: 1, + }, + }, + }, + expectError: true, + errorMsg: "default retention cannot specify both Days and Years", + }, + { + name: "Invalid rule - neither days nor years", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + }, + }, + }, + expectError: true, + errorMsg: "default retention must specify either Days or Years", + }, + { + name: "Invalid rule - invalid mode", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "INVALID_MODE", + Days: 30, + }, + }, + }, + expectError: true, + errorMsg: "invalid default retention mode", + }, + { + name: "Invalid rule - days out of range", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 50000, + }, + }, + }, + expectError: true, + errorMsg: fmt.Sprintf("default retention days must be between 0 and %d", MaxRetentionDays), + }, + { + name: "Invalid rule - years out of range", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Years: 200, + }, + }, + }, + expectError: true, + errorMsg: fmt.Sprintf("default retention years must be between 0 and %d", MaxRetentionYears), + }, + { + name: "Invalid rule - missing DefaultRetention", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: nil, + }, + }, + expectError: true, + errorMsg: "rule configuration must specify DefaultRetention", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateObjectLockConfiguration(tt.config) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateDefaultRetention(t *testing.T) { + tests := []struct { + name string + retention *DefaultRetention + expectError bool + errorMsg string + }{ + { + name: "Valid retention with days", + retention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + }, + expectError: false, + }, + { + name: "Valid retention with years", + retention: &DefaultRetention{ + Mode: "COMPLIANCE", + Years: 1, + }, + expectError: false, + }, + { + name: "Missing mode", + retention: &DefaultRetention{ + Days: 30, + }, + expectError: true, + errorMsg: "default retention must specify Mode", + }, + { + name: "Invalid mode", + retention: &DefaultRetention{ + Mode: "INVALID", + Days: 30, + }, + expectError: true, + errorMsg: "invalid default retention mode", + }, + { + name: "Both days and years specified", + retention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + Years: 1, + }, + expectError: true, + errorMsg: "default retention cannot specify both Days and Years", + }, + { + name: "Neither days nor years specified", + retention: &DefaultRetention{ + Mode: "GOVERNANCE", + }, + expectError: true, + errorMsg: "default retention must specify either Days or Years", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDefaultRetention(tt.retention) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +// Helper function to create a time pointer +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 28eac9951..426535fe0 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -206,11 +206,13 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectRetentionHandler, ACTION_WRITE)), "PUT")).Queries("retention", "") // PutObjectLegalHold bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLegalHoldHandler, ACTION_WRITE)), "PUT")).Queries("legal-hold", "") - // PutObjectLockConfiguration - bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "") // GetObjectACL bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectAclHandler, ACTION_READ_ACP)), "GET")).Queries("acl", "") + // GetObjectRetention + bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectRetentionHandler, ACTION_READ)), "GET")).Queries("retention", "") + // GetObjectLegalHold + bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLegalHoldHandler, ACTION_READ)), "GET")).Queries("legal-hold", "") // objects with query @@ -272,6 +274,10 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketVersioningHandler, ACTION_READ)), "GET")).Queries("versioning", "") bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketVersioningHandler, ACTION_WRITE)), "PUT")).Queries("versioning", "") + // GetObjectLockConfiguration / PutObjectLockConfiguration (bucket-level operations) + bucket.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLockConfigurationHandler, ACTION_READ)), "GET")).Queries("object-lock", "") + bucket.Methods(http.MethodPut).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "") + // GetBucketTagging bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketTaggingHandler, ACTION_TAGGING)), "GET")).Queries("tagging", "") bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketTaggingHandler, ACTION_TAGGING)), "PUT")).Queries("tagging", "") diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go index bcb0a26a8..17057f604 100644 --- a/weed/s3api/s3err/s3api_errors.go +++ b/weed/s3api/s3err/s3api_errors.go @@ -110,6 +110,8 @@ const ( OwnershipControlsNotFoundError ErrNoSuchTagSet + ErrNoSuchObjectLockConfiguration + ErrNoSuchObjectLegalHold ) // Error message constants for checksum validation @@ -197,6 +199,16 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "The TagSet does not exist", HTTPStatusCode: http.StatusNotFound, }, + ErrNoSuchObjectLockConfiguration: { + Code: "NoSuchObjectLockConfiguration", + Description: "The specified object does not have an ObjectLock configuration", + HTTPStatusCode: http.StatusNotFound, + }, + ErrNoSuchObjectLegalHold: { + Code: "NoSuchObjectLegalHold", + Description: "The specified object does not have a legal hold configuration", + HTTPStatusCode: http.StatusNotFound, + }, ErrNoSuchCORSConfiguration: { Code: "NoSuchCORSConfiguration", Description: "The CORS configuration does not exist", |
