From 3130670157418abb2ccd68726cd7eb2ee7773b08 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 7 May 2026 05:14:00 +0800 Subject: [PATCH] test(object-lock): cover default retention delete marker (#2836) --- .../src/object_lock/object_lock_test.rs | 85 +++++++++++++++++++ rustfs/src/app/multipart_usecase.rs | 9 +- rustfs/src/auth.rs | 14 +-- rustfs/src/storage/ecfs.rs | 8 +- rustfs/src/storage/ecfs_test.rs | 10 +-- rustfs/src/storage/options.rs | 18 ++-- 6 files changed, 119 insertions(+), 25 deletions(-) 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 015a62497..b96f04a87 100644 --- a/crates/e2e_test/src/object_lock/object_lock_test.rs +++ b/crates/e2e_test/src/object_lock/object_lock_test.rs @@ -1536,6 +1536,91 @@ async fn test_default_retention_applied_to_new_objects() { info!("โœ… Test passed: Default retention is applied to new objects"); } +#[tokio::test] +#[serial] +async fn test_delete_object_creates_delete_marker_for_default_retained_current_version() { + init_logging(); + info!("๐Ÿงช Test: DeleteObject creates delete marker for default-retained current version"); + + let mut env = ObjectLockTestEnvironment::new().await.unwrap(); + env.start_rustfs().await.unwrap(); + + let bucket = "test-default-retention-delete-marker"; + let key = "default-retained-object"; + let data = b"test data for default-retained current version"; + + env.create_object_lock_bucket(bucket).await.unwrap(); + + let client = env.s3_client(); + put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None) + .await + .unwrap(); + + let put_output = client + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from(data.to_vec())) + .send() + .await + .unwrap(); + let retained_version_id = put_output + .version_id() + .expect("default-retained object should have a version id") + .to_string(); + + let retention = client + .get_object_retention() + .bucket(bucket) + .key(key) + .version_id(&retained_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 delete_marker_output = client.delete_object().bucket(bucket).key(key).send().await.unwrap(); + assert_eq!(delete_marker_output.delete_marker(), Some(true)); + let delete_marker_version_id = delete_marker_output + .version_id() + .expect("delete marker should have a version id") + .to_string(); + + let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await; + assert!(protected_delete.is_err(), "Default-retained version should still reject direct deletion"); + + let retention_after_delete_marker = client + .get_object_retention() + .bucket(bucket) + .key(key) + .version_id(&retained_version_id) + .send() + .await + .unwrap(); + let retention_after_delete_marker = retention_after_delete_marker + .retention() + .expect("default retention should remain readable by version id after delete marker creation"); + assert_eq!(retention_after_delete_marker.mode().map(|value| value.as_str()), Some("GOVERNANCE")); + assert!( + retention_after_delete_marker.retain_until_date().is_some(), + "retained version should keep its retain-until date after delete marker creation" + ); + + delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false) + .await + .unwrap(); + delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true) + .await + .unwrap(); + + info!("โœ… Test passed: Delete marker is allowed while default-retained version stays protected"); +} + #[tokio::test] #[serial] async fn test_put_copy_and_multipart_reject_incomplete_retention_headers() { diff --git a/rustfs/src/app/multipart_usecase.rs b/rustfs/src/app/multipart_usecase.rs index 8bc9e88e5..4e7a535b2 100644 --- a/rustfs/src/app/multipart_usecase.rs +++ b/rustfs/src/app/multipart_usecase.rs @@ -1203,6 +1203,9 @@ mod tests { use super::*; use http::{Extensions, HeaderMap, Method, Uri, header::HeaderValue}; use rustfs_filemeta::ObjectPartInfo; + use rustfs_utils::http::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + }; use std::{collections::HashMap, io::Cursor}; use tokio::io::AsyncReadExt; @@ -1548,9 +1551,9 @@ mod tests { }; for (header_name, header_value) in [ - ("x-amz-object-lock-mode", "GOVERNANCE"), - ("x-amz-object-lock-retain-until-date", "2030-01-01T00:00:00Z"), - ("x-amz-object-lock-legal-hold", "ON"), + (AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, "2030-01-01T00:00:00Z"), + (AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON"), ("x-amz-bypass-governance-retention", "true"), ] { let input = CompleteMultipartUploadInput::builder() diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index 2c4745f66..eed5deee8 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -20,7 +20,9 @@ use rustfs_iam::sys::{ SESSION_POLICY_NAME, get_claims_from_token_with_secret, get_claims_from_token_with_secret_allow_missing_exp, }; use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive}; -use rustfs_utils::http::ip::get_source_ip_raw; +use rustfs_utils::http::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, ip::get_source_ip_raw, +}; use s3s::S3Error; use s3s::S3ErrorCode; use s3s::S3Result; @@ -571,9 +573,9 @@ pub fn get_condition_values_with_query( } for obj_lock in &[ - "x-amz-object-lock-mode", - "x-amz-object-lock-legal-hold", - "x-amz-object-lock-retain-until-date", + AMZ_OBJECT_LOCK_MODE_LOWER, + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, + AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, ] { let values = clone_header .get_all(*obj_lock) @@ -1173,8 +1175,8 @@ mod tests { fn test_get_condition_values_with_object_lock_headers() { let cred = create_test_credentials(); let mut headers = HeaderMap::new(); - headers.insert("x-amz-object-lock-mode", HeaderValue::from_static("GOVERNANCE")); - headers.insert("x-amz-object-lock-retain-until-date", HeaderValue::from_static("2024-12-31T23:59:59Z")); + headers.insert(AMZ_OBJECT_LOCK_MODE_LOWER, HeaderValue::from_static("GOVERNANCE")); + headers.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, HeaderValue::from_static("2024-12-31T23:59:59Z")); let conditions = get_condition_values(&headers, &cred, None, None, None); diff --git a/rustfs/src/storage/ecfs.rs b/rustfs/src/storage/ecfs.rs index 94bcceae2..190a7daa1 100644 --- a/rustfs/src/storage/ecfs.rs +++ b/rustfs/src/storage/ecfs.rs @@ -43,7 +43,9 @@ use rustfs_ecstore::{ }; use rustfs_s3_common::{S3Operation, record_s3_op}; use rustfs_targets::EventName; -use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER; +use rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, +}; use s3s::{S3, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, dto::*, s3_error}; use std::fmt::Debug; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -780,12 +782,12 @@ impl S3 for FS { let mode = object_info .user_defined - .get("x-amz-object-lock-mode") + .get(AMZ_OBJECT_LOCK_MODE_LOWER) .map(|v| ObjectLockRetentionMode::from(v.as_str().to_string())); let retain_until_date = object_info .user_defined - .get("x-amz-object-lock-retain-until-date") + .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) .and_then(|v| OffsetDateTime::parse(v.as_str(), &Rfc3339).ok()) .map(Timestamp::from); diff --git a/rustfs/src/storage/ecfs_test.rs b/rustfs/src/storage/ecfs_test.rs index 4d5a84cd9..c9b3f6e76 100644 --- a/rustfs/src/storage/ecfs_test.rs +++ b/rustfs/src/storage/ecfs_test.rs @@ -841,9 +841,9 @@ mod tests { retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()), }; let compliance_metadata = parse_object_lock_retention(Some(valid_compliance_retention)).unwrap(); - assert_eq!(compliance_metadata.get("x-amz-object-lock-mode").unwrap(), "COMPLIANCE"); + assert_eq!(compliance_metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).unwrap(), "COMPLIANCE"); assert_eq!( - compliance_metadata.get("x-amz-object-lock-retain-until-date").unwrap(), + compliance_metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).unwrap(), "2030-01-01T00:00:00Z" ); assert!(contains_key_str(&compliance_metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP)); @@ -858,7 +858,7 @@ mod tests { retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()), }; let governance_metadata = parse_object_lock_retention(Some(valid_governance_retention)).unwrap(); - assert_eq!(governance_metadata.get("x-amz-object-lock-mode").unwrap(), "GOVERNANCE"); + assert_eq!(governance_metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).unwrap(), "GOVERNANCE"); // [4] Normal case: Retention with None mode (empty string for mode, date not validated) let none_mode_retention = ObjectLockRetention { @@ -866,7 +866,7 @@ mod tests { retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()), }; let none_mode_metadata = parse_object_lock_retention(Some(none_mode_retention)).unwrap(); - assert_eq!(none_mode_metadata.get("x-amz-object-lock-mode").unwrap(), ""); + assert_eq!(none_mode_metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).unwrap(), ""); // [5] Normal case: Retention with None retain_until_date (empty string for date) let none_date_retention = ObjectLockRetention { @@ -874,7 +874,7 @@ mod tests { retain_until_date: None, }; let none_date_metadata = parse_object_lock_retention(Some(none_date_retention)).unwrap(); - assert_eq!(none_date_metadata.get("x-amz-object-lock-retain-until-date").unwrap(), ""); + assert_eq!(none_date_metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).unwrap(), ""); // [6] Error case: Retention with invalid mode (non COMPLIANCE/GOVERNANCE) let invalid_mode_retention = ObjectLockRetention { diff --git a/rustfs/src/storage/options.rs b/rustfs/src/storage/options.rs index 605808820..db9d519c2 100644 --- a/rustfs/src/storage/options.rs +++ b/rustfs/src/storage/options.rs @@ -17,8 +17,10 @@ use http::{HeaderMap, HeaderValue}; use rustfs_ecstore::bucket::versioning_sys::BucketVersioningSys; use rustfs_ecstore::error::Result; use rustfs_ecstore::error::StorageError; -use rustfs_utils::http::AMZ_META_UNENCRYPTED_CONTENT_LENGTH; -use rustfs_utils::http::AMZ_META_UNENCRYPTED_CONTENT_MD5; +use rustfs_utils::http::{ + AMZ_META_UNENCRYPTED_CONTENT_LENGTH, AMZ_META_UNENCRYPTED_CONTENT_MD5, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, + AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, +}; use rustfs_utils::http::{ SUFFIX_FORCE_DELETE, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_SOURCE_DELETEMARKER, SUFFIX_SOURCE_MTIME, SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID, get_header, insert_header_map, @@ -586,9 +588,9 @@ static SUPPORTED_HEADERS: LazyLock> = LazyLock::new(|| { "expires", "x-amz-replication-status", // Object Lock headers - required for S3 Object Lock functionality - "x-amz-object-lock-mode", - "x-amz-object-lock-retain-until-date", - "x-amz-object-lock-legal-hold", + AMZ_OBJECT_LOCK_MODE_LOWER, + AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, ] }); @@ -1325,9 +1327,9 @@ mod tests { "x-amz-tagging", "expires", "x-amz-replication-status", - "x-amz-object-lock-mode", - "x-amz-object-lock-retain-until-date", - "x-amz-object-lock-legal-hold", + AMZ_OBJECT_LOCK_MODE_LOWER, + AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, ]; assert_eq!(*SUPPORTED_HEADERS, expected_headers);