diff --git a/crates/e2e_test/src/object_lock/object_lock_test.rs b/crates/e2e_test/src/object_lock/object_lock_test.rs index 199ffb9d5..015a62497 100644 --- a/crates/e2e_test/src/object_lock/object_lock_test.rs +++ b/crates/e2e_test/src/object_lock/object_lock_test.rs @@ -26,10 +26,12 @@ use super::common::*; use aws_sdk_s3::Client; -use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat}; use aws_sdk_s3::types::{ - CompletedMultipartUpload, CompletedPart, Delete, ObjectIdentifier, ObjectLockLegalHoldStatus, ObjectLockRetentionMode, + CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus, + ObjectLockMode, ObjectLockRetentionMode, }; +use chrono::{DateTime, Duration, Utc}; use serial_test::serial; use tracing::info; @@ -79,6 +81,26 @@ fn assert_access_denied(result: Result, context: &s ); } +fn assert_invalid_object_lock_retention_pair(result: Result, context: &str) { + let err = match result { + Ok(_) => panic!("{context}"), + Err(err) => format!("{err:?}"), + }; + assert!( + err.contains("InvalidRequest") || err.contains("must both be supplied"), + "{context}: expected invalid paired retention headers, got: {err}" + ); +} + +fn parse_s3_datetime(value: &aws_sdk_s3::primitives::DateTime) -> DateTime { + let formatted = value + .fmt(DateTimeFormat::DateTime) + .expect("S3 timestamp should format as RFC3339"); + DateTime::parse_from_rfc3339(&formatted) + .expect("S3 timestamp should parse as RFC3339") + .with_timezone(&Utc) +} + // ============================================================================ // DeleteObject Tests // ============================================================================ @@ -537,6 +559,100 @@ async fn test_copy_object_applies_requested_legal_hold() { ); } +#[tokio::test] +#[serial] +async fn test_copy_object_does_not_inherit_source_legal_hold() { + init_logging(); + info!("🧪 Test: CopyObject does not inherit source Legal Hold"); + + let mut env = ObjectLockTestEnvironment::new().await.unwrap(); + env.start_rustfs().await.unwrap(); + + let bucket = "test-copy-object-legal-hold-inherit"; + let src_key = "held-source"; + + env.create_object_lock_bucket(bucket).await.unwrap(); + + let client = env.s3_client(); + put_object_with_legal_hold(&client, bucket, src_key, b"copy-source", ObjectLockLegalHoldStatus::On) + .await + .unwrap(); + + client + .copy_object() + .copy_source(format!("{bucket}/{src_key}")) + .bucket(bucket) + .key("implicit-copy") + .send() + .await + .unwrap(); + + let implicit_legal_hold = client + .get_object_legal_hold() + .bucket(bucket) + .key("implicit-copy") + .send() + .await + .unwrap(); + assert_eq!( + implicit_legal_hold + .legal_hold() + .and_then(|value| value.status()) + .map(|value| value.as_str()), + Some("OFF") + ); + + client + .copy_object() + .copy_source(format!("{bucket}/{src_key}")) + .bucket(bucket) + .key("explicit-on-copy") + .object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On) + .send() + .await + .unwrap(); + + let explicit_on_legal_hold = client + .get_object_legal_hold() + .bucket(bucket) + .key("explicit-on-copy") + .send() + .await + .unwrap(); + assert_eq!( + explicit_on_legal_hold + .legal_hold() + .and_then(|value| value.status()) + .map(|value| value.as_str()), + Some("ON") + ); + + client + .copy_object() + .copy_source(format!("{bucket}/{src_key}")) + .bucket(bucket) + .key("explicit-off-copy") + .object_lock_legal_hold_status(ObjectLockLegalHoldStatus::Off) + .send() + .await + .unwrap(); + + let explicit_off_legal_hold = client + .get_object_legal_hold() + .bucket(bucket) + .key("explicit-off-copy") + .send() + .await + .unwrap(); + assert_eq!( + explicit_off_legal_hold + .legal_hold() + .and_then(|value| value.status()) + .map(|value| value.as_str()), + Some("OFF") + ); +} + #[tokio::test] #[serial] async fn test_copy_object_overwrite_blocked_by_legal_hold() { @@ -1388,9 +1504,372 @@ async fn test_default_retention_applied_to_new_objects() { let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await; assert!(delete_result.is_err(), "Delete should fail for object with default retention applied"); + let retention = client + .get_object_retention() + .bucket(bucket) + .key(key) + .version_id(version_id) + .send() + .await + .unwrap(); + let retention = retention.retention().expect("default retention should be readable"); + assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE")); + assert!( + retention.retain_until_date().is_some(), + "default retention should write a retain-until date" + ); + + let head = client + .head_object() + .bucket(bucket) + .key(key) + .version_id(version_id) + .send() + .await + .unwrap(); + assert_eq!(head.object_lock_mode().map(|value| value.as_str()), Some("GOVERNANCE")); + assert!( + head.object_lock_retain_until_date().is_some(), + "HeadObject should expose the default retention retain-until date" + ); + info!("✅ Test passed: Default retention is applied to new objects"); } +#[tokio::test] +#[serial] +async fn test_put_copy_and_multipart_reject_incomplete_retention_headers() { + init_logging(); + info!("🧪 Test: write paths reject incomplete Object Lock retention headers"); + + let mut env = ObjectLockTestEnvironment::new().await.unwrap(); + env.start_rustfs().await.unwrap(); + + let bucket = "test-incomplete-retention"; + let src_key = "copy-source"; + + env.create_object_lock_bucket(bucket).await.unwrap(); + + let client = env.s3_client(); + client + .put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from(b"copy-source".to_vec())) + .send() + .await + .unwrap(); + + put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None) + .await + .unwrap(); + + assert_invalid_object_lock_retention_pair( + client + .put_object() + .bucket(bucket) + .key("put-mode-only") + .body(ByteStream::from(b"put-body".to_vec())) + .object_lock_mode(ObjectLockMode::Governance) + .send() + .await, + "PutObject with mode only should fail", + ); + + assert_invalid_object_lock_retention_pair( + client + .put_object() + .bucket(bucket) + .key("put-date-only") + .body(ByteStream::from(b"put-body".to_vec())) + .object_lock_retain_until_date(retention_timestamp(30)) + .send() + .await, + "PutObject with retain-until-date only should fail", + ); + + assert_invalid_object_lock_retention_pair( + client + .copy_object() + .copy_source(format!("{bucket}/{src_key}")) + .bucket(bucket) + .key("copy-mode-only") + .object_lock_mode(ObjectLockMode::Governance) + .send() + .await, + "CopyObject with mode only should fail", + ); + + assert_invalid_object_lock_retention_pair( + client + .copy_object() + .copy_source(format!("{bucket}/{src_key}")) + .bucket(bucket) + .key("copy-date-only") + .object_lock_retain_until_date(retention_timestamp(30)) + .send() + .await, + "CopyObject with retain-until-date only should fail", + ); + + assert_invalid_object_lock_retention_pair( + client + .create_multipart_upload() + .bucket(bucket) + .key("multipart-mode-only") + .object_lock_mode(ObjectLockMode::Governance) + .send() + .await, + "CreateMultipartUpload with mode only should fail", + ); + + assert_invalid_object_lock_retention_pair( + client + .create_multipart_upload() + .bucket(bucket) + .key("multipart-date-only") + .object_lock_retain_until_date(retention_timestamp(30)) + .send() + .await, + "CreateMultipartUpload with retain-until-date only should fail", + ); +} + +#[tokio::test] +#[serial] +async fn test_copy_object_retention_uses_destination_policy() { + init_logging(); + info!("🧪 Test: CopyObject retention follows destination policy"); + + let mut env = ObjectLockTestEnvironment::new().await.unwrap(); + env.start_rustfs().await.unwrap(); + + let src_bucket = "test-copy-retention-src"; + let dst_bucket = "test-copy-retention-dst"; + let no_default_bucket = "test-copy-retention-nodef"; + let src_key = "retained-source"; + + env.create_object_lock_bucket(src_bucket).await.unwrap(); + env.create_object_lock_bucket(dst_bucket).await.unwrap(); + env.create_object_lock_bucket(no_default_bucket).await.unwrap(); + + let client = env.s3_client(); + put_object_lock_configuration(&client, dst_bucket, ObjectLockRetentionMode::Governance, Some(1), None) + .await + .unwrap(); + + put_object_with_retention( + &client, + src_bucket, + src_key, + b"copy-source", + ObjectLockRetentionMode::Compliance, + future_retain_until(30), + ) + .await + .unwrap(); + + let copy_started = Utc::now(); + client + .copy_object() + .copy_source(format!("{src_bucket}/{src_key}")) + .bucket(dst_bucket) + .key("default-copy") + .send() + .await + .unwrap(); + + let retention = client + .get_object_retention() + .bucket(dst_bucket) + .key("default-copy") + .send() + .await + .unwrap(); + let retention = retention + .retention() + .expect("destination default retention should be present"); + assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE")); + let retain_until = parse_s3_datetime(retention.retain_until_date().expect("retain-until date should be present")); + assert!( + retain_until < copy_started + Duration::days(3), + "destination default retention should not inherit the source's longer retention" + ); + + client + .copy_object() + .copy_source(format!("{src_bucket}/{src_key}")) + .bucket(dst_bucket) + .key("replace-copy") + .metadata_directive(MetadataDirective::Replace) + .send() + .await + .unwrap(); + + let replace_retention = client + .get_object_retention() + .bucket(dst_bucket) + .key("replace-copy") + .send() + .await + .unwrap(); + assert_eq!( + replace_retention + .retention() + .and_then(|value| value.mode()) + .map(|value| value.as_str()), + Some("GOVERNANCE") + ); + + client + .copy_object() + .copy_source(format!("{src_bucket}/{src_key}")) + .bucket(dst_bucket) + .key("explicit-copy") + .object_lock_mode(ObjectLockMode::Compliance) + .object_lock_retain_until_date(retention_timestamp(30)) + .send() + .await + .unwrap(); + + let explicit_retention = client + .get_object_retention() + .bucket(dst_bucket) + .key("explicit-copy") + .send() + .await + .unwrap(); + assert_eq!( + explicit_retention + .retention() + .and_then(|value| value.mode()) + .map(|value| value.as_str()), + Some("COMPLIANCE") + ); + let explicit_retain_until = parse_s3_datetime( + explicit_retention + .retention() + .and_then(|value| value.retain_until_date()) + .expect("explicit retain-until date should be present"), + ); + assert!( + explicit_retain_until > Utc::now() + Duration::days(20), + "explicit retention should override the shorter bucket default" + ); + + client + .copy_object() + .copy_source(format!("{src_bucket}/{src_key}")) + .bucket(no_default_bucket) + .key("no-default-copy") + .send() + .await + .unwrap(); + + let no_default_retention = client + .get_object_retention() + .bucket(no_default_bucket) + .key("no-default-copy") + .send() + .await + .unwrap(); + let no_default_retention = no_default_retention + .retention() + .expect("retention response should be present"); + assert!(no_default_retention.mode().is_none()); + assert!(no_default_retention.retain_until_date().is_none()); + + put_object_with_retention( + &client, + dst_bucket, + "locked-destination", + b"locked-target", + ObjectLockRetentionMode::Compliance, + future_retain_until(30), + ) + .await + .unwrap(); + + let overwrite_result = client + .copy_object() + .copy_source(format!("{src_bucket}/{src_key}")) + .bucket(dst_bucket) + .key("locked-destination") + .send() + .await; + assert!( + overwrite_result.is_err(), + "CopyObject overwrite should not bypass active destination retention" + ); +} + +#[tokio::test] +#[serial] +async fn test_multipart_default_retention_fixed_at_create() { + init_logging(); + info!("🧪 Test: multipart default retention is fixed at CreateMultipartUpload"); + + let mut env = ObjectLockTestEnvironment::new().await.unwrap(); + env.start_rustfs().await.unwrap(); + + let bucket = "test-multipart-default-drift"; + let key = "multipart-object"; + + env.create_object_lock_bucket(bucket).await.unwrap(); + + let client = env.s3_client(); + put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(1), None) + .await + .unwrap(); + + let create_started = Utc::now(); + let create_output = client.create_multipart_upload().bucket(bucket).key(key).send().await.unwrap(); + let upload_id = create_output.upload_id().unwrap(); + + let upload_part_output = client + .upload_part() + .bucket(bucket) + .key(key) + .upload_id(upload_id) + .part_number(1) + .body(ByteStream::from(b"multipart-body".to_vec())) + .send() + .await + .unwrap(); + + put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(10), None) + .await + .unwrap(); + + let completed_upload = CompletedMultipartUpload::builder() + .parts( + CompletedPart::builder() + .part_number(1) + .e_tag(upload_part_output.e_tag().unwrap_or_default()) + .build(), + ) + .build(); + + client + .complete_multipart_upload() + .bucket(bucket) + .key(key) + .upload_id(upload_id) + .multipart_upload(completed_upload) + .send() + .await + .unwrap(); + + let retention = client.get_object_retention().bucket(bucket).key(key).send().await.unwrap(); + let retention = retention.retention().expect("multipart default retention should be present"); + assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE")); + let retain_until = parse_s3_datetime(retention.retain_until_date().expect("retain-until date should be present")); + assert!( + retain_until < create_started + Duration::days(3), + "CompleteMultipartUpload should keep the default retention calculated at create time" + ); +} + // ============================================================================ // Versioning Auto-Enable Tests // ============================================================================ diff --git a/rustfs/src/app/multipart_usecase.rs b/rustfs/src/app/multipart_usecase.rs index 01d498d7c..8bc9e88e5 100644 --- a/rustfs/src/app/multipart_usecase.rs +++ b/rustfs/src/app/multipart_usecase.rs @@ -505,6 +505,7 @@ impl DefaultMultipartUsecase { metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags); } + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( &bucket, object_lock_legal_hold_status, @@ -515,6 +516,7 @@ impl DefaultMultipartUsecase { { metadata.extend(object_lock_metadata); } + apply_bucket_default_lock_retention(&bucket, &mut metadata, has_explicit_object_lock_retention).await?; let encryption_request = PrepareEncryptionRequest { bucket: &bucket, diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 38377bc00..4422f9a29 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -920,6 +920,11 @@ async fn apply_put_request_object_lock_opts( Ok(()) } +// Shared across Object Lock validation paths to keep the client-facing +// InvalidRequest message consistent. +pub(crate) const ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED: &str = + "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"; + pub(crate) async fn build_put_like_object_lock_metadata( bucket: &str, object_lock_legal_hold_status: Option, @@ -930,20 +935,22 @@ pub(crate) async fn build_put_like_object_lock_metadata( return Ok(None); } - validate_bucket_object_lock_enabled(bucket).await?; - let retention = match (object_lock_mode, object_lock_retain_until_date) { - (Some(mode), retain_until_date) => Some(ObjectLockRetention { + (Some(mode), Some(retain_until_date)) => Some(ObjectLockRetention { mode: Some(ObjectLockRetentionMode::from(mode.as_str().to_string())), - retain_until_date, - }), - (None, Some(retain_until_date)) => Some(ObjectLockRetention { - mode: None, retain_until_date: Some(retain_until_date), }), + (Some(_), None) | (None, Some(_)) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED.to_string(), + )); + } (None, None) => None, }; + validate_bucket_object_lock_enabled(bucket).await?; + let mut eval_metadata = parse_object_lock_retention(retention)?; eval_metadata.extend(parse_object_lock_legal_hold( object_lock_legal_hold_status.map(|status| ObjectLockLegalHold { status: Some(status) }), @@ -1792,6 +1799,7 @@ impl DefaultObjectUsecase { )?; let mut metadata = metadata.unwrap_or_default(); + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); apply_put_request_metadata( &mut metadata, &req.headers, @@ -1806,6 +1814,7 @@ impl DefaultObjectUsecase { tagging, storage_class.clone(), )?; + apply_bucket_default_lock_retention(&bucket, &mut metadata, has_explicit_object_lock_retention).await?; let mut opts: ObjectOptions = put_opts(&bucket, &key, version_id.clone(), &req.headers, metadata.clone()) .await @@ -2759,6 +2768,8 @@ impl DefaultObjectUsecase { } } + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); + remove_object_lock_metadata_for_copy(&mut src_info.user_defined); if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( &bucket, object_lock_legal_hold_status, @@ -2769,6 +2780,7 @@ impl DefaultObjectUsecase { { src_info.user_defined.extend(object_lock_metadata); } + apply_bucket_default_lock_retention(&bucket, &mut src_info.user_defined, has_explicit_object_lock_retention).await?; let mut reader = match decryption_material { Some(material) => { @@ -4292,6 +4304,7 @@ impl DefaultObjectUsecase { .ok() .and_then(|modified_at_secs| OffsetDateTime::from_unix_timestamp(modified_at_secs as i64).ok()); let mut metadata = HashMap::new(); + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); apply_put_request_metadata( &mut metadata, &req.headers, @@ -4306,6 +4319,7 @@ impl DefaultObjectUsecase { tagging.clone(), storage_class.clone(), )?; + apply_bucket_default_lock_retention(&bucket, &mut metadata, has_explicit_object_lock_retention).await?; let mut opts = put_opts(&bucket, &fpath, None, &req.headers, metadata.clone()) .await .map_err(ApiError::from)?; @@ -4510,6 +4524,32 @@ mod tests { } } + #[tokio::test] + async fn build_put_like_object_lock_metadata_rejects_mode_without_retain_until_date() { + let err = build_put_like_object_lock_metadata( + "test-bucket", + None, + Some(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)), + None, + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); + } + + #[tokio::test] + async fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() { + let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1))); + let err = build_put_like_object_lock_metadata("test-bucket", None, None, Some(retain_until)) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); + } + #[test] fn is_put_object_extract_requested_accepts_meta_header() { let mut headers = HeaderMap::new(); diff --git a/rustfs/src/storage/ecfs_extend.rs b/rustfs/src/storage/ecfs_extend.rs index c761f3e0c..05df4ef2a 100644 --- a/rustfs/src/storage/ecfs_extend.rs +++ b/rustfs/src/storage/ecfs_extend.rs @@ -30,7 +30,7 @@ use rustfs_targets::EventName; use rustfs_targets::arn::{TargetID, TargetIDError}; use rustfs_utils::http::{ AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, insert_str, + SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, contains_key_str, insert_str, remove_str, }; use s3s::dto::{ Delimiter, LambdaFunctionConfiguration, NotificationConfigurationFilter, ObjectLockConfiguration, ObjectLockEnabled, @@ -50,21 +50,41 @@ use tracing::{debug, warn}; pub const RFC1123: &[FormatItem<'_>] = format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT"); +fn format_object_lock_timestamp(timestamp: OffsetDateTime) -> String { + timestamp.format(&Rfc3339).unwrap_or_default() +} + +fn has_object_lock_retention_metadata(metadata: &HashMap) -> bool { + metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER) || metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) +} + +pub(crate) fn remove_object_lock_retention_metadata(metadata: &mut HashMap) -> bool { + let removed_mode = metadata.remove(AMZ_OBJECT_LOCK_MODE_LOWER).is_some(); + let removed_retain_until_date = metadata.remove(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).is_some(); + let removed_timestamp = contains_key_str(metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP); + remove_str(metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP); + + removed_mode || removed_retain_until_date || removed_timestamp +} + +fn remove_object_lock_legal_hold_metadata(metadata: &mut HashMap) -> bool { + let removed_legal_hold = metadata.remove(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).is_some(); + let removed_timestamp = contains_key_str(metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP); + remove_str(metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP); + + removed_legal_hold || removed_timestamp +} + +pub(crate) fn remove_object_lock_metadata_for_copy(metadata: &mut HashMap) -> bool { + let removed_retention = remove_object_lock_retention_metadata(metadata); + let removed_legal_hold = remove_object_lock_legal_hold_metadata(metadata); + + removed_retention || removed_legal_hold +} + /// 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. -#[allow(dead_code)] 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) { + if has_object_lock_retention_metadata(metadata) { return; } @@ -87,9 +107,60 @@ pub(crate) fn apply_lock_retention(object_lock_config: Option, + metadata: &mut HashMap, +) -> bool { + if has_object_lock_retention_metadata(metadata) { + return false; + } + + let mut default_retention_metadata = HashMap::new(); + apply_lock_retention(object_lock_configuration, &mut default_retention_metadata); + if default_retention_metadata.is_empty() { + return false; + } + + metadata.extend(default_retention_metadata); + true +} + +pub(crate) async fn apply_bucket_default_lock_retention( + bucket: &str, + metadata: &mut HashMap, + has_explicit_retention: bool, +) -> S3Result<()> { + if has_explicit_retention { + return Ok(()); + } + + if has_object_lock_retention_metadata(metadata) { + return Ok(()); + } + + 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_default_lock_retention_metadata(object_lock_configuration, metadata); + Ok(()) +} + /// Calculate adaptive buffer size with workload profile support. /// /// This enhanced version supports different workload profiles for optimal performance @@ -301,7 +372,7 @@ pub(crate) fn parse_object_lock_retention(retention: Option insert_str( &mut eval_metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, - format!("{}.{:09}Z", now.format(&Rfc3339).unwrap(), now.nanosecond()), + format_object_lock_timestamp(now), ); } Ok(eval_metadata) @@ -328,7 +399,7 @@ pub(crate) fn parse_object_lock_legal_hold(legal_hold: Option(input: T, method: Method) -> S3Request { S3Request { @@ -408,6 +409,116 @@ mod tests { assert_eq!(err.code(), &S3ErrorCode::Custom("InvalidRetentionPeriod".into())); } + #[test] + fn test_apply_default_lock_retention_metadata_applies_bucket_default() { + use std::collections::HashMap; + + let cfg = 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(1), + years: None, + }), + }), + }; + let mut metadata = HashMap::new(); + + assert!(apply_default_lock_retention_metadata(Some(cfg), &mut metadata)); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER), Some(&"COMPLIANCE".to_string())); + let retain_until = metadata + .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) + .and_then(|value| OffsetDateTime::parse(value, &Rfc3339).ok()) + .expect("default retention should write a valid retain-until date"); + assert!(retain_until > OffsetDateTime::now_utc()); + let retention_timestamp = get_str(&metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP) + .and_then(|value| OffsetDateTime::parse(&value, &Rfc3339).ok()) + .expect("default retention should write a valid internal timestamp"); + assert!(retention_timestamp <= OffsetDateTime::now_utc()); + } + + #[test] + fn test_apply_default_lock_retention_metadata_preserves_explicit_retention() { + use std::collections::HashMap; + + let cfg = 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(1), + years: None, + }), + }), + }; + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), "GOVERNANCE".to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + ]); + + assert!(!apply_default_lock_retention_metadata(Some(cfg), &mut metadata)); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER), Some(&"GOVERNANCE".to_string())); + assert_eq!( + metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER), + Some(&"2030-01-01T00:00:00Z".to_string()) + ); + } + + #[test] + fn test_apply_default_lock_retention_metadata_ignores_bucket_without_default() { + use std::collections::HashMap; + + let cfg = ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }; + let mut metadata = HashMap::new(); + + assert!(!apply_default_lock_retention_metadata(Some(cfg), &mut metadata)); + assert!(metadata.is_empty()); + } + + #[test] + fn test_remove_object_lock_retention_metadata_clears_only_retention_fields() { + use std::collections::HashMap; + + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), "GOVERNANCE".to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + (AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "ON".to_string()), + ]); + insert_str(&mut metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, "2026-01-01T00:00:00Z".to_string()); + + assert!(remove_object_lock_retention_metadata(&mut metadata)); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + assert!(!contains_key_str(&metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP)); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER), Some(&"ON".to_string())); + } + + #[test] + fn test_remove_object_lock_metadata_for_copy_clears_retention_and_legal_hold() { + use std::collections::HashMap; + + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), "GOVERNANCE".to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + (AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "ON".to_string()), + ("content-type".to_string(), "application/octet-stream".to_string()), + ]); + insert_str(&mut metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, "2026-01-01T00:00:00Z".to_string()); + insert_str(&mut metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, "2026-01-01T00:00:00Z".to_string()); + + assert!(remove_object_lock_metadata_for_copy(&mut metadata)); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); + assert!(!contains_key_str(&metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP)); + assert!(!contains_key_str(&metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP)); + assert_eq!(metadata.get("content-type"), Some(&"application/octet-stream".to_string())); + } + #[tokio::test] #[ignore = "requires isolated global object layer state"] async fn test_get_object_tagging_returns_internal_error_when_store_uninitialized() { @@ -736,6 +847,10 @@ mod tests { "2030-01-01T00:00:00Z" ); assert!(contains_key_str(&compliance_metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP)); + let retention_timestamp = get_str(&compliance_metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP) + .and_then(|value| OffsetDateTime::parse(&value, &Rfc3339).ok()) + .expect("explicit retention should write a valid internal timestamp"); + assert!(retention_timestamp <= OffsetDateTime::now_utc()); // [3] Normal case: Retention with valid GOVERNANCE mode (future date) let valid_governance_retention = ObjectLockRetention { @@ -795,7 +910,10 @@ mod tests { }; let on_metadata = parse_object_lock_legal_hold(Some(valid_on_legal_hold)).unwrap(); assert_eq!(on_metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).unwrap(), "ON"); - assert!(contains_key_str(&on_metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP)); + let legal_hold_timestamp = get_str(&on_metadata, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP) + .and_then(|value| OffsetDateTime::parse(&value, &Rfc3339).ok()) + .expect("legal hold should write a valid internal timestamp"); + assert!(legal_hold_timestamp <= OffsetDateTime::now_utc()); // [3] Normal case: Legal hold with valid OFF status let valid_off_legal_hold = ObjectLockLegalHold {