feat: object retention (#1589)

This commit is contained in:
GatewayJ
2026-01-24 22:12:45 +08:00
committed by GitHub
parent db5e72e475
commit 9285acba06
12 changed files with 1832 additions and 120 deletions
+3 -3
View File
@@ -23,7 +23,7 @@ use rustfs_ecstore::bucket::{
lifecycle::Lifecycle,
},
metadata_sys::get_object_lock_config,
object_lock::objectlock_sys::{BucketObjectLockSys, enforce_retention_for_deletion},
object_lock::objectlock_sys::{BucketObjectLockSys, check_object_lock_for_deletion},
versioning::VersioningApi,
versioning_sys::BucketVersioningSys,
};
@@ -127,8 +127,8 @@ impl ScannerItem {
let mut to_del = Vec::<ObjectToDelete>::with_capacity(overflow_versions.len());
for fi in overflow_versions.iter() {
let obj = ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned);
if lock_enabled && enforce_retention_for_deletion(&obj) {
//if enforce_retention_for_deletion(&obj) {
// Lifecycle operations should never bypass governance retention
if lock_enabled && check_object_lock_for_deletion(&self.bucket, &obj, false).await.is_some() {
/*if self.debug {
if obj.version_id.is_some() {
info!("lifecycle: {} v({}) is locked, not deleting\n", obj.name, obj.version_id.expect("err"));
+4
View File
@@ -52,3 +52,7 @@ mod policy;
mod compression_test;
#[cfg(test)]
mod protocols;
// Object Lock tests
#[cfg(test)]
mod object_lock;
+258
View File
@@ -0,0 +1,258 @@
// 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.
//! Object Lock E2E test utilities
//!
//! This module provides Object Lock-specific functionality for testing:
//! - Creating buckets with Object Lock enabled
//! - Setting retention policies
//! - Testing legal hold operations
//! - Bypass governance retention header handling
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
ObjectLockRetention, ObjectLockRetentionMode, ObjectLockRule,
};
use chrono::{DateTime, Duration, Utc};
use tracing::info;
use crate::common::RustFSTestEnvironment;
/// Object Lock test environment
pub struct ObjectLockTestEnvironment {
pub base_env: RustFSTestEnvironment,
}
impl ObjectLockTestEnvironment {
/// Create a new Object Lock test environment
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let base_env = RustFSTestEnvironment::new().await?;
Ok(Self { base_env })
}
/// Start RustFS server (no special flags needed for Object Lock)
pub async fn start_rustfs(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.base_env.start_rustfs_server(Vec::new()).await
}
/// Get S3 client
pub fn s3_client(&self) -> Client {
self.base_env.create_s3_client()
}
/// Create a bucket with Object Lock enabled
pub async fn create_object_lock_bucket(&self, bucket_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.s3_client();
// Create bucket with Object Lock enabled
client
.create_bucket()
.bucket(bucket_name)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
info!("Created bucket with Object Lock enabled: {}", bucket_name);
Ok(())
}
}
/// Put Object Lock configuration on a bucket
pub async fn put_object_lock_configuration(
client: &Client,
bucket: &str,
mode: ObjectLockRetentionMode,
days: Option<i32>,
years: Option<i32>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut default_retention_builder = DefaultRetention::builder().mode(mode);
if let Some(d) = days {
default_retention_builder = default_retention_builder.days(d);
}
if let Some(y) = years {
default_retention_builder = default_retention_builder.years(y);
}
let default_retention = default_retention_builder.build();
let rule = ObjectLockRule::builder().default_retention(default_retention).build();
let config = ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.rule(rule)
.build();
client
.put_object_lock_configuration()
.bucket(bucket)
.object_lock_configuration(config)
.send()
.await?;
info!("Put Object Lock configuration on bucket: {}", bucket);
Ok(())
}
/// Convert ObjectLockRetentionMode to ObjectLockMode for put_object
fn retention_mode_to_lock_mode(mode: ObjectLockRetentionMode) -> ObjectLockMode {
match mode.as_str() {
"GOVERNANCE" => ObjectLockMode::Governance,
"COMPLIANCE" => ObjectLockMode::Compliance,
_ => ObjectLockMode::Governance,
}
}
/// Put an object with retention
pub async fn put_object_with_retention(
client: &Client,
bucket: &str,
key: &str,
data: &[u8],
mode: ObjectLockRetentionMode,
retain_until: DateTime<Utc>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let retain_until_datetime =
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
let lock_mode = retention_mode_to_lock_mode(mode.clone());
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.object_lock_mode(lock_mode)
.object_lock_retain_until_date(retain_until_datetime)
.send()
.await?;
let version_id = response.version_id().unwrap_or("null").to_string();
info!("Put object {} with retention mode {:?}, version: {}", key, mode, version_id);
Ok(version_id)
}
/// Put an object with legal hold
pub async fn put_object_with_legal_hold(
client: &Client,
bucket: &str,
key: &str,
data: &[u8],
legal_hold: ObjectLockLegalHoldStatus,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let legal_hold_str = format!("{:?}", legal_hold);
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.object_lock_legal_hold_status(legal_hold)
.send()
.await?;
let version_id = response.version_id().unwrap_or("null").to_string();
info!("Put object {} with legal hold {}, version: {}", key, legal_hold_str, version_id);
Ok(version_id)
}
/// Put object retention on existing object
pub async fn put_object_retention(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
mode: ObjectLockRetentionMode,
retain_until: DateTime<Utc>,
bypass_governance: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let retain_until_datetime =
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
let retention = ObjectLockRetention::builder()
.mode(mode.clone())
.retain_until_date(retain_until_datetime)
.build();
let mut request = client
.put_object_retention()
.bucket(bucket)
.key(key)
.retention(retention)
.bypass_governance_retention(bypass_governance);
if let Some(vid) = version_id {
request = request.version_id(vid);
}
request.send().await?;
info!("Put object retention on {} with mode {:?}", key, mode);
Ok(())
}
/// Put object legal hold
pub async fn put_object_legal_hold(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
status: ObjectLockLegalHoldStatus,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let status_str = format!("{:?}", status);
let legal_hold = ObjectLockLegalHold::builder().status(status).build();
let mut request = client.put_object_legal_hold().bucket(bucket).key(key).legal_hold(legal_hold);
if let Some(vid) = version_id {
request = request.version_id(vid);
}
request.send().await?;
info!("Put legal hold {} on {}", status_str, key);
Ok(())
}
/// Delete object with optional bypass governance retention
pub async fn delete_object_with_bypass(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
bypass_governance: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut request = client
.delete_object()
.bucket(bucket)
.key(key)
.bypass_governance_retention(bypass_governance);
if let Some(vid) = version_id {
request = request.version_id(vid);
}
request.send().await?;
info!("Deleted object {} (bypass: {})", key, bypass_governance);
Ok(())
}
/// Calculate a future retain_until date
pub fn future_retain_until(days: i64) -> DateTime<Utc> {
Utc::now() + Duration::days(days)
}
+25
View File
@@ -0,0 +1,25 @@
// 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.
//! Object Lock E2E tests
//!
//! This module contains end-to-end tests for S3 Object Lock functionality, including:
//! - COMPLIANCE and GOVERNANCE retention modes
//! - Legal Hold
//! - Bypass Governance Retention
//! - Default bucket retention configuration
//! - Delete operations on locked objects
pub mod common;
mod object_lock_test;
@@ -0,0 +1,665 @@
// 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::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, ObjectIdentifier, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
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();
}
// ============================================================================
// 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_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_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");
}
// ============================================================================
// 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");
info!("✅ Test passed: Default retention is applied to new objects");
}
// ============================================================================
// Versioning Auto-Enable Tests
// ============================================================================
#[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");
}
@@ -22,7 +22,7 @@ use crate::bucket::lifecycle::bucket_lifecycle_audit::{LcAuditEvent, LcEventSrc}
use crate::bucket::lifecycle::lifecycle::{self, ExpirationOptions, Lifecycle, TransitionOptions};
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier};
use crate::bucket::object_lock::objectlock_sys::enforce_retention_for_deletion;
use crate::bucket::object_lock::objectlock_sys::check_object_lock_for_deletion;
use crate::bucket::{metadata_sys::get_lifecycle_config, versioning_sys::BucketVersioningSys};
use crate::client::object_api_utils::new_getobjectreader;
use crate::error::Error;
@@ -1041,7 +1041,8 @@ pub async fn eval_action_from_lifecycle(
if oi.version_id.is_none() {
return lifecycle::Event::default();
}
if lock_enabled && enforce_retention_for_deletion(oi) {
// Lifecycle operations should never bypass governance retention
if lock_enabled && check_object_lock_for_deletion(&oi.bucket, oi, false).await.is_some() {
//if serverDebugLog {
if oi.version_id.is_some() {
info!(
@@ -14,15 +14,12 @@
use std::sync::Arc;
use s3s::dto::{
BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
};
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled};
use time::OffsetDateTime;
use tracing::info;
use crate::bucket::lifecycle::lifecycle::{Event, Lifecycle, ObjectOpts};
use crate::bucket::object_lock::ObjectLockStatusExt;
use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta, utc_now_ntp};
use crate::bucket::object_lock::objectlock_sys::is_object_locked_by_metadata;
use crate::bucket::replication::ReplicationConfig;
use rustfs_common::metrics::IlmAction;
@@ -75,9 +72,10 @@ impl Evaluator {
}
/// IsObjectLocked checks if it is appropriate to remove an
/// object according to locking configuration when this is lifecycle/ bucket quota asking.
/// (copied over from enforceRetentionForDeletion)
/// object according to locking configuration when this is lifecycle/bucket quota asking.
/// Uses the common `is_object_locked_by_metadata` function for consistency.
pub fn is_object_locked(&self, obj: &ObjectOpts) -> bool {
// First check if object lock is enabled for this bucket
if self.lock_retention.as_ref().is_none_or(|v| {
v.object_lock_enabled
.as_ref()
@@ -86,31 +84,8 @@ impl Evaluator {
return false;
}
if obj.delete_marker {
return false;
}
let lhold = get_object_legalhold_meta(obj.user_defined.clone());
if lhold
.status
.is_some_and(|v| v.valid() && v.as_str() == ObjectLockLegalHoldStatus::ON)
{
return true;
}
let ret = get_object_retention_meta(obj.user_defined.clone());
if ret
.mode
.is_some_and(|v| matches!(v.as_str(), ObjectLockRetentionMode::COMPLIANCE | ObjectLockRetentionMode::GOVERNANCE))
{
let t = utc_now_ntp();
if let Some(retain_until) = ret.retain_until_date
&& OffsetDateTime::from(retain_until).gt(&t)
{
return true;
}
}
false
// Use the common function to check if the object is locked
is_object_locked_by_metadata(&obj.user_defined, obj.delete_marker)
}
/// eval will return a lifecycle event for each object in objs for a given time.
@@ -31,63 +31,206 @@ pub fn utc_now_ntp() -> OffsetDateTime {
OffsetDateTime::now_utc()
}
pub fn get_object_retention_meta(meta: HashMap<String, String>) -> ObjectLockRetention {
let mut retain_until_date: Date = Date::from(OffsetDateTime::UNIX_EPOCH);
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectLockRetention {
// Note: X_AMZ_OBJECT_LOCK_MODE.as_str() is already lowercase ("x-amz-object-lock-mode")
let mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
let mut mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str().to_lowercase().as_str());
if mode_str.is_none() {
mode_str = Some(&meta[X_AMZ_OBJECT_LOCK_MODE.as_str()]);
}
let mode = if let Some(mode_str) = mode_str {
parse_ret_mode(mode_str.as_str())
} else {
let Some(mode_str) = mode_str else {
return ObjectLockRetention {
mode: None,
retain_until_date: None,
};
};
let mut till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_lowercase().as_str());
if till_str.is_none() {
till_str = Some(&meta[X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str()]);
}
if let Some(till_str) = till_str {
let t = OffsetDateTime::parse(till_str, &format_description::well_known::Iso8601::DEFAULT);
if let Ok(parsed_time) = t {
retain_until_date = Date::from(parsed_time);
}
}
// If mode is invalid, return empty retention (don't panic)
let Some(mode) = parse_ret_mode(mode_str.as_str()) else {
return ObjectLockRetention {
mode: None,
retain_until_date: None,
};
};
let till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
let retain_until_date = till_str
.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok())
.map(Date::from);
ObjectLockRetention {
mode: Some(mode),
retain_until_date: Some(retain_until_date),
retain_until_date,
}
}
pub fn get_object_legalhold_meta(meta: HashMap<String, String>) -> ObjectLockLegalHold {
let mut hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_lowercase().as_str());
if hold_str.is_none() {
hold_str = Some(&meta[X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()]);
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLockLegalHold {
// Note: X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str() is already lowercase
let hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str());
match hold_str.and_then(|s| parse_legalhold_status(s)) {
Some(status) => ObjectLockLegalHold { status: Some(status) },
None => ObjectLockLegalHold { status: None },
}
if let Some(hold_str) = hold_str {
return ObjectLockLegalHold {
status: Some(parse_legalhold_status(hold_str)),
};
}
ObjectLockLegalHold { status: None }
}
pub fn parse_ret_mode(mode_str: &str) -> ObjectLockRetentionMode {
/// Parse retention mode string into ObjectLockRetentionMode.
/// Returns None for invalid/unknown mode strings instead of panicking.
pub fn parse_ret_mode(mode_str: &str) -> Option<ObjectLockRetentionMode> {
match mode_str.to_uppercase().as_str() {
"GOVERNANCE" => ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE),
"COMPLIANCE" => ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE),
_ => unreachable!(),
"GOVERNANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
"COMPLIANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
_ => None,
}
}
pub fn parse_legalhold_status(hold_str: &str) -> ObjectLockLegalHoldStatus {
match hold_str {
"ON" => ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON),
"OFF" => ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF),
_ => unreachable!(),
/// Parse legal hold status string into ObjectLockLegalHoldStatus.
/// Returns None for invalid/unknown status strings instead of panicking.
pub fn parse_legalhold_status(hold_str: &str) -> Option<ObjectLockLegalHoldStatus> {
match hold_str.to_uppercase().as_str() {
"ON" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
"OFF" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ret_mode_valid() {
// Test uppercase
let mode = parse_ret_mode("GOVERNANCE");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
let mode = parse_ret_mode("COMPLIANCE");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
// Test lowercase
let mode = parse_ret_mode("governance");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
let mode = parse_ret_mode("compliance");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
// Test mixed case
let mode = parse_ret_mode("Governance");
assert!(mode.is_some());
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
}
#[test]
fn test_parse_ret_mode_invalid() {
// Test invalid values return None instead of panicking
assert!(parse_ret_mode("INVALID").is_none());
assert!(parse_ret_mode("").is_none());
assert!(parse_ret_mode("gov").is_none());
assert!(parse_ret_mode("comp").is_none());
}
#[test]
fn test_parse_legalhold_status_valid() {
// Test uppercase
let status = parse_legalhold_status("ON");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
let status = parse_legalhold_status("OFF");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
// Test lowercase
let status = parse_legalhold_status("on");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
let status = parse_legalhold_status("off");
assert!(status.is_some());
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
}
#[test]
fn test_parse_legalhold_status_invalid() {
// Test invalid values return None instead of panicking
assert!(parse_legalhold_status("INVALID").is_none());
assert!(parse_legalhold_status("").is_none());
assert!(parse_legalhold_status("true").is_none());
assert!(parse_legalhold_status("false").is_none());
}
#[test]
fn test_get_object_retention_meta_empty() {
let meta = HashMap::new();
let retention = get_object_retention_meta(&meta);
assert!(retention.mode.is_none());
assert!(retention.retain_until_date.is_none());
}
#[test]
fn test_get_object_retention_meta_with_mode() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
let retention = get_object_retention_meta(&meta);
assert!(retention.mode.is_some());
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
assert!(retention.retain_until_date.is_none());
}
#[test]
fn test_get_object_retention_meta_with_invalid_mode() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-mode".to_string(), "INVALID_MODE".to_string());
let retention = get_object_retention_meta(&meta);
// Invalid mode should return empty retention, not panic
assert!(retention.mode.is_none());
assert!(retention.retain_until_date.is_none());
}
#[test]
fn test_get_object_retention_meta_with_date() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string());
let retention = get_object_retention_meta(&meta);
assert!(retention.mode.is_some());
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
assert!(retention.retain_until_date.is_some());
}
#[test]
fn test_get_object_legalhold_meta_empty() {
let meta = HashMap::new();
let legalhold = get_object_legalhold_meta(&meta);
assert!(legalhold.status.is_none());
}
#[test]
fn test_get_object_legalhold_meta_on() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
let legalhold = get_object_legalhold_meta(&meta);
assert!(legalhold.status.is_some());
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
}
#[test]
fn test_get_object_legalhold_meta_off() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
let legalhold = get_object_legalhold_meta(&meta);
assert!(legalhold.status.is_some());
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
}
#[test]
fn test_get_object_legalhold_meta_invalid() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-legal-hold".to_string(), "INVALID".to_string());
let legalhold = get_object_legalhold_meta(&meta);
// Invalid status should return None, not panic
assert!(legalhold.status.is_none());
}
}
@@ -37,28 +37,517 @@ impl BucketObjectLockSys {
}
}
pub fn enforce_retention_for_deletion(obj_info: &ObjectInfo) -> bool {
if obj_info.delete_marker {
/// Check if a retention period is still active based on mode and retain_until_date
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
return false;
}
let lhold = objectlock::get_object_legalhold_meta(obj_info.user_defined.clone());
match lhold.status {
Some(st) if st.as_str() == ObjectLockLegalHoldStatus::ON => {
return true;
}
_ => (),
}
let ret = objectlock::get_object_retention_meta(obj_info.user_defined.clone());
match ret.mode {
Some(r) if (r.as_str() == ObjectLockRetentionMode::COMPLIANCE || r.as_str() == ObjectLockRetentionMode::GOVERNANCE) => {
let t = objectlock::utc_now_ntp();
if OffsetDateTime::from(ret.retain_until_date.expect("err!")).unix_timestamp() > t.unix_timestamp() {
return true;
}
}
_ => (),
if let Some(retain_until) = retain_until_date {
let now = objectlock::utc_now_ntp();
return OffsetDateTime::from(retain_until.clone()).unix_timestamp() > now.unix_timestamp();
}
false
}
/// Check if retention modification is blocked for the given object.
pub fn check_retention_for_modification(
user_defined: &std::collections::HashMap<String, String>,
new_retain_until: Option<OffsetDateTime>,
bypass_governance: bool,
) -> Option<ObjectLockBlockReason> {
let retention = objectlock::get_object_retention_meta(user_defined);
let Some(mode) = &retention.mode else {
return None;
};
let mode_str = mode.as_str();
if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) {
return None;
}
let existing_retain_until = retention.retain_until_date.as_ref().map(|d| OffsetDateTime::from(d.clone()));
// Check if new retention period is shorter than existing
let is_shortening = match (&existing_retain_until, &new_retain_until) {
(Some(existing), Some(new)) => new < existing,
(Some(_), None) => true, // Clearing retention is shortening
_ => false,
};
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
// Can only extend the retention period
if mode_str == ObjectLockRetentionMode::COMPLIANCE {
if is_shortening {
return Some(ObjectLockBlockReason::Retention {
mode: mode_str.to_string(),
retain_until: existing_retain_until,
});
}
// Extending retention in COMPLIANCE mode is allowed
return None;
}
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
// This matches AWS S3 behavior where:
// - Extending retention: allowed without bypass permission
// - Shortening/removing retention: requires bypass permission
if mode_str == ObjectLockRetentionMode::GOVERNANCE {
if is_shortening && !bypass_governance {
return Some(ObjectLockBlockReason::Retention {
mode: mode_str.to_string(),
retain_until: existing_retain_until,
});
}
// Extending retention or shortening with bypass is allowed
return None;
}
None
}
pub(crate) fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
let target_year = dt.year() + years;
dt.replace_year(target_year)
.or_else(|_| {
// Feb 29 -> non-leap year: use Feb 28
dt.replace_day(28).and_then(|d| d.replace_year(target_year))
})
.unwrap_or(dt)
}
/// Check if an object has legal hold enabled.
/// Returns true if legal hold is ON.
fn has_legal_hold(user_defined: &std::collections::HashMap<String, String>) -> bool {
let lhold = objectlock::get_object_legalhold_meta(user_defined);
matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON)
}
/// Check if an object is locked based on its metadata.
/// This is a common function used by both lifecycle evaluation and deletion checks.
///
/// # Arguments
/// * `user_defined` - The object's user-defined metadata
/// * `is_delete_marker` - Whether the object is a delete marker
///
/// # Returns
/// * `true` if the object is locked (cannot be deleted/modified)
/// * `false` if the object is not locked
pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap<String, String>, is_delete_marker: bool) -> bool {
// Delete markers are never locked
if is_delete_marker {
return false;
}
// Check legal hold - always blocks if ON
if has_legal_hold(user_defined) {
return true;
}
// Check retention - reuse is_retention_active to avoid code duplication
let ret = objectlock::get_object_retention_meta(user_defined);
if let Some(mode) = &ret.mode
&& is_retention_active(mode.as_str(), ret.retain_until_date.as_ref())
{
return true;
}
false
}
/// Reason why object deletion is blocked by Object Lock
#[derive(Debug, Clone, PartialEq)]
pub enum ObjectLockBlockReason {
/// Object has legal hold enabled (must be explicitly removed)
LegalHold,
/// Object is under retention until the specified date
Retention {
mode: String,
retain_until: Option<OffsetDateTime>,
},
}
impl ObjectLockBlockReason {
/// Get a user-friendly error message for this block reason
pub fn error_message(&self) -> String {
match self {
ObjectLockBlockReason::LegalHold => {
"Object has a legal hold and cannot be deleted. Remove the legal hold first.".to_string()
}
ObjectLockBlockReason::Retention { mode, retain_until } => {
if let Some(until) = retain_until {
format!("Object is under {} retention and cannot be deleted until {}", mode, until)
} else {
format!("Object is under {} retention and cannot be deleted", mode)
}
}
}
}
}
/// Check if retention blocks deletion based on mode and bypass permission.
/// Returns Some(ObjectLockBlockReason) if blocked, None if allowed.
fn check_retention_blocks_deletion(
mode_str: &str,
retain_until: Option<OffsetDateTime>,
bypass_governance: bool,
) -> Option<ObjectLockBlockReason> {
// COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission
let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance;
if !can_bypass {
return Some(ObjectLockBlockReason::Retention {
mode: mode_str.to_string(),
retain_until,
});
}
None
}
/// # S3 Standard Behavior
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
/// - Legal Hold: Cannot be bypassed regardless of mode
pub async fn check_object_lock_for_deletion(
bucket: &str,
obj_info: &ObjectInfo,
bypass_governance: bool,
) -> Option<ObjectLockBlockReason> {
if obj_info.delete_marker {
return None;
}
// 1. Check legal hold - cannot be bypassed (reuse has_legal_hold)
if has_legal_hold(&obj_info.user_defined) {
return Some(ObjectLockBlockReason::LegalHold);
}
// 2. Check explicit retention
let explicit_ret = objectlock::get_object_retention_meta(&obj_info.user_defined);
if let Some(mode) = &explicit_ret.mode {
let mode_str = mode.as_str();
if is_retention_active(mode_str, explicit_ret.retain_until_date.as_ref())
&& let Some(reason) = check_retention_blocks_deletion(
mode_str,
explicit_ret.retain_until_date.map(OffsetDateTime::from),
bypass_governance,
)
{
return Some(reason);
}
}
// 3. Check default retention only if no explicit retention is set
if explicit_ret.mode.is_none()
&& let Some(default_retention) = BucketObjectLockSys::get(bucket).await
&& let Some(mode) = &default_retention.mode
{
let mode_str = mode.as_str();
if mode_str == ObjectLockRetentionMode::COMPLIANCE || mode_str == ObjectLockRetentionMode::GOVERNANCE {
// Calculate retention expiration date from object modification time
if let Some(mod_time) = obj_info.mod_time {
let now = objectlock::utc_now_ntp();
let retain_until = if let Some(days) = default_retention.days {
mod_time.saturating_add(time::Duration::days(days as i64))
} else if let Some(years) = default_retention.years {
add_years(mod_time, years)
} else {
return None; // No retention period specified
};
if retain_until.unix_timestamp() > now.unix_timestamp()
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
{
return Some(reason);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use time::{Date, Month, PrimitiveDateTime, Time};
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
let date = Date::from_calendar_date(year, Month::try_from(month).unwrap(), day).unwrap();
let time = Time::from_hms(0, 0, 0).unwrap();
PrimitiveDateTime::new(date, time).assume_utc()
}
#[test]
fn test_add_years_normal() {
// Normal case: add 1 year to a regular date
let dt = make_datetime(2024, 3, 15);
let result = add_years(dt, 1);
assert_eq!(result.year(), 2025);
assert_eq!(result.month(), Month::March);
assert_eq!(result.day(), 15);
}
#[test]
fn test_add_years_multiple() {
// Add multiple years
let dt = make_datetime(2024, 6, 1);
let result = add_years(dt, 5);
assert_eq!(result.year(), 2029);
assert_eq!(result.month(), Month::June);
assert_eq!(result.day(), 1);
}
#[test]
fn test_add_years_leap_year_to_leap_year() {
// Feb 29 in leap year to another leap year (2024 -> 2028)
let dt = make_datetime(2024, 2, 29);
let result = add_years(dt, 4);
assert_eq!(result.year(), 2028);
assert_eq!(result.month(), Month::February);
assert_eq!(result.day(), 29);
}
#[test]
fn test_add_years_leap_year_to_non_leap_year() {
// Feb 29 in leap year to non-leap year should become Feb 28
let dt = make_datetime(2024, 2, 29);
let result = add_years(dt, 1);
assert_eq!(result.year(), 2025);
assert_eq!(result.month(), Month::February);
assert_eq!(result.day(), 28);
}
#[test]
fn test_add_years_negative() {
// Subtract years
let dt = make_datetime(2024, 3, 15);
let result = add_years(dt, -2);
assert_eq!(result.year(), 2022);
assert_eq!(result.month(), Month::March);
assert_eq!(result.day(), 15);
}
#[test]
fn test_add_years_zero() {
// Add zero years (should return same date)
let dt = make_datetime(2024, 7, 4);
let result = add_years(dt, 0);
assert_eq!(result.year(), 2024);
assert_eq!(result.month(), Month::July);
assert_eq!(result.day(), 4);
}
#[test]
fn test_is_retention_active_invalid_mode() {
// Invalid mode should return false
assert!(!is_retention_active("INVALID", None));
assert!(!is_retention_active("", None));
}
#[test]
fn test_is_retention_active_no_date() {
// Valid mode but no retain_until_date should return false
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, None));
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, None));
}
#[test]
fn test_is_retention_active_future_date() {
// Valid mode with future retain_until_date should return true
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
let s3_date = s3s::dto::Date::from(future_date);
assert!(is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
let s3_date = s3s::dto::Date::from(future_date);
assert!(is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
}
#[test]
fn test_is_retention_active_past_date() {
// Valid mode with past retain_until_date should return false
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
let s3_date = s3s::dto::Date::from(past_date);
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
let s3_date = s3s::dto::Date::from(past_date);
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
}
#[test]
fn test_check_retention_for_modification_no_existing_retention() {
// No existing retention - modification should be allowed
let user_defined = std::collections::HashMap::new();
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
assert!(check_retention_for_modification(&user_defined, new_retain, false).is_none());
}
#[test]
fn test_check_retention_for_modification_compliance_extend() {
// COMPLIANCE mode - extending retention should be allowed
let mut user_defined = std::collections::HashMap::new();
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
existing_retain
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
);
// Extending by another 30 days should be allowed
let new_retain = Some(existing_retain + time::Duration::days(30));
assert!(check_retention_for_modification(&user_defined, new_retain, false).is_none());
}
#[test]
fn test_check_retention_for_modification_compliance_shorten() {
// COMPLIANCE mode - shortening retention should be blocked
let mut user_defined = std::collections::HashMap::new();
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(60);
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
existing_retain
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
);
// Shortening to 30 days should be blocked
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
let result = check_retention_for_modification(&user_defined, new_retain, false);
assert!(result.is_some());
assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. })));
}
#[test]
fn test_check_retention_for_modification_compliance_clear() {
// COMPLIANCE mode - clearing retention should be blocked
let mut user_defined = std::collections::HashMap::new();
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
existing_retain
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
);
// Clearing (None) should be blocked
let result = check_retention_for_modification(&user_defined, None, false);
assert!(result.is_some());
}
#[test]
fn test_check_retention_for_modification_governance_shorten_without_bypass() {
// GOVERNANCE mode - shortening retention without bypass should be blocked
let mut user_defined = std::collections::HashMap::new();
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
existing_retain
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
);
// Shortening from 30 days to 15 days without bypass should be blocked
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
let result = check_retention_for_modification(&user_defined, new_retain, false);
assert!(result.is_some());
}
#[test]
fn test_check_retention_for_modification_governance_extend_without_bypass() {
// GOVERNANCE mode - extending retention without bypass should be allowed
// This matches AWS S3 behavior where extending is always allowed
let mut user_defined = std::collections::HashMap::new();
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
existing_retain
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
);
// Extending from 30 days to 60 days without bypass should be allowed
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60));
assert!(check_retention_for_modification(&user_defined, new_retain, false).is_none());
}
#[test]
fn test_check_retention_for_modification_governance_shorten_with_bypass() {
// GOVERNANCE mode - shortening retention with bypass should be allowed
let mut user_defined = std::collections::HashMap::new();
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
existing_retain
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
);
// Shortening from 30 days to 15 days with bypass should be allowed
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
assert!(check_retention_for_modification(&user_defined, new_retain, true).is_none());
}
#[test]
fn test_is_object_locked_by_metadata_delete_marker() {
// Delete markers are never locked
let user_defined = std::collections::HashMap::new();
assert!(!is_object_locked_by_metadata(&user_defined, true));
}
#[test]
fn test_is_object_locked_by_metadata_legal_hold_on() {
// Legal hold ON should be locked
let mut user_defined = std::collections::HashMap::new();
user_defined.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
assert!(is_object_locked_by_metadata(&user_defined, false));
}
#[test]
fn test_is_object_locked_by_metadata_legal_hold_off() {
// Legal hold OFF should not be locked
let mut user_defined = std::collections::HashMap::new();
user_defined.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
assert!(!is_object_locked_by_metadata(&user_defined, false));
}
#[test]
fn test_is_object_locked_by_metadata_retention_active() {
// Active retention should be locked
let mut user_defined = std::collections::HashMap::new();
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
future_date.format(&time::format_description::well_known::Rfc3339).unwrap(),
);
assert!(is_object_locked_by_metadata(&user_defined, false));
}
#[test]
fn test_is_object_locked_by_metadata_retention_expired() {
// Expired retention should not be locked
let mut user_defined = std::collections::HashMap::new();
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
user_defined.insert(
"x-amz-object-lock-retain-until-date".to_string(),
past_date.format(&time::format_description::well_known::Rfc3339).unwrap(),
);
assert!(!is_object_locked_by_metadata(&user_defined, false));
}
#[test]
fn test_is_object_locked_by_metadata_no_lock() {
// No lock settings should not be locked
let user_defined = std::collections::HashMap::new();
assert!(!is_object_locked_by_metadata(&user_defined, false));
}
}
+35 -5
View File
@@ -20,6 +20,7 @@ use rustfs_ecstore::bucket::policy_sys::PolicySys;
use rustfs_iam::error::Error as IamError;
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_policy::policy::{Args, BucketPolicyArgs};
use rustfs_utils::http::AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE;
use s3s::access::{S3Access, S3AccessContext};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Result, dto::*, s3_error};
use std::collections::HashMap;
@@ -189,6 +190,15 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
Err(s3_error!(AccessDenied, "Access Denied"))
}
/// Check if the request has the x-amz-bypass-governance-retention header set to true
pub fn has_bypass_governance_header(headers: &http::HeaderMap) -> bool {
headers
.get(AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE)
.and_then(|v| v.to_str().ok())
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
#[async_trait::async_trait]
impl S3Access for FS {
// /// Checks whether the current request has accesses to the resources.
@@ -448,7 +458,14 @@ impl S3Access for FS {
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::DeleteObjectAction)).await
authorize_request(req, Action::S3Action(S3Action::DeleteObjectAction)).await?;
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
if has_bypass_governance_header(&req.headers) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
Ok(())
}
/// Checks whether the DeleteObjectTagging request has accesses to the resources.
@@ -471,7 +488,15 @@ impl S3Access for FS {
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = None;
req_info.version_id = None;
authorize_request(req, Action::S3Action(S3Action::DeleteObjectAction)).await
authorize_request(req, Action::S3Action(S3Action::DeleteObjectAction)).await?;
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
if has_bypass_governance_header(&req.headers) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
Ok(())
}
/// Checks whether the DeletePublicAccessBlock request has accesses to the resources.
@@ -1113,15 +1138,20 @@ impl S3Access for FS {
}
/// Checks whether the PutObjectRetention request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_object_retention(&self, req: &mut S3Request<PutObjectRetentionInput>) -> S3Result<()> {
let req_info = req.extensions.get_mut::<ReqInfo>().expect("ReqInfo not found");
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await
authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?;
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
if has_bypass_governance_header(&req.headers) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
Ok(())
}
/// Checks whether the PutObjectTagging request has accesses to the resources.
+133 -18
View File
@@ -26,7 +26,7 @@ use crate::storage::entity;
use crate::storage::helper::OperationHelper;
use crate::storage::options::{filter_object_metadata, get_content_sha256};
use crate::storage::{
access::{ReqInfo, authorize_request},
access::{ReqInfo, authorize_request, has_bypass_governance_header},
options::{
copy_dst_opts, copy_src_opts, del_opts, extract_metadata, extract_metadata_from_mime_with_object_name,
get_complete_multipart_upload_opts, get_opts, parse_copy_source_range, put_opts,
@@ -53,7 +53,7 @@ use rustfs_ecstore::{
},
metadata_sys,
metadata_sys::get_replication_config,
object_lock::objectlock_sys::BucketObjectLockSys,
object_lock::objectlock_sys::{BucketObjectLockSys, check_object_lock_for_deletion, check_retention_for_modification},
policy_sys::PolicySys,
quota::QuotaOperation,
replication::{
@@ -551,12 +551,24 @@ fn parse_object_lock_retention(retention: Option<ObjectLockRetention>) -> S3Resu
None => String::default(),
};
let retain_until_date = v
.retain_until_date
.map(|v| OffsetDateTime::from(v).format(&Rfc3339).unwrap())
.unwrap_or_default();
let now = OffsetDateTime::now_utc();
// Validate retain_until_date is in the future (S3 requirement)
// Only validate when both mode and date are provided (not clearing retention)
let retain_until_date = if let Some(date) = v.retain_until_date {
let retain_until = OffsetDateTime::from(date);
// Only validate future date when mode is set (not clearing retention)
if !mode.is_empty() && retain_until <= now {
return Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"The retain until date must be in the future".to_string(),
));
}
retain_until.format(&Rfc3339).unwrap()
} else {
String::default()
};
// This is intentional behavior. Empty string represents "retention cleared" which is different from "retention never set". Consistent with minio
eval_metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode);
eval_metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), retain_until_date);
@@ -1955,12 +1967,13 @@ impl S3 for FS {
let metadata = extract_metadata(&req.headers);
// Clone version_id before it's moved
let version_id_clone = version_id.clone();
let mut opts: ObjectOptions = del_opts(&bucket, &key, version_id, &req.headers, metadata)
.await
.map_err(ApiError::from)?;
// TODO: check object lock
let lock_cfg = BucketObjectLockSys::get(&bucket).await;
if lock_cfg.is_some() && opts.delete_prefix {
return Err(S3Error::with_message(
@@ -1983,6 +1996,33 @@ impl S3 for FS {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
// Check Object Lock retention before deletion
// TODO: Future optimization (separate PR) - If performance becomes critical under high delete load:
// 1. Integrate OptimizedFileCache (file_cache.rs) into the read_version() path
// 2. Or add a lightweight get_object_lock_info() that only fetches retention metadata
// 3. Or use combined get-and-delete in storage layer with retention check callback
// Note: The project has OptimizedFileCache with moka, but get_object_info doesn't use it yet
let get_opts: ObjectOptions = get_opts(&bucket, &key, version_id_clone, None, &req.headers)
.await
.map_err(ApiError::from)?;
match store.get_object_info(&bucket, &key, &get_opts).await {
Ok(obj_info) => {
// Check for bypass governance retention header (permission already verified in access.rs)
let bypass_governance = has_bypass_governance_header(&req.headers);
if let Some(block_reason) = check_object_lock_for_deletion(&bucket, &obj_info, bypass_governance).await {
return Err(S3Error::with_message(S3ErrorCode::AccessDenied, block_reason.error_message()));
}
}
Err(err) => {
// If object not found, allow deletion to proceed (will return 204 No Content)
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
return Err(ApiError::from(err).into());
}
}
}
let obj_info = {
match store.delete_object(&bucket, &key, opts).await {
Ok(obj) => obj,
@@ -2101,6 +2141,9 @@ impl S3 for FS {
let version_cfg = BucketVersioningSys::get(&bucket).await.unwrap_or_default();
// Check for bypass governance retention header (permission already verified in access.rs)
let bypass_governance = has_bypass_governance_header(&req.headers);
#[derive(Default, Clone)]
struct DeleteResult {
delete_object: Option<rustfs_ecstore::store_api::DeletedObject>,
@@ -2176,6 +2219,21 @@ impl S3 for FS {
Err(e) => (ObjectInfo::default(), Some(e.to_string())),
};
// Check Object Lock retention before deletion
// NOTE: Unlike single DeleteObject, this reuses the get_object_info result from quota
// tracking above, so no additional storage operation is required for the retention check.
if gerr.is_none()
&& let Some(block_reason) = check_object_lock_for_deletion(&bucket, &goi, bypass_governance).await
{
delete_results[idx].error = Some(Error {
code: Some("AccessDenied".to_string()),
key: Some(obj_id.key.clone()),
message: Some(block_reason.error_message()),
version_id: version_id.clone(),
});
continue;
}
// Store object size for quota tracking
object_sizes.insert(object.object_name.clone(), goi.size);
@@ -5992,6 +6050,20 @@ impl S3 for FS {
.await
.map_err(ApiError::from)?;
// When Object Lock is enabled, automatically enable versioning if not already enabled
// This matches AWS S3 and MinIO behavior
let versioning_config = BucketVersioningSys::get(&bucket).await.map_err(ApiError::from)?;
if !versioning_config.enabled() {
let enable_versioning_config = VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
};
let versioning_data = try_!(serialize(&enable_versioning_config));
metadata_sys::update(&bucket, BUCKET_VERSIONING_CONFIG, versioning_data)
.await
.map_err(ApiError::from)?;
}
Ok(S3Response::new(PutObjectLockConfigurationOutput::default()))
}
@@ -6639,7 +6711,41 @@ impl S3 for FS {
// check object lock
validate_bucket_object_lock_enabled(&bucket).await?;
// TODO: check allow
// Extract new retain_until_date for validation
let new_retain_until = retention
.as_ref()
.and_then(|r| r.retain_until_date.as_ref())
.map(|d| OffsetDateTime::from(d.clone()));
// Check if object already has retention and if modification is allowed
// This follows AWS S3 and MinIO behavior:
// - COMPLIANCE mode: can only extend retention period, never shorten
// - GOVERNANCE mode: requires bypass header to modify/shorten retention
//
// TODO: Known race condition (fix in future PR)
// There's a TOCTOU (time-of-check-time-of-use) window between retention check here
// and the actual update at put_object_metadata. In theory:
// Thread A: reads GOVERNANCE mode, checks bypass header
// Thread B: updates retention to COMPLIANCE mode
// Thread A: modifies retention, bypassing what is now COMPLIANCE mode
// This violates S3 spec that COMPLIANCE cannot be modified even with bypass.
// Fix options:
// 1. Pass expected retention mode to storage layer, verify before update
// 2. Use optimistic concurrency with version/etag checks
// 3. Perform check within the same lock scope as update in storage layer
// Current mitigation: Storage layer has fast_lock_manager which provides some protection
let check_opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
.await
.map_err(ApiError::from)?;
if let Ok(existing_obj_info) = store.get_object_info(&bucket, &key, &check_opts).await {
let bypass_governance = has_bypass_governance_header(&req.headers);
if let Some(block_reason) =
check_retention_for_modification(&existing_obj_info.user_defined, new_retain_until, bypass_governance)
{
return Err(S3Error::with_message(S3ErrorCode::AccessDenied, block_reason.error_message()));
}
}
let eval_metadata = parse_object_lock_retention(retention)?;
@@ -7122,33 +7228,33 @@ mod tests {
assert!(parse_object_lock_retention(None).is_ok());
assert!(parse_object_lock_retention(None).unwrap().is_empty());
// [2] Normal case: Retention with valid COMPLIANCE mode
// [2] Normal case: Retention with valid COMPLIANCE mode (future date)
let valid_compliance_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
retain_until_date: Some(datetime!(2025-01-01 00:00:00 UTC).into()),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let compliance_metadata = parse_object_lock_retention(Some(valid_compliance_retention)).unwrap();
assert_eq!(compliance_metadata.get("x-amz-object-lock-mode").unwrap(), "COMPLIANCE");
assert_eq!(
compliance_metadata.get("x-amz-object-lock-retain-until-date").unwrap(),
"2025-01-01T00:00:00Z"
"2030-01-01T00:00:00Z"
);
assert!(
compliance_metadata.contains_key(&format!("{}{}", RESERVED_METADATA_PREFIX_LOWER, "objectlock-retention-timestamp"))
);
// [3] Normal case: Retention with valid GOVERNANCE mode
// [3] Normal case: Retention with valid GOVERNANCE mode (future date)
let valid_governance_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
retain_until_date: Some(datetime!(2025-01-01 00:00:00 UTC).into()),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let governance_metadata = parse_object_lock_retention(Some(valid_governance_retention)).unwrap();
assert_eq!(governance_metadata.get("x-amz-object-lock-mode").unwrap(), "GOVERNANCE");
// [4] Normal case: Retention with None mode (empty string for mode)
// [4] Normal case: Retention with None mode (empty string for mode, date not validated)
let none_mode_retention = ObjectLockRetention {
mode: None,
retain_until_date: Some(datetime!(2025-01-01 00:00:00 UTC).into()),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let none_mode_metadata = parse_object_lock_retention(Some(none_mode_retention)).unwrap();
assert_eq!(none_mode_metadata.get("x-amz-object-lock-mode").unwrap(), "");
@@ -7164,7 +7270,7 @@ mod tests {
// [6] Error case: Retention with invalid mode (non COMPLIANCE/GOVERNANCE)
let invalid_mode_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static("INVALID_MODE")),
retain_until_date: Some(datetime!(2025-01-01 00:00:00 UTC).into()),
retain_until_date: Some(datetime!(2030-01-01 00:00:00 UTC).into()),
};
let err = parse_object_lock_retention(Some(invalid_mode_retention)).unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::MalformedXML.as_str());
@@ -7172,6 +7278,15 @@ mod tests {
err.message(),
Some("The XML you provided was not well-formed or did not validate against our published schema")
);
// [7] Error case: Retention with past date should fail
let past_date_retention = ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
retain_until_date: Some(datetime!(2020-01-01 00:00:00 UTC).into()),
};
let err = parse_object_lock_retention(Some(past_date_retention)).unwrap_err();
assert_eq!(err.code().as_str(), S3ErrorCode::InvalidArgument.as_str());
assert_eq!(err.message(), Some("The retain until date must be in the future"));
}
#[test]
+8 -1
View File
@@ -444,6 +444,10 @@ static SUPPORTED_HEADERS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
"x-amz-tagging",
"expires",
"x-amz-replication-status",
// Object Lock headers - required for S3 Object Lock functionality
"x-amz-object-lock-mode",
"x-amz-object-lock-retain-until-date",
"x-amz-object-lock-legal-hold",
]
});
@@ -1021,10 +1025,13 @@ mod tests {
"x-amz-tagging",
"expires",
"x-amz-replication-status",
"x-amz-object-lock-mode",
"x-amz-object-lock-retain-until-date",
"x-amz-object-lock-legal-hold",
];
assert_eq!(*SUPPORTED_HEADERS, expected_headers);
assert_eq!(SUPPORTED_HEADERS.len(), 9);
assert_eq!(SUPPORTED_HEADERS.len(), 12);
}
#[test]