mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 19:42:17 +00:00
feat: object retention (#1589)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user