diff --git a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs index 100697d58..1ecea8253 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs @@ -106,7 +106,7 @@ pub fn check_retention_for_modification( None } -pub(crate) fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime { +pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime { let target_year = dt.year() + years; dt.replace_year(target_year) .or_else(|_| { diff --git a/rustfs/src/storage/ecfs.rs b/rustfs/src/storage/ecfs.rs index 357bc4b5c..c6a20ff80 100644 --- a/rustfs/src/storage/ecfs.rs +++ b/rustfs/src/storage/ecfs.rs @@ -31,11 +31,12 @@ use crate::storage::{ }, }; use crate::storage::{ - check_preconditions, create_managed_encryption_material, decrypt_managed_encryption_key, decrypt_multipart_managed_stream, - derive_part_nonce, get_buffer_size_opt_in, get_validated_store, has_replication_rules, is_managed_sse, - parse_object_lock_legal_hold, parse_object_lock_retention, process_lambda_configurations, process_queue_configurations, - process_topic_configurations, strip_managed_encryption_metadata, validate_bucket_object_lock_enabled, - validate_list_object_unordered_with_delimiter, validate_object_key, wrap_response_with_cors, + apply_lock_retention, check_preconditions, create_managed_encryption_material, decrypt_managed_encryption_key, + decrypt_multipart_managed_stream, derive_part_nonce, get_buffer_size_opt_in, get_validated_store, has_replication_rules, + is_managed_sse, parse_object_lock_legal_hold, parse_object_lock_retention, process_lambda_configurations, + process_queue_configurations, process_topic_configurations, strip_managed_encryption_metadata, + validate_bucket_object_lock_enabled, validate_list_object_unordered_with_delimiter, validate_object_key, + wrap_response_with_cors, }; use crate::storage::{entity, parse_part_number_i32_to_usize}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; @@ -4666,6 +4667,23 @@ impl S3 for FS { let mut metadata = metadata.unwrap_or_default(); + let object_lock_configuration = match metadata_sys::get_object_lock_config(&bucket).await { + Ok((cfg, _created)) => Some(cfg), + Err(err) => { + if err == StorageError::ConfigNotFound { + None + } else { + warn!("get_object_lock_config err {:?}", err); + return Err(S3Error::with_message( + S3ErrorCode::InternalError, + "Failed to load Object Lock configuration".to_string(), + )); + } + } + }; + + apply_lock_retention(object_lock_configuration, &mut metadata); + if let Some(content_type) = content_type { metadata.insert("content-type".to_string(), content_type.to_string()); } diff --git a/rustfs/src/storage/ecfs_extend.rs b/rustfs/src/storage/ecfs_extend.rs index 1a1bb92e0..e0fb9f3f5 100644 --- a/rustfs/src/storage/ecfs_extend.rs +++ b/rustfs/src/storage/ecfs_extend.rs @@ -23,6 +23,7 @@ use http::{HeaderMap, HeaderValue, StatusCode}; use metrics::counter; use rustfs_ecstore::bucket::metadata_sys; use rustfs_ecstore::bucket::metadata_sys::get_replication_config; +use rustfs_ecstore::bucket::object_lock::objectlock_sys; use rustfs_ecstore::bucket::replication::ReplicationConfigurationExt; use rustfs_ecstore::error::StorageError; use rustfs_ecstore::store_api::{BucketOptions, ObjectInfo, ObjectToDelete}; @@ -37,9 +38,9 @@ use rustfs_utils::http::{ RESERVED_METADATA_PREFIX_LOWER, }; use s3s::dto::{ - Delimiter, LambdaFunctionConfiguration, NotificationConfigurationFilter, ObjectLockEnabled, ObjectLockLegalHold, - ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode, QueueConfiguration, ServerSideEncryption, - TopicConfiguration, + Delimiter, LambdaFunctionConfiguration, NotificationConfigurationFilter, ObjectLockConfiguration, ObjectLockEnabled, + ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode, QueueConfiguration, + ServerSideEncryption, TopicConfiguration, }; use s3s::{S3Error, S3ErrorCode, S3Response, S3Result}; use serde_urlencoded::from_bytes; @@ -55,6 +56,45 @@ use tracing::{debug, warn}; pub const RFC1123: &[FormatItem<'_>] = format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT"); +/// Apply bucket default Object Lock retention to object metadata if no explicit retention is set. +/// +/// This function implements S3-compatible behavior where objects uploaded to a bucket with +/// default retention configuration automatically inherit the bucket's default retention policy. +/// The retention is only applied if: +/// 1. The bucket has Object Lock enabled +/// 2. The bucket has a default retention rule configured +/// 3. The object metadata does not already contain explicit retention headers +/// +/// # Arguments +/// * `object_lock_config` - Optional bucket Object Lock configuration. If None, no retention is applied. +/// * `metadata` - Mutable reference to object metadata HashMap. Retention headers are inserted here. +pub(crate) fn apply_lock_retention(object_lock_config: Option, metadata: &mut HashMap) { + if metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER) || metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) { + return; + } + + let Some(config) = object_lock_config else { return }; + + if config.object_lock_enabled.as_ref().map(|e| e.as_str()) != Some(ObjectLockEnabled::ENABLED) { + return; + } + + let Some(default_retention) = config.rule.and_then(|r| r.default_retention) else { return }; + let Some(mode) = default_retention.mode else { return }; + + let now = OffsetDateTime::now_utc(); + let retain_until = match (default_retention.days, default_retention.years) { + (Some(days), _) => now.saturating_add(time::Duration::days(days as i64)), + (None, Some(years)) => objectlock_sys::add_years(now, years), + _ => return, + }; + + if let Ok(date_str) = retain_until.format(&Rfc3339) { + metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode.as_str().to_string()); + metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), date_str); + } +} + /// ======================= /// Presigned POST helpers /// ======================= diff --git a/rustfs/src/storage/ecfs_test.rs b/rustfs/src/storage/ecfs_test.rs index d282a398e..b3c2f0e65 100644 --- a/rustfs/src/storage/ecfs_test.rs +++ b/rustfs/src/storage/ecfs_test.rs @@ -798,6 +798,127 @@ mod tests { assert_eq!(result13.unwrap_err().code(), &S3ErrorCode::InvalidArgument); } + #[test] + fn test_apply_lock_retention() { + use crate::storage::ecfs_extend::apply_lock_retention; + use s3s::dto::{DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule}; + use std::collections::HashMap; + + // [1] Normal case: Apply default retention with COMPLIANCE mode and days + let mut metadata = HashMap::new(); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), + days: Some(30), + years: None, + }), + }), + }); + apply_lock_retention(config, &mut metadata); + assert_eq!(metadata.get("x-amz-object-lock-mode"), Some(&"COMPLIANCE".to_string())); + assert!(metadata.contains_key("x-amz-object-lock-retain-until-date")); + + // [2] Normal case: Apply default retention with GOVERNANCE mode and years + let mut metadata = HashMap::new(); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)), + days: None, + years: Some(1), + }), + }), + }); + apply_lock_retention(config, &mut metadata); + assert_eq!(metadata.get("x-amz-object-lock-mode"), Some(&"GOVERNANCE".to_string())); + assert!(metadata.contains_key("x-amz-object-lock-retain-until-date")); + + // [3] Skip case: No configuration provided + let mut metadata = HashMap::new(); + apply_lock_retention(None, &mut metadata); + assert!(!metadata.contains_key("x-amz-object-lock-mode")); + assert!(!metadata.contains_key("x-amz-object-lock-retain-until-date")); + + // [4] Skip case: Object Lock not enabled + let mut metadata = HashMap::new(); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: None, + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), + days: Some(30), + years: None, + }), + }), + }); + apply_lock_retention(config, &mut metadata); + assert!(!metadata.contains_key("x-amz-object-lock-mode")); + + // [5] Skip case: Explicit retention already set (explicit takes precedence) + let mut metadata = HashMap::new(); + metadata.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string()); + metadata.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string()); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), + days: Some(30), + years: None, + }), + }), + }); + apply_lock_retention(config, &mut metadata); + // Explicit retention should remain unchanged + assert_eq!(metadata.get("x-amz-object-lock-mode"), Some(&"GOVERNANCE".to_string())); + assert_eq!( + metadata.get("x-amz-object-lock-retain-until-date"), + Some(&"2030-01-01T00:00:00Z".to_string()) + ); + + // [6] Skip case: No default retention configured + let mut metadata = HashMap::new(); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { default_retention: None }), + }); + apply_lock_retention(config, &mut metadata); + assert!(!metadata.contains_key("x-amz-object-lock-mode")); + + // [7] Skip case: No retention mode specified + let mut metadata = HashMap::new(); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: None, + days: Some(30), + years: None, + }), + }), + }); + apply_lock_retention(config, &mut metadata); + assert!(!metadata.contains_key("x-amz-object-lock-mode")); + + // [8] Skip case: No retention period specified (neither days nor years) + let mut metadata = HashMap::new(); + let config = Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), + days: None, + years: None, + }), + }), + }); + apply_lock_retention(config, &mut metadata); + assert!(!metadata.contains_key("x-amz-object-lock-mode")); + } + // Note: S3Request structure is complex and requires many fields. // For real testing, we would need proper integration test setup. // Removing this test as it requires too much S3 infrastructure setup.