fix(ecstore): allow expired delete markers on locked buckets (#3048)

* fix: allow expired delete markers on locked buckets

* fix: reject zero-day del marker expiration
This commit is contained in:
houseme
2026-05-22 09:39:53 +08:00
committed by GitHub
parent 2404bc1657
commit ed2078e025
2 changed files with 234 additions and 12 deletions
+76 -11
View File
@@ -34,7 +34,8 @@ const ERR_LIFECYCLE_NO_RULE: &str = "Lifecycle configuration should have at leas
const ERR_LIFECYCLE_DUPLICATE_ID: &str = "Rule ID must be unique. Found same ID for more than one rule"; const ERR_LIFECYCLE_DUPLICATE_ID: &str = "Rule ID must be unique. Found same ID for more than one rule";
const _ERR_XML_NOT_WELL_FORMED: &str = const _ERR_XML_NOT_WELL_FORMED: &str =
"The XML you provided was not well-formed or did not validate against our published schema"; "The XML you provided was not well-formed or did not validate against our published schema";
const ERR_LIFECYCLE_BUCKET_LOCKED: &str = "ExpiredObjectDeleteMarker is not allowed on a bucket with Object Lock enabled"; const ERR_LIFECYCLE_BUCKET_LOCKED: &str =
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket";
const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules"; const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules";
const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "Lifecycle expiration days must not be negative"; const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "Lifecycle expiration days must not be negative";
const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str = "Lifecycle noncurrent expiration days must not be negative"; const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str = "Lifecycle noncurrent expiration days must not be negative";
@@ -320,14 +321,15 @@ impl Lifecycle for BucketLifecycleConfiguration {
r.validate()?; r.validate()?;
if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref() if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref()
&& object_lock_enabled.as_str() == ObjectLockEnabled::ENABLED && object_lock_enabled.as_str() == ObjectLockEnabled::ENABLED
&& let Some(expiration) = r.expiration.as_ref()
{ {
// Object Lock + ExpiredObjectDeleteMarker conflict if let Some(expiration) = r.expiration.as_ref() {
if expiration.expired_object_delete_marker.is_some_and(|v| v) { // Object Lock + ExpiredObjectAllVersions conflict (MinIO extension)
return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED)); if expiration.expired_object_all_versions.is_some_and(|v| v) {
return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED));
}
} }
// Object Lock + ExpiredObjectAllVersions conflict (MinIO extension) // Object Lock + DelMarkerExpiration conflict
if expiration.expired_object_all_versions.is_some_and(|v| v) { if r.del_marker_expiration.is_some() {
return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED)); return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED));
} }
} }
@@ -2060,11 +2062,11 @@ mod tests {
assert_eq!(event_after.due, Some(future_date)); assert_eq!(event_after.due, Some(future_date));
} }
// --- TASK-002 tests: Object Lock + ExpiredObjectDeleteMarker conflict --- // --- TASK-002 tests: Object Lock + ExpiredObjectDeleteMarker compatibility ---
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn validate_rejects_expired_object_delete_marker_on_locked_bucket() { async fn validate_allows_expired_object_delete_marker_on_locked_bucket() {
let lc = BucketLifecycleConfiguration { let lc = BucketLifecycleConfiguration {
expiry_updated_at: None, expiry_updated_at: None,
rules: vec![LifecycleRule { rules: vec![LifecycleRule {
@@ -2089,8 +2091,9 @@ mod tests {
..Default::default() ..Default::default()
}; };
let err = lc.validate(&locked_config).await.unwrap_err(); lc.validate(&locked_config)
assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); .await
.expect("expected validation to pass for ExpiredObjectDeleteMarker on locked bucket");
} }
#[tokio::test] #[tokio::test]
@@ -2154,6 +2157,68 @@ mod tests {
.expect("expected days-based expiration to pass on locked bucket"); .expect("expected days-based expiration to pass on locked bucket");
} }
#[tokio::test]
#[serial]
async fn validate_rejects_del_marker_expiration_on_locked_bucket() {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(1) }),
filter: None,
id: Some("test-rule".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
let locked_config = ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
..Default::default()
};
let err = lc.validate(&locked_config).await.unwrap_err();
assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED);
}
#[tokio::test]
#[serial]
async fn validate_rejects_zero_day_del_marker_expiration_on_locked_bucket() {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(0) }),
filter: None,
id: Some("test-rule".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
let locked_config = ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
..Default::default()
};
let err = lc.validate(&locked_config).await.unwrap_err();
assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED);
}
// --- TASK-003 tests: Round up to next UTC processing boundary --- // --- TASK-003 tests: Round up to next UTC processing boundary ---
#[test] #[test]
+158 -1
View File
@@ -21,7 +21,7 @@ use futures::stream;
use http::{Extensions, HeaderMap, Method, Uri}; use http::{Extensions, HeaderMap, Method, Uri};
use rustfs_config::ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT; use rustfs_config::ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT;
use rustfs_ecstore::{ use rustfs_ecstore::{
bucket::metadata::BUCKET_LIFECYCLE_CONFIG, bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, OBJECT_LOCK_CONFIG},
bucket::metadata_sys, bucket::metadata_sys,
client::object_api_utils::to_s3s_etag, client::object_api_utils::to_s3s_etag,
client::transition_api::{ReadCloser, ReaderImpl}, client::transition_api::{ReadCloser, ReaderImpl},
@@ -185,6 +185,15 @@ async fn set_bucket_lifecycle_transition_with_tier(
Ok(()) Ok(())
} }
async fn set_bucket_object_lock_enabled(bucket_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let object_lock_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ObjectLockConfiguration>
<ObjectLockEnabled>Enabled</ObjectLockEnabled>
</ObjectLockConfiguration>"#;
metadata_sys::update(bucket_name, OBJECT_LOCK_CONFIG, object_lock_xml.as_bytes().to_vec()).await?;
Ok(())
}
fn expiration_lifecycle_configuration(prefix: &str) -> BucketLifecycleConfiguration { fn expiration_lifecycle_configuration(prefix: &str) -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration { BucketLifecycleConfiguration {
expiry_updated_at: None, expiry_updated_at: None,
@@ -219,6 +228,54 @@ fn expiration_lifecycle_configuration(prefix: &str) -> BucketLifecycleConfigurat
} }
} }
fn expired_object_delete_marker_lifecycle_configuration() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
expiration: Some(LifecycleExpiration {
expired_object_delete_marker: Some(true),
..Default::default()
}),
filter: Some(LifecycleRuleFilter {
prefix: Some("test/".to_string()),
..Default::default()
}),
id: Some("expired-delete-marker".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
}
}
fn del_marker_expiration_lifecycle_configuration(days: i32) -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
abort_incomplete_multipart_upload: None,
del_marker_expiration: Some(DelMarkerExpiration { days: Some(days) }),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
filter: Some(LifecycleRuleFilter {
prefix: Some("test/".to_string()),
..Default::default()
}),
id: Some("del-marker-expiration".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
}
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct MockWarmBackend { struct MockWarmBackend {
objects: Arc<Mutex<HashMap<String, Vec<u8>>>>, objects: Arc<Mutex<HashMap<String, Vec<u8>>>>,
@@ -1027,3 +1084,103 @@ async fn put_bucket_lifecycle_configuration_expires_existing_objects() {
"existing object should be lifecycle-deleted after lifecycle update" "existing object should be lifecycle-deleted after lifecycle update"
); );
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "requires isolated global object layer state"]
async fn put_bucket_lifecycle_configuration_allows_expired_delete_marker_on_object_lock_bucket() {
let (_disk_paths, ecstore) = setup_test_env().await;
let usecase = DefaultBucketUsecase::without_context();
let bucket = format!("test-lock-expired-del-marker-{}", &Uuid::new_v4().simple().to_string()[..8]);
create_test_bucket(&ecstore, bucket.as_str()).await;
set_bucket_object_lock_enabled(bucket.as_str())
.await
.expect("Failed to enable object lock for bucket");
let req = build_request(
PutBucketLifecycleConfigurationInput::builder()
.bucket(bucket.clone())
.lifecycle_configuration(Some(expired_object_delete_marker_lifecycle_configuration()))
.build()
.unwrap(),
Method::PUT,
);
usecase
.execute_put_bucket_lifecycle_configuration(req)
.await
.expect("ExpiredObjectDeleteMarker should be accepted on object-lock bucket");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "requires isolated global object layer state"]
async fn put_bucket_lifecycle_configuration_rejects_del_marker_expiration_on_object_lock_bucket() {
let (_disk_paths, ecstore) = setup_test_env().await;
let usecase = DefaultBucketUsecase::without_context();
let bucket = format!("test-lock-del-marker-exp-{}", &Uuid::new_v4().simple().to_string()[..8]);
create_test_bucket(&ecstore, bucket.as_str()).await;
set_bucket_object_lock_enabled(bucket.as_str())
.await
.expect("Failed to enable object lock for bucket");
let req = build_request(
PutBucketLifecycleConfigurationInput::builder()
.bucket(bucket.clone())
.lifecycle_configuration(Some(del_marker_expiration_lifecycle_configuration(1)))
.build()
.unwrap(),
Method::PUT,
);
let err = usecase
.execute_put_bucket_lifecycle_configuration(req)
.await
.expect_err("DelMarkerExpiration must be rejected on object-lock bucket");
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidArgument);
let message = err.message().unwrap_or_default();
assert!(
message.contains(
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket"
),
"unexpected error message: {message}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "requires isolated global object layer state"]
async fn put_bucket_lifecycle_configuration_rejects_zero_day_del_marker_expiration_on_object_lock_bucket() {
let (_disk_paths, ecstore) = setup_test_env().await;
let usecase = DefaultBucketUsecase::without_context();
let bucket = format!("test-lock-del-marker-exp0-{}", &Uuid::new_v4().simple().to_string()[..8]);
create_test_bucket(&ecstore, bucket.as_str()).await;
set_bucket_object_lock_enabled(bucket.as_str())
.await
.expect("Failed to enable object lock for bucket");
let req = build_request(
PutBucketLifecycleConfigurationInput::builder()
.bucket(bucket.clone())
.lifecycle_configuration(Some(del_marker_expiration_lifecycle_configuration(0)))
.build()
.unwrap(),
Method::PUT,
);
let err = usecase
.execute_put_bucket_lifecycle_configuration(req)
.await
.expect_err("DelMarkerExpiration with zero days must be rejected on object-lock bucket");
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidArgument);
let message = err.message().unwrap_or_default();
assert!(
message.contains(
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket"
),
"unexpected error message: {message}"
);
}