mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-28 00:58:59 +00:00
feat: object retention (#1589)
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -52,3 +52,7 @@ mod policy;
|
||||
mod compression_test;
|
||||
#[cfg(test)]
|
||||
mod protocols;
|
||||
|
||||
// Object Lock tests
|
||||
#[cfg(test)]
|
||||
mod object_lock;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user