implement PubObjectRetention and WORM (#6969)

* implement PubObjectRetention and WORM

* Update s3_worm_integration_test.go

* avoid previous buckets

* Update s3-versioning-tests.yml

* address comments

* address comments

* rename to ExtObjectLockModeKey

* only checkObjectLockPermissions if versioningEnabled

* address comments

* comments

* Revert "comments"

This reverts commit 6736434176f86c6e222b867777324b17c2de716f.

* Update s3api_object_handlers_skip.go

* Update s3api_object_retention_test.go

* add version id to ObjectIdentifier

* address comments

* add comments

* Add proper error logging for timestamp parsing failures

* address comments

* add version id to the error

* Update weed/s3api/s3api_object_retention_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update weed/s3api/s3api_object_retention.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* constants

* fix comments

* address comments

* address comment

* refactor out handleObjectLockAvailabilityCheck

* errors.Is ErrBucketNotFound

* better error checking

* address comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Lu
2025-07-12 21:58:55 -07:00
committed by GitHub
parent 687a6a6c1d
commit 1549ee2e15
18 changed files with 3844 additions and 43 deletions

View File

@@ -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"
)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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", "")

View File

@@ -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",