diff --git a/crates/ahm/src/scanner/lifecycle.rs b/crates/ahm/src/scanner/lifecycle.rs index 9d410b267..cd2d4933d 100644 --- a/crates/ahm/src/scanner/lifecycle.rs +++ b/crates/ahm/src/scanner/lifecycle.rs @@ -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::::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")); diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index 0342da9a5..514330f13 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -52,3 +52,7 @@ mod policy; mod compression_test; #[cfg(test)] mod protocols; + +// Object Lock tests +#[cfg(test)] +mod object_lock; diff --git a/crates/e2e_test/src/object_lock/common.rs b/crates/e2e_test/src/object_lock/common.rs new file mode 100644 index 000000000..17e41f366 --- /dev/null +++ b/crates/e2e_test/src/object_lock/common.rs @@ -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> { + 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> { + 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> { + 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, + years: Option, +) -> Result<(), Box> { + 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, +) -> Result> { + // 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> { + 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, + bypass_governance: bool, +) -> Result<(), Box> { + // 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> { + 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> { + 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::now() + Duration::days(days) +} diff --git a/crates/e2e_test/src/object_lock/mod.rs b/crates/e2e_test/src/object_lock/mod.rs new file mode 100644 index 000000000..38d01bef2 --- /dev/null +++ b/crates/e2e_test/src/object_lock/mod.rs @@ -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; diff --git a/crates/e2e_test/src/object_lock/object_lock_test.rs b/crates/e2e_test/src/object_lock/object_lock_test.rs new file mode 100644 index 000000000..31a86d89a --- /dev/null +++ b/crates/e2e_test/src/object_lock/object_lock_test.rs @@ -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"); +} diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs index 5742736bf..60d822aa3 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs @@ -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!( diff --git a/crates/ecstore/src/bucket/lifecycle/evaluator.rs b/crates/ecstore/src/bucket/lifecycle/evaluator.rs index 2a5eefb09..a2387789a 100644 --- a/crates/ecstore/src/bucket/lifecycle/evaluator.rs +++ b/crates/ecstore/src/bucket/lifecycle/evaluator.rs @@ -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. diff --git a/crates/ecstore/src/bucket/object_lock/objectlock.rs b/crates/ecstore/src/bucket/object_lock/objectlock.rs index 6452506fa..d0c0e0486 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock.rs @@ -31,63 +31,206 @@ pub fn utc_now_ntp() -> OffsetDateTime { OffsetDateTime::now_utc() } -pub fn get_object_retention_meta(meta: HashMap) -> ObjectLockRetention { - let mut retain_until_date: Date = Date::from(OffsetDateTime::UNIX_EPOCH); +pub fn get_object_retention_meta(meta: &HashMap) -> 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) -> 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) -> 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 { 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 { + 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()); } } diff --git a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs index 92e2e84d7..100697d58 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs @@ -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, + new_retain_until: Option, + bypass_governance: bool, +) -> Option { + 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) -> 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, 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, + }, +} + +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, + bypass_governance: bool, +) -> Option { + // 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 { + 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)); + } +} diff --git a/rustfs/src/storage/access.rs b/rustfs/src/storage/access.rs index 79515cdc4..6177814bb 100644 --- a/rustfs/src/storage/access.rs +++ b/rustfs/src/storage/access.rs @@ -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(req: &mut S3Request, 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) -> S3Result<()> { let req_info = req.extensions.get_mut::().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. diff --git a/rustfs/src/storage/ecfs.rs b/rustfs/src/storage/ecfs.rs index 417b1e9e8..4d1845d71 100644 --- a/rustfs/src/storage/ecfs.rs +++ b/rustfs/src/storage/ecfs.rs @@ -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) -> 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, @@ -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] diff --git a/rustfs/src/storage/options.rs b/rustfs/src/storage/options.rs index 222bbff09..6d0d57218 100644 --- a/rustfs/src/storage/options.rs +++ b/rustfs/src/storage/options.rs @@ -444,6 +444,10 @@ static SUPPORTED_HEADERS: LazyLock> = 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]