mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 16:28:15 +00:00
fix(object-lock): materialize default retention metadata (#2824)
Signed-off-by: houseme <housemecn@gmail.com> Co-authored-by: cxymds <Cxymds@qq.com> Co-authored-by: loverustfs <hello@rustfs.com> Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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<T, E: std::fmt::Debug>(result: Result<T, E>, context: &s
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_invalid_object_lock_retention_pair<T, E: std::fmt::Debug>(result: Result<T, E>, 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<Utc> {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ObjectLockLegalHoldStatus>,
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String, String>) -> 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<String, String>) -> 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<String, String>) -> 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<String, String>) -> 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<ObjectLockConfiguration>, metadata: &mut HashMap<String, String>) {
|
||||
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<ObjectLockConfigur
|
||||
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);
|
||||
insert_str(metadata, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, format_object_lock_timestamp(now));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_default_lock_retention_metadata(
|
||||
object_lock_configuration: Option<ObjectLockConfiguration>,
|
||||
metadata: &mut HashMap<String, String>,
|
||||
) -> 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<String, String>,
|
||||
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<ObjectLockRetention>
|
||||
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<ObjectLockLegalHol
|
||||
insert_str(
|
||||
&mut eval_metadata,
|
||||
SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP,
|
||||
format!("{}.{:09}Z", now.format(&Rfc3339).unwrap(), now.nanosecond()),
|
||||
format_object_lock_timestamp(now),
|
||||
);
|
||||
}
|
||||
Ok(eval_metadata)
|
||||
|
||||
@@ -19,10 +19,11 @@ mod tests {
|
||||
use crate::storage::ecfs::{FS, validate_object_lock_configuration_input};
|
||||
use crate::storage::s3_api::common::{rustfs_initiator, rustfs_owner};
|
||||
use crate::storage::{
|
||||
apply_cors_headers, check_preconditions, get_adaptive_buffer_size_with_profile, get_buffer_size_opt_in, is_etag_equal,
|
||||
matches_origin_pattern, parse_etag, parse_object_lock_legal_hold, parse_object_lock_retention,
|
||||
process_lambda_configurations, process_queue_configurations, process_topic_configurations,
|
||||
validate_bucket_object_lock_enabled, validate_list_object_unordered_with_delimiter,
|
||||
apply_cors_headers, apply_default_lock_retention_metadata, check_preconditions, get_adaptive_buffer_size_with_profile,
|
||||
get_buffer_size_opt_in, is_etag_equal, matches_origin_pattern, parse_etag, parse_object_lock_legal_hold,
|
||||
parse_object_lock_retention, process_lambda_configurations, process_queue_configurations, process_topic_configurations,
|
||||
remove_object_lock_metadata_for_copy, remove_object_lock_retention_metadata, validate_bucket_object_lock_enabled,
|
||||
validate_list_object_unordered_with_delimiter,
|
||||
};
|
||||
use http::{Extensions, HeaderMap, HeaderValue, Method, StatusCode, Uri};
|
||||
use rustfs_config::MI_B;
|
||||
@@ -30,8 +31,8 @@ mod tests {
|
||||
use rustfs_ecstore::set_disk::DEFAULT_READ_BUFFER_SIZE;
|
||||
use rustfs_ecstore::store_api::ObjectInfo;
|
||||
use rustfs_utils::http::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP,
|
||||
contains_key_str,
|
||||
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, contains_key_str, get_str, insert_str,
|
||||
};
|
||||
use rustfs_zip::CompressionFormat;
|
||||
use s3s::dto::{
|
||||
@@ -43,7 +44,7 @@ mod tests {
|
||||
TopicConfiguration,
|
||||
};
|
||||
use s3s::{S3, S3Error, S3ErrorCode, S3Request, s3_error};
|
||||
use time::OffsetDateTime;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user