Files
rustfs/crates/e2e_test/src/object_lock/object_lock_test.rs
T
2026-08-01 10:14:32 +08:00

2373 lines
73 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#![cfg(test)]
//! Object Lock E2E Tests
//!
//! These tests verify the complete Object Lock workflow including:
//! - COMPLIANCE mode blocks deletion
//! - GOVERNANCE mode blocks deletion without bypass header
//! - GOVERNANCE mode allows deletion with bypass header
//! - Legal Hold blocks deletion
//! - PutObjectRetention modification restrictions
//! - Default bucket retention is applied to new objects
use super::common::*;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
use aws_sdk_s3::types::{
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
ObjectLockMode, ObjectLockRetentionMode,
};
use chrono::{DateTime, Duration, Utc};
use serial_test::serial;
use tracing::info;
/// Initialize test logging
fn init_logging() {
let _ = tracing_subscriber::fmt()
.with_env_filter("e2e_test=debug,rustfs=info")
.try_init();
}
async fn put_bucket_deny_policy(
client: &Client,
bucket: &str,
sid: &str,
action: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Sid": sid,
"Effect": "Deny",
"Principal": "*",
"Action": action,
"Resource": format!("arn:aws:s3:::{}/*", bucket)
}]
})
.to_string();
client.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
Ok(())
}
fn retention_timestamp(days: i64) -> aws_sdk_s3::primitives::DateTime {
let retain_until = future_retain_until(days).format("%Y-%m-%dT%H:%M:%SZ").to_string();
aws_sdk_s3::primitives::DateTime::from_str(&retain_until, aws_sdk_s3::primitives::DateTimeFormat::DateTime)
.expect("retention timestamp should parse")
}
fn assert_access_denied<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("AccessDenied") || err.to_lowercase().contains("access denied"),
"{context}: expected AccessDenied, got: {err}"
);
}
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
// ============================================================================
#[tokio::test]
#[serial]
async fn test_delete_object_blocked_by_compliance_retention() {
init_logging();
info!("🧪 Test: DeleteObject blocked by COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-compliance-delete";
let key = "locked-object";
let data = b"test data for compliance mode";
// Create bucket with Object Lock enabled
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with COMPLIANCE retention (30 days in future)
let retain_until = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until)
.await
.unwrap();
// Attempt to delete - should fail
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
assert!(delete_result.is_err(), "Delete should fail for COMPLIANCE locked object");
// Even with bypass header, COMPLIANCE should not allow deletion
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
assert!(
delete_with_bypass_result.is_err(),
"Delete with bypass should still fail for COMPLIANCE mode"
);
info!("✅ Test passed: COMPLIANCE retention blocks deletion");
}
#[tokio::test]
#[serial]
async fn test_delete_object_blocked_by_governance_without_bypass() {
init_logging();
info!("🧪 Test: DeleteObject blocked by GOVERNANCE retention without bypass");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-no-bypass";
let key = "governance-locked-object";
let data = b"test data for governance mode";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention
let retain_until = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
.await
.unwrap();
// Attempt to delete without bypass - should fail
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
assert!(delete_result.is_err(), "Delete without bypass should fail for GOVERNANCE locked object");
info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass");
}
#[tokio::test]
#[serial]
async fn test_delete_object_allowed_by_governance_with_bypass() {
init_logging();
info!("🧪 Test: DeleteObject allowed by GOVERNANCE retention with bypass");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-with-bypass";
let key = "governance-bypass-object";
let data = b"test data for governance bypass";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention
let retain_until = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
.await
.unwrap();
// Delete with bypass header - should succeed
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
// Verify object is deleted
let head_result = client
.head_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await;
assert!(head_result.is_err(), "Object should be deleted");
info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass");
}
#[tokio::test]
#[serial]
async fn test_delete_object_creates_delete_marker_for_retained_current_version() {
init_logging();
info!("🧪 Test: DeleteObject creates delete marker for retained current version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-retention-delete-marker";
let key = "retained-object";
let data = b"test data for retained current version";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let retain_until = future_retain_until(30);
let retained_version_id =
put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until)
.await
.unwrap();
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(), "Retained version should still reject direct deletion");
delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false)
.await
.unwrap();
let still_protected = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
assert!(
still_protected.is_err(),
"Retained version should remain protected after delete marker removal"
);
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true)
.await
.unwrap();
info!("✅ Test passed: Delete marker is allowed while retained version stays protected");
}
#[tokio::test]
#[serial]
async fn test_delete_object_blocked_by_legal_hold() {
init_logging();
info!("🧪 Test: DeleteObject blocked by Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-legal-hold-delete";
let key = "legal-hold-object";
let data = b"test data for legal hold";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with legal hold ON
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::On)
.await
.unwrap();
// Attempt to delete - should fail (legal hold cannot be bypassed)
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
assert!(delete_result.is_err(), "Delete should fail for legal hold object");
// Even with bypass header, legal hold should block deletion
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
assert!(delete_with_bypass_result.is_err(), "Delete with bypass should still fail for legal hold");
info!("✅ Test passed: Legal Hold blocks deletion");
}
#[tokio::test]
#[serial]
async fn test_delete_object_allowed_with_legal_hold_off() {
init_logging();
info!("🧪 Test: DeleteObject allowed with Legal Hold OFF");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-legal-hold-off-delete";
let key = "legal-hold-off-object";
let data = b"test data for legal hold off";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::Off)
.await
.unwrap();
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
assert!(delete_result.is_ok(), "Delete should succeed when legal hold is OFF");
let head_result = client
.head_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await;
assert!(head_result.is_err(), "Object should be deleted when legal hold is OFF");
info!("✅ Test passed: Legal Hold OFF allows deletion");
}
#[tokio::test]
#[serial]
async fn test_delete_object_after_legal_hold_removed() {
init_logging();
info!("🧪 Test: DeleteObject succeeds after Legal Hold is removed");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-legal-hold-remove";
let key = "legal-hold-remove-object";
let data = b"test data for legal hold removal";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with legal hold ON
let version_id = put_object_with_legal_hold(&client, bucket, key, data, ObjectLockLegalHoldStatus::On)
.await
.unwrap();
// Remove legal hold
put_object_legal_hold(&client, bucket, key, Some(&version_id), ObjectLockLegalHoldStatus::Off)
.await
.unwrap();
// Now deletion should succeed
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
assert!(delete_result.is_ok(), "Delete should succeed after legal hold is removed");
info!("✅ Test passed: Deletion succeeds after Legal Hold removal");
}
#[tokio::test]
#[serial]
async fn test_get_object_legal_hold_returns_updated_status() {
init_logging();
info!("🧪 Test: GetObjectLegalHold returns updated status");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-get-legal-hold";
let key = "legal-hold-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let version_id = put_object_with_legal_hold(&client, bucket, key, b"test data", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let on_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.unwrap();
assert_eq!(
on_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON")
);
put_object_legal_hold(&client, bucket, key, Some(&version_id), ObjectLockLegalHoldStatus::Off)
.await
.unwrap();
let off_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.unwrap();
assert_eq!(
off_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("OFF")
);
}
#[tokio::test]
#[serial]
async fn test_get_object_retention_returns_configured_values() {
init_logging();
info!("🧪 Test: GetObjectRetention returns configured values");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-get-retention";
let key = "retained-object";
let retain_until = future_retain_until(30);
let retain_until_expected = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let version_id =
put_object_with_retention(&client, bucket, key, b"test data", ObjectLockRetentionMode::Governance, retain_until)
.await
.unwrap();
let retention = client
.get_object_retention()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.unwrap();
let retention = retention.retention().expect("retention should be present");
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
assert_eq!(
retention
.retain_until_date()
.expect("retain_until_date should be present")
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
.unwrap(),
retain_until_expected
);
}
// ============================================================================
// Put/Copy/Multipart Legal Hold Tests
// ============================================================================
// AWS semantics (PR #3179): Object Lock buckets are always versioned, so a
// plain PUT/copy/multipart write to a held or retained key succeeds by
// creating a new current version. The lock protects the existing version
// from deletion; it never blocks new versions.
#[tokio::test]
#[serial]
async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
init_logging();
info!("🧪 Test: PutObject overwrite of a legal-hold version creates a new version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-put-overwrite-legal-hold";
let key = "overwrite-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let held_version_id = put_object_with_legal_hold(&client, bucket, key, b"locked-body", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let overwrite_output = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(b"replacement-body".to_vec()))
.send()
.await
.expect("PutObject overwrite should succeed by creating a new version");
let new_version_id = overwrite_output
.version_id()
.expect("versioned put should return a version id")
.to_string();
assert_ne!(new_version_id, held_version_id, "overwrite must create a distinct version");
let current_body = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.unwrap()
.body
.collect()
.await
.unwrap()
.into_bytes();
assert_eq!(current_body.as_ref(), b"replacement-body");
let held_body = client
.get_object()
.bucket(bucket)
.key(key)
.version_id(&held_version_id)
.send()
.await
.unwrap()
.body
.collect()
.await
.unwrap()
.into_bytes();
assert_eq!(held_body.as_ref(), b"locked-body", "held version must keep its original content");
let held_legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(key)
.version_id(&held_version_id)
.send()
.await
.unwrap();
assert_eq!(
held_legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON"),
"held version must keep its legal hold after the overwrite"
);
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
assert!(delete_result.is_err(), "held version must stay delete-protected after the overwrite");
}
#[tokio::test]
#[serial]
async fn test_copy_object_applies_requested_legal_hold() {
init_logging();
info!("🧪 Test: CopyObject applies requested Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-copy-object-legal-hold";
let src_key = "src-object";
let dst_key = "dst-object";
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();
client
.copy_object()
.copy_source(format!("{bucket}/{src_key}"))
.bucket(bucket)
.key(dst_key)
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await
.unwrap();
let legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(dst_key)
.send()
.await
.unwrap();
assert_eq!(
legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON")
);
}
#[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_creates_new_version_under_legal_hold() {
init_logging();
info!("🧪 Test: CopyObject overwrite of a legal-hold destination creates a new version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-copy-overwrite-legal-hold";
let src_key = "src-object";
let dst_key = "dst-object";
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();
let held_version_id =
put_object_with_legal_hold(&client, bucket, dst_key, b"locked-destination", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let copy_output = client
.copy_object()
.copy_source(format!("{bucket}/{src_key}"))
.bucket(bucket)
.key(dst_key)
.send()
.await
.expect("CopyObject overwrite should succeed by creating a new destination version");
let new_version_id = copy_output
.version_id()
.expect("versioned copy should return a version id")
.to_string();
assert_ne!(new_version_id, held_version_id, "copy must create a distinct destination version");
let current_body = client
.get_object()
.bucket(bucket)
.key(dst_key)
.send()
.await
.unwrap()
.body
.collect()
.await
.unwrap()
.into_bytes();
assert_eq!(current_body.as_ref(), b"copy-source");
let held_legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(dst_key)
.version_id(&held_version_id)
.send()
.await
.unwrap();
assert_eq!(
held_legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON"),
"held destination version must keep its legal hold after the copy"
);
let delete_result = delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await;
assert!(delete_result.is_err(), "held destination version must stay delete-protected");
}
#[tokio::test]
#[serial]
async fn test_create_multipart_upload_applies_requested_legal_hold() {
init_logging();
info!("🧪 Test: CreateMultipartUpload applies requested Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-multipart-legal-hold";
let key = "multipart-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let create_output = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.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();
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 legal_hold = client.get_object_legal_hold().bucket(bucket).key(key).send().await.unwrap();
assert_eq!(
legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON")
);
}
#[tokio::test]
#[serial]
async fn test_create_multipart_upload_creates_new_version_under_compliance_retention() {
init_logging();
info!("🧪 Test: CreateMultipartUpload over a COMPLIANCE-retained key creates a new version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-multipart-create-compliance";
let key = "protected-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let retained_version_id = put_object_with_retention(
&client,
bucket,
key,
b"locked-destination",
ObjectLockRetentionMode::Compliance,
future_retain_until(30),
)
.await
.unwrap();
let create_output = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.send()
.await
.expect("CreateMultipartUpload should succeed; completion will create a new version");
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();
let completed_upload = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(upload_part_output.e_tag().unwrap_or_default())
.build(),
)
.build();
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await
.expect("CompleteMultipartUpload should succeed by creating a new version");
let new_version_id = complete_output
.version_id()
.expect("versioned multipart completion should return a version id")
.to_string();
assert_ne!(new_version_id, retained_version_id, "completion must create a distinct version");
// COMPLIANCE retention on the previous version survives the overwrite and
// cannot be bypassed.
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
assert!(
delete_result.is_err(),
"retained version must stay delete-protected even with governance bypass"
);
}
#[tokio::test]
#[serial]
async fn test_delete_completed_multipart_object_blocked_by_legal_hold() {
init_logging();
info!("🧪 Test: Delete completed multipart object blocked by Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-multipart-delete-legal-hold";
let key = "multipart-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let create_output = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.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();
let completed_upload = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(upload_part_output.e_tag().unwrap_or_default())
.build(),
)
.build();
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await
.unwrap();
let version_id = complete_output.version_id().expect("multipart object should be versioned");
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by legal hold");
}
#[tokio::test]
#[serial]
async fn test_delete_completed_multipart_object_blocked_by_retention() {
init_logging();
info!("🧪 Test: Delete completed multipart object blocked by retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-multipart-delete-retention";
let key = "multipart-object";
let retain_until = retention_timestamp(30);
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
let create_output = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Compliance)
.object_lock_retain_until_date(retain_until)
.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();
let completed_upload = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(upload_part_output.e_tag().unwrap_or_default())
.build(),
)
.build();
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await
.unwrap();
let version_id = complete_output.version_id().expect("multipart object should be versioned");
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by retention");
}
#[tokio::test]
#[serial]
async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
init_logging();
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-complete-multipart-legal-hold";
let key = "multipart-race-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
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();
let held_version_id =
put_object_with_legal_hold(&client, bucket, key, b"locked-current-version", ObjectLockLegalHoldStatus::On)
.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();
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await
.expect("CompleteMultipartUpload should succeed by creating a new version over the held one");
let new_version_id = complete_output
.version_id()
.expect("versioned multipart completion should return a version id")
.to_string();
assert_ne!(new_version_id, held_version_id, "completion must create a distinct version");
let held_legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(key)
.version_id(&held_version_id)
.send()
.await
.unwrap();
assert_eq!(
held_legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON"),
"held version must keep its legal hold after multipart completion"
);
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
assert!(delete_result.is_err(), "held version must stay delete-protected");
}
#[tokio::test]
#[serial]
async fn test_complete_multipart_upload_creates_new_version_under_compliance_retention() {
init_logging();
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-complete-multipart-compliance";
let key = "multipart-race-object";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
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();
let retained_version_id = put_object_with_retention(
&client,
bucket,
key,
b"locked-current-version",
ObjectLockRetentionMode::Compliance,
future_retain_until(30),
)
.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();
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await
.expect("CompleteMultipartUpload should succeed by creating a new version over the retained one");
let new_version_id = complete_output
.version_id()
.expect("versioned multipart completion should return a version id")
.to_string();
assert_ne!(new_version_id, retained_version_id, "completion must create a distinct version");
// COMPLIANCE retention on the previous version survives the overwrite and
// cannot be bypassed.
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
assert!(
delete_result.is_err(),
"retained version must stay delete-protected even with governance bypass"
);
}
#[tokio::test]
#[serial]
async fn test_write_paths_require_put_object_legal_hold_permission() {
init_logging();
info!("🧪 Test: write paths require PutObjectLegalHold permission");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-legal-hold-permissions";
let src_key = "src-object";
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_bucket_deny_policy(&client, bucket, "DenyPutObjectLegalHold", "s3:PutObjectLegalHold")
.await
.unwrap();
assert_access_denied(
client
.put_object()
.bucket(bucket)
.key("put-target")
.body(ByteStream::from(b"put-body".to_vec()))
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await,
"PutObject with legal hold should require s3:PutObjectLegalHold",
);
assert_access_denied(
client
.copy_object()
.copy_source(format!("{bucket}/{src_key}"))
.bucket(bucket)
.key("copy-target")
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await,
"CopyObject with legal hold should require s3:PutObjectLegalHold",
);
assert_access_denied(
client
.create_multipart_upload()
.bucket(bucket)
.key("multipart-target")
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await,
"CreateMultipartUpload with legal hold should require s3:PutObjectLegalHold",
);
}
#[tokio::test]
#[serial]
async fn test_write_paths_require_put_object_retention_permission() {
init_logging();
info!("🧪 Test: write paths require PutObjectRetention permission");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-retention-permissions";
let src_key = "src-object";
let retain_until = retention_timestamp(30);
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_bucket_deny_policy(&client, bucket, "DenyPutObjectRetention", "s3:PutObjectRetention")
.await
.unwrap();
assert_access_denied(
client
.put_object()
.bucket(bucket)
.key("put-target")
.body(ByteStream::from(b"put-body".to_vec()))
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until)
.send()
.await,
"PutObject with retention should require s3:PutObjectRetention",
);
assert_access_denied(
client
.copy_object()
.copy_source(format!("{bucket}/{src_key}"))
.bucket(bucket)
.key("copy-target")
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until)
.send()
.await,
"CopyObject with retention should require s3:PutObjectRetention",
);
assert_access_denied(
client
.create_multipart_upload()
.bucket(bucket)
.key("multipart-target")
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until)
.send()
.await,
"CreateMultipartUpload with retention should require s3:PutObjectRetention",
);
}
// ============================================================================
// DeleteObjects (Batch Delete) Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_delete_objects_mixed_locked_unlocked() {
init_logging();
info!("🧪 Test: DeleteObjects with mixed locked and unlocked objects");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-batch-delete-mixed";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put unlocked object
let unlocked_key = "unlocked-object";
client
.put_object()
.bucket(bucket)
.key(unlocked_key)
.body(ByteStream::from(b"unlocked data".to_vec()))
.send()
.await
.unwrap();
// Put locked object with COMPLIANCE
let locked_key = "locked-object";
let retain_until = future_retain_until(30);
let locked_version = put_object_with_retention(
&client,
bucket,
locked_key,
b"locked data",
ObjectLockRetentionMode::Compliance,
retain_until,
)
.await
.unwrap();
// Batch delete both objects
let delete = Delete::builder()
.objects(ObjectIdentifier::builder().key(unlocked_key).build().unwrap())
.objects(
ObjectIdentifier::builder()
.key(locked_key)
.version_id(&locked_version)
.build()
.unwrap(),
)
.build()
.unwrap();
let result = client.delete_objects().bucket(bucket).delete(delete).send().await.unwrap();
// Unlocked object should be deleted
let deleted_count = result.deleted().len();
let error_count = result.errors().len();
info!("Deleted: {}, Errors: {}", deleted_count, error_count);
// Should have 1 successful delete (unlocked) and 1 error (locked)
assert_eq!(deleted_count, 1, "One object should be deleted");
assert_eq!(error_count, 1, "One object should have error (locked)");
// Verify locked object still exists
let head_result = client
.head_object()
.bucket(bucket)
.key(locked_key)
.version_id(&locked_version)
.send()
.await;
assert!(head_result.is_ok(), "Locked object should still exist");
info!("✅ Test passed: Batch delete correctly handles mixed locked/unlocked objects");
}
// ============================================================================
// PutObjectRetention Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_put_retention_compliance_cannot_shorten() {
init_logging();
info!("🧪 Test: PutObjectRetention cannot shorten COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-retention-shorten";
let key = "compliance-shorten-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with COMPLIANCE retention for 60 days
let retain_until_60 = future_retain_until(60);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until_60)
.await
.unwrap();
// Try to shorten to 30 days - should fail
let retain_until_30 = future_retain_until(30);
let shorten_result = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Compliance,
retain_until_30,
false,
)
.await;
assert!(shorten_result.is_err(), "Shortening COMPLIANCE retention should fail");
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
}
#[tokio::test]
#[serial]
async fn test_put_retention_compliance_can_extend() {
init_logging();
info!("🧪 Test: PutObjectRetention can extend COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-retention-extend";
let key = "compliance-extend-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with COMPLIANCE retention for 30 days
let retain_until_30 = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Compliance, retain_until_30)
.await
.unwrap();
// Extend to 60 days - should succeed
let retain_until_60 = future_retain_until(60);
let extend_result = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Compliance,
retain_until_60,
false,
)
.await;
assert!(extend_result.is_ok(), "Extending COMPLIANCE retention should succeed");
info!("✅ Test passed: Can extend COMPLIANCE retention");
}
#[tokio::test]
#[serial]
async fn test_put_retention_governance_extend_without_bypass() {
init_logging();
info!("🧪 Test: PutObjectRetention on GOVERNANCE can extend without bypass");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-extend";
let key = "governance-extend-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention for 30 days
let retain_until_30 = future_retain_until(30);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until_30)
.await
.unwrap();
// Extend to 60 days without bypass - should succeed (AWS S3 behavior)
let retain_until_60 = future_retain_until(60);
let extend_without_bypass = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Governance,
retain_until_60,
false,
)
.await;
assert!(
extend_without_bypass.is_ok(),
"Extending GOVERNANCE retention without bypass should succeed"
);
info!("✅ Test passed: GOVERNANCE retention can be extended without bypass");
}
#[tokio::test]
#[serial]
async fn test_put_retention_governance_shorten_requires_bypass() {
init_logging();
info!("🧪 Test: PutObjectRetention on GOVERNANCE requires bypass to shorten");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-governance-shorten";
let key = "governance-shorten-object";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with GOVERNANCE retention for 60 days
let retain_until_60 = future_retain_until(60);
let version_id = put_object_with_retention(&client, bucket, key, data, ObjectLockRetentionMode::Governance, retain_until_60)
.await
.unwrap();
// Try to shorten to 30 days without bypass - should fail
let retain_until_30 = future_retain_until(30);
let shorten_without_bypass = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Governance,
retain_until_30,
false,
)
.await;
assert!(
shorten_without_bypass.is_err(),
"Shortening GOVERNANCE retention without bypass should fail"
);
// Shorten with bypass - should succeed
let shorten_with_bypass = put_object_retention(
&client,
bucket,
key,
Some(&version_id),
ObjectLockRetentionMode::Governance,
retain_until_30,
true,
)
.await;
assert!(shorten_with_bypass.is_ok(), "Shortening GOVERNANCE retention with bypass should succeed");
info!("✅ Test passed: GOVERNANCE retention shortening requires bypass");
}
// ============================================================================
// Default Retention Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_default_retention_applied_to_new_objects() {
init_logging();
info!("🧪 Test: Default retention is applied to new objects");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-default-retention";
let key = "object-with-default-retention";
let data = b"test data";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Set default retention: GOVERNANCE for 30 days
put_object_lock_configuration(&client, bucket, ObjectLockRetentionMode::Governance, Some(30), None)
.await
.unwrap();
// Put object without explicit retention
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.send()
.await
.unwrap();
let version_id = response.version_id().unwrap();
// Try to delete without bypass - should fail due to default retention
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_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() {
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());
let retained_version_id = put_object_with_retention(
&client,
dst_bucket,
"locked-destination",
b"locked-target",
ObjectLockRetentionMode::Compliance,
future_retain_until(30),
)
.await
.unwrap();
let overwrite_output = client
.copy_object()
.copy_source(format!("{src_bucket}/{src_key}"))
.bucket(dst_bucket)
.key("locked-destination")
.send()
.await
.expect("CopyObject overwrite should succeed by creating a new destination version");
let overwrite_version_id = overwrite_output
.version_id()
.expect("versioned copy should return a version id")
.to_string();
assert_ne!(
overwrite_version_id, retained_version_id,
"copy must create a distinct destination version"
);
// COMPLIANCE retention on the previous destination version survives the
// overwrite and cannot be bypassed.
let delete_result =
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await;
assert!(
delete_result.is_err(),
"retained destination version must stay delete-protected even with governance bypass"
);
}
#[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
// ============================================================================
#[tokio::test]
#[serial]
async fn test_unretained_object_lock_object_delete_and_bucket_cleanup() {
init_logging();
info!("🧪 Test: Unretained Object Lock object delete and bucket cleanup (Issue #5339)");
let mut env = ObjectLockTestEnvironment::new()
.await
.expect("failed to create Object Lock test environment");
env.start_rustfs().await.expect("failed to start RustFS");
let bucket = "test-object-lock-delete-cleanup";
let key = "unretained-object";
env.create_object_lock_bucket(bucket)
.await
.expect("failed to create Object Lock bucket");
let client = env.s3_client();
let put_response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"unretained data"))
.send()
.await
.expect("failed to upload unretained object");
let object_version_id = put_response
.version_id()
.expect("Object Lock buckets must create versioned objects")
.to_string();
let delete_response = client
.delete_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect("failed to create delete marker");
assert_eq!(delete_response.delete_marker(), Some(true));
let delete_marker_version_id = delete_response
.version_id()
.expect("Deleting without a version ID must create a delete marker")
.to_string();
let get_error = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("GET must not return an object hidden by a delete marker");
assert_eq!(get_error.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(get_error.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
let listed_objects = client
.list_objects_v2()
.bucket(bucket)
.send()
.await
.expect("failed to list current objects");
assert!(
listed_objects.contents().iter().all(|object| object.key() != Some(key)),
"ListObjectsV2 must hide objects whose latest version is a delete marker"
);
let listed_versions = client
.list_object_versions()
.bucket(bucket)
.send()
.await
.expect("failed to list object versions");
assert!(
listed_versions
.versions()
.iter()
.any(|version| version.key() == Some(key) && version.version_id() == Some(object_version_id.as_str())),
"The data version must remain until it is explicitly deleted"
);
assert!(
listed_versions
.delete_markers()
.iter()
.any(|marker| marker.key() == Some(key) && marker.version_id() == Some(delete_marker_version_id.as_str())),
"ListObjectVersions must expose the delete marker"
);
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(object_version_id)
.send()
.await
.expect("failed to delete the data version");
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(delete_marker_version_id)
.send()
.await
.expect("failed to delete the delete marker");
let remaining_versions = client
.list_object_versions()
.bucket(bucket)
.send()
.await
.expect("failed to list versions after cleanup");
assert!(remaining_versions.versions().is_empty());
assert!(remaining_versions.delete_markers().is_empty());
client
.delete_bucket()
.bucket(bucket)
.send()
.await
.expect("Deleting every version must remove xl.meta so the bucket can be deleted normally");
}
#[tokio::test]
#[serial]
async fn test_versioning_auto_enabled_with_object_lock() {
init_logging();
info!("🧪 Test: Versioning is auto-enabled when Object Lock is configured");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-versioning-auto-enable";
// Create bucket with Object Lock enabled
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Check versioning status - should be Enabled
let versioning = client.get_bucket_versioning().bucket(bucket).send().await.unwrap();
// Object Lock buckets must have versioning enabled
// Note: Some S3 implementations may report MfaDelete status as well
let status = versioning.status();
info!("Versioning status: {:?}", status);
// Put an object and verify it gets a version ID
let key = "versioned-object";
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(b"v1".to_vec()))
.send()
.await
.unwrap();
let version1 = response.version_id();
assert!(version1.is_some(), "Object should have a version ID");
// Put another version
let response2 = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(b"v2".to_vec()))
.send()
.await
.unwrap();
let version2 = response2.version_id();
assert!(version2.is_some(), "Second object should have a version ID");
assert_ne!(version1, version2, "Version IDs should be different");
info!("✅ Test passed: Versioning is auto-enabled with Object Lock");
}
// ============================================================================
// Error Message Tests
// ============================================================================
#[tokio::test]
#[serial]
async fn test_error_message_distinguishes_legal_hold_from_retention() {
init_logging();
info!("🧪 Test: Error messages distinguish Legal Hold from Retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
let bucket = "test-error-messages";
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
// Put object with legal hold
let legal_hold_key = "legal-hold-object";
let lh_version = put_object_with_legal_hold(&client, bucket, legal_hold_key, b"data", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
// Put object with retention
let retention_key = "retention-object";
let retain_until = future_retain_until(30);
let ret_version =
put_object_with_retention(&client, bucket, retention_key, b"data", ObjectLockRetentionMode::Compliance, retain_until)
.await
.unwrap();
// Delete legal hold object - check error
let lh_delete_result = client
.delete_object()
.bucket(bucket)
.key(legal_hold_key)
.version_id(&lh_version)
.send()
.await;
if let Err(e) = lh_delete_result {
let error_str = format!("{:?}", e);
info!("Legal hold delete error: {}", error_str);
// Error should mention legal hold
assert!(
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
"Error should mention legal hold"
);
}
// Delete retention object - check error
let ret_delete_result = client
.delete_object()
.bucket(bucket)
.key(retention_key)
.version_id(&ret_version)
.send()
.await;
if let Err(e) = ret_delete_result {
let error_str = format!("{:?}", e);
info!("Retention delete error: {}", error_str);
// Error should mention retention
assert!(
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
"Error should mention retention"
);
}
info!("✅ Test passed: Error messages distinguish lock types");
}