package s3api import ( "bytes" "context" "encoding/json" "errors" "fmt" "path/filepath" "strings" "sync" "time" "github.com/aws/aws-sdk-go/service/s3" lru "github.com/hashicorp/golang-lru/v2" "google.golang.org/protobuf/proto" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/kms" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/s3_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/cors" "github.com/seaweedfs/seaweedfs/weed/s3api/lifecycle_xml" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) // BucketConfig represents cached bucket configuration. Only fields parsed // from the bucket's filer entry are retained — not the entry itself — so a // cached config stays small; the write path (updateBucketConfig) re-reads // the entry from the filer. type BucketConfig struct { Name string Versioning string // "Enabled", "Suspended", or "" Ownership string ACL []byte Owner string IdentityId string // identity that created the bucket Crtime int64 // bucket creation time, unix seconds IsPublicRead bool // Cached flag to avoid JSON parsing on every request CORS *cors.CORSConfiguration ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration BucketPolicy *policy_engine.PolicyDocument // Cached bucket policy for performance // LifecycleTTL answers "what volume TTL should this PutObject get?" // using only fast-path-safe predicates (prefix + size; tags excluded // because they're mutable post-PUT). nil = no TTL applies (fast path // not enabled on the bucket, no lifecycle config, versioned bucket, // or only ineligible rules). The fast path is opt-in per bucket. The // lifecycle worker reads bucket entries directly off the meta-log // rather than this cache. LifecycleTTL *LifecycleTTLResolver // LifecycleXML is the stored lifecycle configuration as served by // GetBucketLifecycle, with its companion transition minimum object size. LifecycleXML []byte LifecycleTransitionMinSize string Tags map[string]string // parsed from entry content Encryption *s3_pb.EncryptionConfiguration // parsed from entry content KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations LastModified time.Time } // BucketKMSCache represents per-bucket KMS key caching for SSE-KMS operations // This provides better isolation and automatic cleanup compared to global caching type BucketKMSCache struct { cache map[string]*BucketKMSCacheEntry // Key: contextHash, Value: cached data key mutex sync.RWMutex bucket string // Bucket name for logging/debugging lastTTL time.Duration // TTL used for cache entries (typically 1 hour) } // BucketKMSCacheEntry represents a single cached KMS data key type BucketKMSCacheEntry struct { DataKey interface{} // Could be *kms.GenerateDataKeyResponse or similar ExpiresAt time.Time KeyID string ContextHash string // Hash of encryption context for cache validation } // NewBucketKMSCache creates a new per-bucket KMS key cache func NewBucketKMSCache(bucketName string, ttl time.Duration) *BucketKMSCache { return &BucketKMSCache{ cache: make(map[string]*BucketKMSCacheEntry), bucket: bucketName, lastTTL: ttl, } } // Get retrieves a cached KMS data key if it exists and hasn't expired func (bkc *BucketKMSCache) Get(contextHash string) (*BucketKMSCacheEntry, bool) { if bkc == nil { return nil, false } bkc.mutex.RLock() defer bkc.mutex.RUnlock() entry, exists := bkc.cache[contextHash] if !exists { return nil, false } // Check if entry has expired if time.Now().After(entry.ExpiresAt) { return nil, false } return entry, true } // Set stores a KMS data key in the cache func (bkc *BucketKMSCache) Set(contextHash, keyID string, dataKey interface{}, ttl time.Duration) { if bkc == nil { return } bkc.mutex.Lock() defer bkc.mutex.Unlock() bkc.cache[contextHash] = &BucketKMSCacheEntry{ DataKey: dataKey, ExpiresAt: time.Now().Add(ttl), KeyID: keyID, ContextHash: contextHash, } bkc.lastTTL = ttl } // CleanupExpired removes expired entries from the cache func (bkc *BucketKMSCache) CleanupExpired() int { if bkc == nil { return 0 } bkc.mutex.Lock() defer bkc.mutex.Unlock() now := time.Now() expiredCount := 0 for key, entry := range bkc.cache { if now.After(entry.ExpiresAt) { // Clear sensitive data before removing from cache bkc.clearSensitiveData(entry) delete(bkc.cache, key) expiredCount++ } } return expiredCount } // Size returns the current number of cached entries func (bkc *BucketKMSCache) Size() int { if bkc == nil { return 0 } bkc.mutex.RLock() defer bkc.mutex.RUnlock() return len(bkc.cache) } // clearSensitiveData securely clears sensitive data from a cache entry func (bkc *BucketKMSCache) clearSensitiveData(entry *BucketKMSCacheEntry) { if dataKeyResp, ok := entry.DataKey.(*kms.GenerateDataKeyResponse); ok { // Zero out the plaintext data key to prevent it from lingering in memory if dataKeyResp.Plaintext != nil { for i := range dataKeyResp.Plaintext { dataKeyResp.Plaintext[i] = 0 } dataKeyResp.Plaintext = nil } } } // Clear clears all cached KMS entries, securely zeroing sensitive data first func (bkc *BucketKMSCache) Clear() { if bkc == nil { return } bkc.mutex.Lock() defer bkc.mutex.Unlock() // Clear sensitive data from all entries before deletion for _, entry := range bkc.cache { bkc.clearSensitiveData(entry) } // Clear the cache map bkc.cache = make(map[string]*BucketKMSCacheEntry) } // bucketCacheCapacity bounds the per-bucket caches (bucket config cache, // bucket registry, and their negative caches). Only the hot working set // stays resident; evicted buckets reload from the filer on next access. const bucketCacheCapacity = 65536 // BucketConfigCache provides caching for bucket configurations // Cache entries are automatically updated/invalidated through metadata subscription events, // so TTL serves as a safety fallback rather than the primary consistency mechanism. // Both caches are size-capped LRUs so a gateway serving millions of buckets // only keeps its hot working set resident. type BucketConfigCache struct { cache *lru.Cache[string, *BucketConfig] negativeCache *lru.Cache[string, time.Time] // Cache for non-existent buckets ttl time.Duration // Safety fallback TTL; real-time consistency maintained via events negativeTTL time.Duration // TTL for negative cache entries } // BucketMetadata represents the complete metadata for a bucket type BucketMetadata struct { Tags map[string]string `json:"tags,omitempty"` CORS *cors.CORSConfiguration `json:"cors,omitempty"` Encryption *s3_pb.EncryptionConfiguration `json:"encryption,omitempty"` // Future extensions can be added here: // Versioning *s3_pb.VersioningConfiguration `json:"versioning,omitempty"` // Lifecycle *s3_pb.LifecycleConfiguration `json:"lifecycle,omitempty"` // Notification *s3_pb.NotificationConfiguration `json:"notification,omitempty"` // Replication *s3_pb.ReplicationConfiguration `json:"replication,omitempty"` // Analytics *s3_pb.AnalyticsConfiguration `json:"analytics,omitempty"` // Logging *s3_pb.LoggingConfiguration `json:"logging,omitempty"` // Website *s3_pb.WebsiteConfiguration `json:"website,omitempty"` // RequestPayer *s3_pb.RequestPayerConfiguration `json:"requestPayer,omitempty"` // PublicAccess *s3_pb.PublicAccessConfiguration `json:"publicAccess,omitempty"` } // NewBucketMetadata creates a new BucketMetadata with default values func NewBucketMetadata() *BucketMetadata { return &BucketMetadata{ Tags: make(map[string]string), } } // IsEmpty returns true if the metadata has no configuration set func (bm *BucketMetadata) IsEmpty() bool { return len(bm.Tags) == 0 && bm.CORS == nil && bm.Encryption == nil } // HasEncryption returns true if bucket has encryption configuration func (bm *BucketMetadata) HasEncryption() bool { return bm.Encryption != nil } // HasCORS returns true if bucket has CORS configuration func (bm *BucketMetadata) HasCORS() bool { return bm.CORS != nil } // HasTags returns true if bucket has tags func (bm *BucketMetadata) HasTags() bool { return len(bm.Tags) > 0 } // NewBucketConfigCache creates a new bucket configuration cache // TTL can be set to a longer duration since cache consistency is maintained // through real-time metadata subscription events rather than TTL expiration func NewBucketConfigCache(ttl time.Duration) *BucketConfigCache { negativeTTL := ttl / 4 // Negative cache TTL is shorter than positive cache if negativeTTL < 30*time.Second { negativeTTL = 30 * time.Second // Minimum 30 seconds for negative cache } cache, _ := lru.New[string, *BucketConfig](bucketCacheCapacity) negativeCache, _ := lru.New[string, time.Time](bucketCacheCapacity) return &BucketConfigCache{ cache: cache, negativeCache: negativeCache, ttl: ttl, negativeTTL: negativeTTL, } } // Get retrieves bucket configuration from cache func (bcc *BucketConfigCache) Get(bucket string) (*BucketConfig, bool) { config, exists := bcc.cache.Get(bucket) if !exists { return nil, false } // Check if cache entry is expired (safety fallback; entries are normally updated via events) if time.Since(config.LastModified) > bcc.ttl { return nil, false } return config, true } // Set stores bucket configuration in cache func (bcc *BucketConfigCache) Set(bucket string, config *BucketConfig) { config.LastModified = time.Now() bcc.cache.Add(bucket, config) } // Contains reports whether the bucket is resident in the cache, regardless of TTL func (bcc *BucketConfigCache) Contains(bucket string) bool { return bcc.cache.Contains(bucket) } // Remove removes bucket configuration from cache func (bcc *BucketConfigCache) Remove(bucket string) { bcc.cache.Remove(bucket) } // Clear clears all cached configurations func (bcc *BucketConfigCache) Clear() { bcc.cache.Purge() bcc.negativeCache.Purge() } // IsNegativelyCached checks if a bucket is in the negative cache (doesn't exist) func (bcc *BucketConfigCache) IsNegativelyCached(bucket string) bool { if cachedTime, exists := bcc.negativeCache.Get(bucket); exists { // Check if the negative cache entry is still valid if time.Since(cachedTime) < bcc.negativeTTL { return true } // Entry expired, remove it bcc.negativeCache.Remove(bucket) } return false } // SetNegativeCache marks a bucket as non-existent in the negative cache func (bcc *BucketConfigCache) SetNegativeCache(bucket string) { bcc.negativeCache.Add(bucket, time.Now()) } // RemoveNegativeCache removes a bucket from the negative cache func (bcc *BucketConfigCache) RemoveNegativeCache(bucket string) { bcc.negativeCache.Remove(bucket) } // loadBucketPolicyFromExtended loads and parses bucket policy from entry extended attributes func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy_engine.PolicyDocument { if entry.Extended == nil { return nil } policyJSON, exists := entry.Extended[BUCKET_POLICY_METADATA_KEY] if !exists || len(policyJSON) == 0 { glog.V(4).Infof("loadBucketPolicyFromExtended: no bucket policy found for bucket %s", bucket) return nil } var policyDoc policy_engine.PolicyDocument if err := json.Unmarshal(policyJSON, &policyDoc); err != nil { glog.Errorf("loadBucketPolicyFromExtended: failed to parse bucket policy for %s: %v", bucket, err) return nil } glog.V(3).Infof("loadBucketPolicyFromExtended: loaded bucket policy for bucket %s", bucket) return &policyDoc } // getBucketConfig retrieves bucket configuration with caching func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.ErrorCode) { // Check negative cache first if s3a.bucketConfigCache.IsNegativelyCached(bucket) { return nil, s3err.ErrNoSuchBucket } // Try positive cache if config, found := s3a.bucketConfigCache.Get(bucket); found { return config, s3err.ErrNone } // Try to get from filer entry, err := s3a.getBucketEntry(bucket) if err != nil { if errors.Is(err, filer_pb.ErrNotFound) { // Bucket doesn't exist - set negative cache s3a.bucketConfigCache.SetNegativeCache(bucket) return nil, s3err.ErrNoSuchBucket } glog.Errorf("getBucketConfig: failed to get bucket entry for %s: %v", bucket, err) return nil, s3err.ErrInternalError } config := s3a.newBucketConfigFromEntry(bucket, entry) // A cold load is the first time this gateway learns the bucket's policy // exists, and the metadata subscription only mirrors changes - a policy // that predates the IAM integration would otherwise never reach the // advanced-IAM mirror and its grants would not bind on the IAM path. // Synchronous: the IAM auth path primes the bucket through here before // evaluating the mirror, so the backfill has to land first - a one-time // cost on the load that discovers the policy. Raw entry bytes, not the // parsed document, so the backfill can byte-compare against a later // entry read when it reconciles. if policyJSON := entry.Extended[BUCKET_POLICY_METADATA_KEY]; len(policyJSON) > 0 && config.BucketPolicy != nil && s3a.bucketPolicyIAMManager() != nil { s3a.ensureBucketPolicyInIAM(bucket, policyJSON) } // Cache the result s3a.bucketConfigCache.Set(bucket, config) return config, s3err.ErrNone } // newBucketConfigFromEntry builds a BucketConfig from the bucket's filer // entry, parsing Entry.Extended / Entry.Content into the cached fields // (versioning flag, ACL, owner, object lock, bucket policy, CORS, tags, // encryption, lifecycle TTL resolver). It is the single source of truth for // that mapping; the read path (getBucketConfig), the write path // (updateBucketConfig), and the meta-log subscription cache refresher all // funnel through here so a missed field can't silently keep stale data — // e.g. a stale LifecycleTTL after a Put/DeleteBucketLifecycle would keep // stamping the old policy's irreversible volume TTL onto new writes. func (s3a *S3ApiServer) newBucketConfigFromEntry(bucket string, entry *filer_pb.Entry) *BucketConfig { config := &BucketConfig{ Name: bucket, } if entry == nil { return config } if entry.Attributes != nil { config.Crtime = entry.Attributes.Crtime } if entry.Extended != nil { if versioning, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists { config.Versioning = string(versioning) } if ownership, exists := entry.Extended[s3_constants.ExtOwnershipKey]; exists { config.Ownership = string(ownership) } if acl, exists := entry.Extended[s3_constants.ExtAmzAclKey]; exists { config.ACL = acl // Parse ACL once and cache public-read status. config.IsPublicRead = parseAndCachePublicReadStatus(acl) } config.Owner = bucketOwnerAccountId(s3a.iam, entry) if identityId, exists := entry.Extended[s3_constants.AmzIdentityId]; exists { config.IdentityId = string(identityId) } if objectLockConfig, found := LoadObjectLockConfigurationFromExtended(entry); found { config.ObjectLockConfig = objectLockConfig } config.BucketPolicy = loadBucketPolicyFromExtended(entry, bucket) if lifecycleXML, exists := entry.Extended[bucketLifecycleConfigurationXMLKey]; exists && len(lifecycleXML) > 0 { config.LifecycleXML = lifecycleXML config.LifecycleTransitionMinSize = string(entry.Extended[bucketLifecycleTransitionMinimumObjectSizeKey]) } // The lifecycle TTL fast path is opt-in per bucket: a volume TTL // stamped at write time can't honor a later policy change (rule // removed or lengthened) the way worker-driven expiration does, // so it stays off unless explicitly enabled. Skip the XML parse // entirely when off. nil on parse error so the PUT path falls // through to "no TTL" rather than rejecting writes. if bytes.Equal(entry.Extended[s3_constants.ExtLifecycleTtlFastPathKey], []byte("true")) && len(config.LifecycleXML) > 0 { if rules, err := lifecycle_xml.ParseCanonical(config.LifecycleXML); err == nil { // Object Lock requires versioning, so an ObjectLockConfig // implies the bucket is versioned even when the explicit // Versioning header was never written. BucketIsVersioned // in this file uses the same OR — keep them aligned. versioned := config.Versioning == s3_constants.VersioningEnabled || config.Versioning == s3_constants.VersioningSuspended || config.ObjectLockConfig != nil config.LifecycleTTL = NewLifecycleTTLResolver(rules, versioned) } else { glog.V(1).Infof("newBucketConfigFromEntry: bucket %s lifecycle xml parse: %v", bucket, err) } } } // Sync bucket policy to the policy engine for evaluation. s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy) // Parse tags, CORS, and encryption from the entry's Content field. if len(entry.Content) > 0 { var protoMetadata s3_pb.BucketMetadata if err := proto.Unmarshal(entry.Content, &protoMetadata); err != nil { glog.Errorf("newBucketConfigFromEntry: failed to unmarshal metadata for bucket %s: %v", bucket, err) } else { config.Tags = protoMetadata.Tags config.CORS = corsConfigFromProto(protoMetadata.Cors) config.Encryption = protoMetadata.Encryption } } return config } // updateBucketConfig updates bucket configuration and invalidates cache. // It reads the bucket entry fresh from the filer so the patch diff is // computed against current state rather than a possibly stale cached copy. func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketConfig) error) s3err.ErrorCode { entry, err := s3a.getBucketEntry(bucket) if err != nil { if errors.Is(err, filer_pb.ErrNotFound) { if s3a.bucketConfigCache != nil { s3a.bucketConfigCache.SetNegativeCache(bucket) } return s3err.ErrNoSuchBucket } glog.Errorf("updateBucketConfig: failed to get bucket entry for %s: %v", bucket, err) return s3err.ErrInternalError } config := s3a.newBucketConfigFromEntry(bucket, entry) // Apply update function if err := updateFn(config); err != nil { glog.Errorf("updateBucketConfig: update function failed for bucket %s: %v", bucket, err) return s3err.ErrInternalError } oldExt := entry.GetExtended() newExt := make(map[string][]byte, len(oldExt)) for k, v := range oldExt { newExt[k] = v } if err := applyBucketConfigToExtended(config, newExt); err != nil { glog.Errorf("updateBucketConfig: failed to serialize config for bucket %s: %v", bucket, err) return s3err.ErrInternalError } // Patch only the changed/removed extended keys, leaving Entry.content // untouched so a concurrent content write (e.g. encryption) is preserved. set := make(map[string][]byte) for k, v := range newExt { if ov, ok := oldExt[k]; !ok || !bytes.Equal(ov, v) { set[k] = v } } var del []string for k := range oldExt { if _, ok := newExt[k]; !ok { del = append(del, k) } } glog.V(3).Infof("updateBucketConfig: patching %d/%d extended keys for bucket %s", len(set), len(del), bucket) if err := s3a.patchBucketEntry(bucket, &filer_pb.ObjectMutation{SetExtended: set, DeleteExtended: del}); err != nil { glog.Errorf("updateBucketConfig: failed to patch bucket entry for %s: %v", bucket, err) return s3err.ErrInternalError } // Invalidate rather than cache the updated config: it may be stale relative // to a concurrent write. The next read re-fetches the merged entry. if s3a.bucketConfigCache != nil { s3a.bucketConfigCache.Remove(bucket) s3a.bucketConfigCache.RemoveNegativeCache(bucket) } return s3err.ErrNone } // applyBucketConfigToExtended maps the persisted BucketConfig fields onto an // extended-attribute map; keys not derived from BucketConfig are left alone. func applyBucketConfigToExtended(config *BucketConfig, ext map[string][]byte) error { setOrDelete := func(key string, value []byte) { if len(value) > 0 { ext[key] = value } else { delete(ext, key) } } setOrDelete(s3_constants.ExtVersioningKey, []byte(config.Versioning)) setOrDelete(s3_constants.ExtOwnershipKey, []byte(config.Ownership)) setOrDelete(s3_constants.ExtAmzAclKey, config.ACL) setOrDelete(s3_constants.ExtAmzOwnerKey, []byte(config.Owner)) setOrDelete(bucketLifecycleConfigurationXMLKey, config.LifecycleXML) if len(config.LifecycleXML) > 0 && config.LifecycleTransitionMinSize != "" { ext[bucketLifecycleTransitionMinimumObjectSizeKey] = []byte(config.LifecycleTransitionMinSize) } else { delete(ext, bucketLifecycleTransitionMinimumObjectSizeKey) } // Object Lock, once enabled, is never deleted; a nil config leaves any // existing keys untouched. if config.ObjectLockConfig != nil { return StoreObjectLockConfigurationInExtended(&filer_pb.Entry{Extended: ext}, config.ObjectLockConfig) } return nil } // patchBucketEntry applies a field-level PATCH_EXTENDED mutation to the bucket's // entry via ObjectTransaction, routed to the bucket's owner filer so its per-path // lock serializes concurrent config writes cluster-wide rather than racing // whole-entry rewrites. A nil/empty mutation is a no-op. func (s3a *S3ApiServer) patchBucketEntry(bucket string, m *filer_pb.ObjectMutation) error { if m == nil || (len(m.SetExtended) == 0 && len(m.DeleteExtended) == 0 && !m.SetContent) { return nil } dir := s3a.option.BucketsPath bucketPath := dir + "/" + bucket m.Type = filer_pb.ObjectMutation_PATCH_EXTENDED m.Directory = dir m.Name = bucket req := &filer_pb.ObjectTransactionRequest{ LockKey: bucketPath, RouteKey: objectWriteRouteKeyPrefix + bucketPath, Mutations: []*filer_pb.ObjectMutation{m}, } respErr := func(resp *filer_pb.ObjectTransactionResponse, err error) error { if err != nil { return err } if resp.Error != "" { return fmt.Errorf("patch bucket %s: %s", bucket, resp.Error) } return nil } if s3a.objectWriteLockClient != nil { if owner := s3a.objectWriteLockClient.PrimaryForKey(req.RouteKey); owner != "" { return respErr(s3a.objectTxnOnFiler(owner, req)) } } return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { return respErr(client.ObjectTransaction(context.Background(), req)) }) } // isVersioningEnabled checks if versioning is enabled for a bucket (with caching) func (s3a *S3ApiServer) isVersioningEnabled(bucket string) (bool, error) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { if errCode == s3err.ErrNoSuchBucket { return false, filer_pb.ErrNotFound } return false, fmt.Errorf("failed to get bucket config: %v", errCode) } // Versioning is enabled if explicitly set to "Enabled" OR if object lock is enabled // (since object lock requires versioning to be enabled) return config.Versioning == s3_constants.VersioningEnabled || config.ObjectLockConfig != nil, nil } // isVersioningConfigured checks if versioning has been configured (either Enabled or Suspended) func (s3a *S3ApiServer) isVersioningConfigured(bucket string) (bool, error) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { if errCode == s3err.ErrNoSuchBucket { return false, filer_pb.ErrNotFound } return false, fmt.Errorf("failed to get bucket config: %v", errCode) } // Versioning is configured if explicitly set to either "Enabled" or "Suspended" // OR if object lock is enabled (which forces versioning) return config.Versioning != "" || config.ObjectLockConfig != nil, nil } // isObjectLockEnabled checks if Object Lock is enabled for a bucket (with caching) func (s3a *S3ApiServer) isObjectLockEnabled(bucket string) (bool, error) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { if errCode == s3err.ErrNoSuchBucket { return false, filer_pb.ErrNotFound } return false, fmt.Errorf("failed to get bucket config: %v", errCode) } return config.ObjectLockConfig != nil, nil } // getVersioningState returns the detailed versioning state for a bucket func (s3a *S3ApiServer) getVersioningState(bucket string) (string, error) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { if errCode == s3err.ErrNoSuchBucket { // Signal to callers that the bucket does not exist so they can // decide whether to auto-create it (e.g., in PUT handlers). return "", filer_pb.ErrNotFound } glog.Errorf("getVersioningState: failed to get bucket config for %s: %v", bucket, errCode) return "", fmt.Errorf("failed to get bucket config: %v", errCode) } // If object lock is enabled, versioning must be enabled regardless of explicit setting if config.ObjectLockConfig != nil { return s3_constants.VersioningEnabled, nil } // Return the explicit versioning status (empty string means never configured) return config.Versioning, nil } // getBucketVersioningStatus returns the versioning status for a bucket func (s3a *S3ApiServer) getBucketVersioningStatus(bucket string) (string, s3err.ErrorCode) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { return "", errCode } // Return exactly what's stored - empty string means versioning was never configured // This matches AWS S3 behavior where new buckets have no Status field in GetBucketVersioning response return config.Versioning, s3err.ErrNone } // setBucketVersioningStatus sets the versioning status for a bucket func (s3a *S3ApiServer) setBucketVersioningStatus(bucket, status string) s3err.ErrorCode { errCode := s3a.updateBucketConfig(bucket, func(config *BucketConfig) error { config.Versioning = status return nil }) return errCode } // getBucketOwnership returns the ownership setting for a bucket func (s3a *S3ApiServer) getBucketOwnership(bucket string) (string, s3err.ErrorCode) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { return "", errCode } return s3_constants.EffectiveOwnership(config.Ownership), s3err.ErrNone } // setBucketOwnership sets the ownership setting for a bucket func (s3a *S3ApiServer) setBucketOwnership(bucket, ownership string) s3err.ErrorCode { return s3a.updateBucketConfig(bucket, func(config *BucketConfig) error { config.Ownership = ownership return nil }) } // getCORSConfiguration retrieves CORS configuration with caching func (s3a *S3ApiServer) getCORSConfiguration(bucket string) (*cors.CORSConfiguration, s3err.ErrorCode) { config, errCode := s3a.getBucketConfig(bucket) if errCode != s3err.ErrNone { return nil, errCode } return config.CORS, s3err.ErrNone } // updateCORSConfiguration updates the CORS configuration for a bucket func (s3a *S3ApiServer) updateCORSConfiguration(bucket string, corsConfig *cors.CORSConfiguration) s3err.ErrorCode { // Update using structured API // Note: UpdateBucketCORS -> UpdateBucketMetadata -> setBucketMetadata // already invalidates the cache synchronously after successful update err := s3a.UpdateBucketCORS(bucket, corsConfig) if err != nil { glog.Errorf("updateCORSConfiguration: failed to update CORS config for bucket %s: %v", bucket, err) return s3err.ErrInternalError } return s3err.ErrNone } // removeCORSConfiguration removes the CORS configuration for a bucket func (s3a *S3ApiServer) removeCORSConfiguration(bucket string) s3err.ErrorCode { // Update using structured API // Note: ClearBucketCORS -> UpdateBucketMetadata -> setBucketMetadata // already invalidates the cache synchronously after successful update err := s3a.ClearBucketCORS(bucket) if err != nil { glog.Errorf("removeCORSConfiguration: failed to remove CORS config for bucket %s: %v", bucket, err) return s3err.ErrInternalError } return s3err.ErrNone } // Conversion functions between CORS types and protobuf types // corsRuleToProto converts a CORS rule to protobuf format func corsRuleToProto(rule cors.CORSRule) *s3_pb.CORSRule { return &s3_pb.CORSRule{ AllowedHeaders: rule.AllowedHeaders, AllowedMethods: rule.AllowedMethods, AllowedOrigins: rule.AllowedOrigins, ExposeHeaders: rule.ExposeHeaders, MaxAgeSeconds: int32(getMaxAgeSecondsValue(rule.MaxAgeSeconds)), Id: rule.ID, } } // corsRuleFromProto converts a protobuf CORS rule to standard format func corsRuleFromProto(protoRule *s3_pb.CORSRule) cors.CORSRule { var maxAge *int // Always create the pointer if MaxAgeSeconds is >= 0 // This prevents nil pointer dereferences in tests and matches AWS behavior if protoRule.MaxAgeSeconds >= 0 { age := int(protoRule.MaxAgeSeconds) maxAge = &age } // Only leave maxAge as nil if MaxAgeSeconds was explicitly set to a negative value return cors.CORSRule{ AllowedHeaders: protoRule.AllowedHeaders, AllowedMethods: protoRule.AllowedMethods, AllowedOrigins: protoRule.AllowedOrigins, ExposeHeaders: protoRule.ExposeHeaders, MaxAgeSeconds: maxAge, ID: protoRule.Id, } } // corsConfigToProto converts CORS configuration to protobuf format func corsConfigToProto(config *cors.CORSConfiguration) *s3_pb.CORSConfiguration { if config == nil { return nil } protoRules := make([]*s3_pb.CORSRule, len(config.CORSRules)) for i, rule := range config.CORSRules { protoRules[i] = corsRuleToProto(rule) } return &s3_pb.CORSConfiguration{ CorsRules: protoRules, } } // corsConfigFromProto converts protobuf CORS configuration to standard format func corsConfigFromProto(protoConfig *s3_pb.CORSConfiguration) *cors.CORSConfiguration { if protoConfig == nil { return nil } rules := make([]cors.CORSRule, len(protoConfig.CorsRules)) for i, protoRule := range protoConfig.CorsRules { rules[i] = corsRuleFromProto(protoRule) } return &cors.CORSConfiguration{ CORSRules: rules, } } // getMaxAgeSecondsValue safely extracts max age seconds value func getMaxAgeSecondsValue(maxAge *int) int { if maxAge == nil { return 0 } return *maxAge } // parseAndCachePublicReadStatus parses the ACL and caches the public-read status func parseAndCachePublicReadStatus(acl []byte) bool { var grants []*s3.Grant if err := json.Unmarshal(acl, &grants); err != nil { return false } // Check if any grant gives read permission to "AllUsers" group for _, grant := range grants { if grant.Grantee != nil && grant.Grantee.URI != nil && grant.Permission != nil { // Check for AllUsers group with Read permission if *grant.Grantee.URI == s3_constants.GranteeGroupAllUsers && (*grant.Permission == s3_constants.PermissionRead || *grant.Permission == s3_constants.PermissionFullControl) { return true } } } return false } // getBucketMetadata retrieves bucket metadata as a structured object with caching func (s3a *S3ApiServer) getBucketMetadata(bucket string) (*BucketMetadata, error) { if s3a.bucketConfigCache != nil { // Check negative cache first if s3a.bucketConfigCache.IsNegativelyCached(bucket) { return nil, fmt.Errorf("bucket directory not found %s", bucket) } // Build from the cached parsed config; copy the tags so callers // can't mutate the cached map. if config, found := s3a.bucketConfigCache.Get(bucket); found { metadata := NewBucketMetadata() for k, v := range config.Tags { metadata.Tags[k] = v } metadata.CORS = config.CORS metadata.Encryption = config.Encryption return metadata, nil } } // Load directly from filer return s3a.loadBucketMetadataFromFiler(bucket) } // loadBucketMetadataFromFiler loads bucket metadata directly from the filer func (s3a *S3ApiServer) loadBucketMetadataFromFiler(bucket string) (*BucketMetadata, error) { // Validate bucket name to prevent path traversal attacks if bucket == "" || strings.Contains(bucket, "/") || strings.Contains(bucket, "\\") || strings.Contains(bucket, "..") || strings.Contains(bucket, "~") { return nil, fmt.Errorf("invalid bucket name: %s", bucket) } // Clean the bucket name further to prevent any potential path traversal bucket = filepath.Clean(bucket) if bucket == "." || bucket == ".." { return nil, fmt.Errorf("invalid bucket name: %s", bucket) } // Get bucket directory entry to access its content entry, err := s3a.getBucketEntry(bucket) if err != nil { // Check if this is a "not found" error if errors.Is(err, filer_pb.ErrNotFound) { // Set negative cache for non-existent bucket if s3a.bucketConfigCache != nil { s3a.bucketConfigCache.SetNegativeCache(bucket) } } return nil, fmt.Errorf("error retrieving bucket directory %s: %w", bucket, err) } if entry == nil { // Set negative cache for non-existent bucket if s3a.bucketConfigCache != nil { s3a.bucketConfigCache.SetNegativeCache(bucket) } return nil, fmt.Errorf("bucket directory not found %s", bucket) } // If no content, return empty metadata if len(entry.Content) == 0 { return NewBucketMetadata(), nil } // Unmarshal metadata from protobuf var protoMetadata s3_pb.BucketMetadata if err := proto.Unmarshal(entry.Content, &protoMetadata); err != nil { glog.Errorf("getBucketMetadata: failed to unmarshal protobuf metadata for bucket %s: %v", bucket, err) return nil, fmt.Errorf("failed to unmarshal bucket metadata for %s: %w", bucket, err) } // Convert protobuf CORS to standard CORS corsConfig := corsConfigFromProto(protoMetadata.Cors) // Create and return structured metadata metadata := &BucketMetadata{ Tags: protoMetadata.Tags, CORS: corsConfig, Encryption: protoMetadata.Encryption, } return metadata, nil } // setBucketMetadata stores bucket metadata from a structured object func (s3a *S3ApiServer) setBucketMetadata(bucket string, metadata *BucketMetadata) error { // Validate bucket name to prevent path traversal attacks if bucket == "" || strings.Contains(bucket, "/") || strings.Contains(bucket, "\\") || strings.Contains(bucket, "..") || strings.Contains(bucket, "~") { return fmt.Errorf("invalid bucket name: %s", bucket) } // Clean the bucket name further to prevent any potential path traversal bucket = filepath.Clean(bucket) if bucket == "." || bucket == ".." { return fmt.Errorf("invalid bucket name: %s", bucket) } // Default to empty metadata if nil if metadata == nil { metadata = NewBucketMetadata() } // Create protobuf metadata protoMetadata := &s3_pb.BucketMetadata{ Tags: metadata.Tags, Cors: corsConfigToProto(metadata.CORS), Encryption: metadata.Encryption, } // Marshal metadata to protobuf metadataBytes, err := proto.Marshal(protoMetadata) if err != nil { return fmt.Errorf("failed to marshal bucket metadata to protobuf: %w", err) } // Patch only Entry.content so a concurrent extended-attribute write // (e.g. versioning) is preserved. err = s3a.patchBucketEntry(bucket, &filer_pb.ObjectMutation{ SetContent: true, Content: metadataBytes, }) // Invalidate cache after successful update if err == nil && s3a.bucketConfigCache != nil { s3a.bucketConfigCache.Remove(bucket) s3a.bucketConfigCache.RemoveNegativeCache(bucket) // Remove from negative cache too } return err } // New structured API functions using BucketMetadata // GetBucketMetadata retrieves complete bucket metadata as a structured object func (s3a *S3ApiServer) GetBucketMetadata(bucket string) (*BucketMetadata, error) { return s3a.getBucketMetadata(bucket) } // SetBucketMetadata stores complete bucket metadata from a structured object func (s3a *S3ApiServer) SetBucketMetadata(bucket string, metadata *BucketMetadata) error { return s3a.setBucketMetadata(bucket, metadata) } // UpdateBucketMetadata updates specific parts of bucket metadata while preserving others // // DISTRIBUTED SYSTEM DESIGN NOTE: // This function implements a read-modify-write pattern with "last write wins" semantics. // In the rare case of concurrent updates to different parts of bucket metadata // (e.g., simultaneous tag and CORS updates), the last write may overwrite previous changes. // // This is an acceptable trade-off because: // 1. Bucket metadata updates are infrequent in typical S3 usage // 2. Traditional locking doesn't work in distributed systems across multiple nodes // 3. The complexity of distributed consensus (e.g., Raft) for metadata updates would // be disproportionate to the low frequency of bucket configuration changes // 4. Most bucket operations (tags, CORS, encryption) are typically configured once // during setup rather than being frequently modified // // If stronger consistency is required, consider implementing optimistic concurrency // control with version numbers or ETags at the storage layer. func (s3a *S3ApiServer) UpdateBucketMetadata(bucket string, update func(*BucketMetadata) error) error { // Get current metadata metadata, err := s3a.GetBucketMetadata(bucket) if err != nil { return fmt.Errorf("failed to get current bucket metadata: %w", err) } // Apply update function if err := update(metadata); err != nil { return fmt.Errorf("failed to apply metadata update: %w", err) } // Store updated metadata (last write wins) return s3a.SetBucketMetadata(bucket, metadata) } // Helper functions for specific metadata operations using structured API // UpdateBucketTags sets bucket tags using the structured API func (s3a *S3ApiServer) UpdateBucketTags(bucket string, tags map[string]string) error { return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error { metadata.Tags = tags return nil }) } // UpdateBucketCORS sets bucket CORS configuration using the structured API func (s3a *S3ApiServer) UpdateBucketCORS(bucket string, corsConfig *cors.CORSConfiguration) error { return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error { metadata.CORS = corsConfig return nil }) } // UpdateBucketEncryption sets bucket encryption configuration using the structured API func (s3a *S3ApiServer) UpdateBucketEncryption(bucket string, encryptionConfig *s3_pb.EncryptionConfiguration) error { return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error { metadata.Encryption = encryptionConfig return nil }) } // ClearBucketTags removes all bucket tags using the structured API func (s3a *S3ApiServer) ClearBucketTags(bucket string) error { return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error { metadata.Tags = make(map[string]string) return nil }) } // ClearBucketCORS removes bucket CORS configuration using the structured API func (s3a *S3ApiServer) ClearBucketCORS(bucket string) error { return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error { metadata.CORS = nil return nil }) } // ClearBucketEncryption removes bucket encryption configuration using the structured API func (s3a *S3ApiServer) ClearBucketEncryption(bucket string) error { return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error { metadata.Encryption = nil return nil }) }