// 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. use crate::bucket::metadata_sys::{ ObjectLockConfigState, default_retention_from_object_lock_config, get_object_lock_config, get_object_lock_config_state, }; use crate::bucket::object_lock::objectlock; use crate::bucket::object_lock::types::{DefaultRetention, LegalHoldStatus, RetentionMode}; use crate::error::{Error, Result, StorageError}; use crate::object_api::{ObjectInfo, ObjectOptions}; use rustfs_utils::http::headers::{ AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, }; use std::sync::Arc; use time::OffsetDateTime; pub struct BucketObjectLockSys {} impl BucketObjectLockSys { #[allow(clippy::new_ret_no_self)] pub async fn new() -> Arc { Arc::new(Self {}) } /// The bucket's active default retention, if the bucket has an /// authoritative Object Lock configuration with a usable /// GOVERNANCE/COMPLIANCE default retention rule. pub async fn get(bucket: &str) -> Option { if let Ok(object_lock_config) = get_object_lock_config(bucket).await { return default_retention_from_object_lock_config(&object_lock_config.0); } None } } pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, state: &ObjectLockConfigState) -> Result<()> { match state { ObjectLockConfigState::ConfirmedAbsent => Ok(()), ObjectLockConfigState::Configured { .. } => Err(StorageError::InvalidArgument( bucket.to_string(), String::new(), "force-delete is forbidden on Object Locking enabled buckets".to_string(), )), ObjectLockConfigState::Fabricated => { Err(Error::other(format!("bucket Object Lock metadata is not authoritative: {bucket}"))) } } } /// Check if a retention period is still active based on mode and retain_until_date pub fn is_retention_active(_mode: RetentionMode, retain_until_date: Option) -> bool { if let Some(retain_until) = retain_until_date { let now = objectlock::utc_now_ntp(); return retain_until.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_mode: Option, new_retain_until: Option, bypass_governance: bool, ) -> Option { let retention = objectlock::get_object_retention_meta(user_defined); let mode = retention.mode?; if !is_retention_active(mode, retention.retain_until_date) { return None; } let existing_retain_until = retention.retain_until_date; let mode_changed = new_mode != Some(mode); // 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, }; match mode { // COMPLIANCE mode: cannot shorten retention at all (even with bypass) // Can only extend the retention period RetentionMode::Compliance => { if mode_changed || is_shortening { return Some(ObjectLockBlockReason::Retention { mode, retain_until: existing_retain_until, }); } // Extending retention in COMPLIANCE mode is allowed 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 RetentionMode::Governance => { if (mode_changed || is_shortening) && !bypass_governance { return Some(ObjectLockBlockReason::Retention { mode, retain_until: existing_retain_until, }); } // Extending retention or shortening with bypass is allowed None } } } pub 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 { objectlock::get_object_legalhold_meta(user_defined).is_on() } /// Whether an authorized replication write (`ObjectOptions::replication_request`) /// may overwrite a locked destination version. /// /// The source's lock state governs a replica (MinIO `checkPutObjectLockAllowed` /// skips the existing-version check for replicas), and a source-side hold /// release or retention change reaches this site only through this write. The /// overwrite is allowed only when the write carries the source timestamp of /// every category that currently locks the version, so receiver-side LWW /// (`merge_replication_metadata_lww`) judges each of them: a category locked /// more recently here is kept, otherwise the source's newer state wins. A write /// without that timestamp carries no source decision for the category — the /// metadata replace would lift the lock unjudged — so it stays WORM-rejected. /// /// The locking categories come from the same authoritative evaluation as the /// commit-time WORM gate (`check_object_lock_for_deletion_with_state`): the /// bucket default retention locks a version that carries no explicit /// retention keys, so it is judged here too rather than read off the keys. /// Malformed persisted lock metadata or a non-authoritative bucket /// configuration is an error, never a pass. pub fn replication_write_may_pass_worm_gate( state: &ObjectLockConfigState, obj_info: &ObjectInfo, opts: &ObjectOptions, ) -> Result { if !opts.replication_request { return Ok(false); } if obj_info.delete_marker { // Delete markers are never locked (same as the WORM gate). return Ok(true); } let default_retention = default_retention_from_state(state)?; if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() { return Ok(false); } let retention_locked = active_retention(default_retention.as_ref(), obj_info)?.is_some(); Ok(!(retention_locked && opts.replication_retention_timestamp.is_none())) } /// Whether an authorized replication delete (`ObjectOptions::replication_request`) /// addressed to an explicit version may bypass GOVERNANCE retention on the /// local replica, exactly as an `x-amz-bypass-governance-retention` caller /// with the bypass permission would. /// /// The source is authoritative for a replicated version purge (issue #6850): /// the same WORM deletion gate already ran there, and GOVERNANCE retention /// with an authorized bypass is the only lock state it can purge through. /// Requiring the bypass header again here makes the purge permanently /// undeliverable — replication senders never carry it — and the sites diverge /// forever. COMPLIANCE retention and legal hold stay blocking: the source /// gate can never purge through them, so a replication purge that meets one /// here is divergence or forgery and fails closed. /// /// The trust judgment is the same one the write-path exemption uses: /// `replication_request` is only set once the receiving handler has /// authorized the caller for the replication action /// (`ReplicateDeleteAction`), never straight from request headers. pub fn replication_delete_may_bypass_governance(opts: &ObjectOptions) -> bool { opts.replication_request && opts.version_id.is_some() } /// 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 #[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")] 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, ret.retain_until_date) { 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: RetentionMode, 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: RetentionMode, retain_until: Option, bypass_governance: bool, ) -> Option { // COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission let can_bypass = mode == RetentionMode::Governance && bypass_governance; if !can_bypass { return Some(ObjectLockBlockReason::Retention { mode, retain_until }); } None } /// Check an object's lock metadata using an already resolved bucket default /// retention. `None` means the bucket configuration is confirmed absent or /// carries no usable default retention rule. /// /// # 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(crate) fn check_object_lock_for_deletion_with_default_retention( default_retention: Option<&DefaultRetention>, obj_info: &ObjectInfo, bypass_governance: bool, ) -> Result> { if obj_info.delete_marker { return Ok(None); } if legal_hold_locks(obj_info)? { return Ok(Some(ObjectLockBlockReason::LegalHold)); } if let Some((mode, retain_until)) = active_retention(default_retention, obj_info)? && let Some(reason) = check_retention_blocks_deletion(mode, Some(retain_until), bypass_governance) { return Ok(Some(reason)); } Ok(None) } /// A cleared retention / legal hold is persisted as empty strings (the MinIO /// on-disk shape, `parse_object_lock_retention`); read it as "no lock" rather /// than as corrupt metadata. fn persisted_lock_value<'a>(obj_info: &'a ObjectInfo, key: &str) -> Option<&'a String> { obj_info.user_defined.get(key).filter(|value| !value.is_empty()) } /// Whether the version's persisted legal hold is ON. Any other non-empty /// value than ON/OFF is malformed metadata and fails closed. fn legal_hold_locks(obj_info: &ObjectInfo) -> Result { let Some(status) = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) else { return Ok(false); }; match LegalHoldStatus::parse(status) { Some(LegalHoldStatus::On) => Ok(true), Some(LegalHoldStatus::Off) => Ok(false), None => Err(Error::other("persisted object legal-hold metadata is invalid")), } } /// The retention that currently locks the version, if any: the explicit /// persisted retention when the keys are present, otherwise the bucket /// default retention computed from the version's modification time. Returns /// `(mode, retain_until)` only while the retention is still active. fn active_retention( default_retention: Option<&DefaultRetention>, obj_info: &ObjectInfo, ) -> Result> { let mode = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_MODE_LOWER); let retain_until = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER); match (mode, retain_until) { (None, None) => {} (Some(mode), Some(retain_until)) => { let mode = objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?; let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT) .map_err(|_| Error::other("persisted object retention date is invalid"))?; return Ok(is_retention_active(mode, Some(retain_until)).then_some((mode, retain_until))); } _ => return Err(Error::other("persisted object retention metadata is incomplete")), } let Some(default_retention) = default_retention else { return Ok(None); }; // Calculate retention expiration date from object modification time let mod_time = obj_info .mod_time .ok_or_else(|| Error::other("persisted object modification time is missing"))?; let now = objectlock::utc_now_ntp(); let retain_until = if let Some(days) = default_retention.days { mod_time.saturating_add(time::Duration::days(i64::from(days))) } else { let years = default_retention .years .ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?; add_years(mod_time, years) }; Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((default_retention.mode, retain_until))) } /// The bucket default retention carried by an authoritative Object Lock /// state. `ConfirmedAbsent` and a configuration without a usable default /// retention rule are both `None`; a fabricated state is an error, never a /// pass. fn default_retention_from_state(state: &ObjectLockConfigState) -> Result> { match state { ObjectLockConfigState::Configured { config, .. } => Ok(default_retention_from_object_lock_config(config)), ObjectLockConfigState::ConfirmedAbsent => Ok(None), ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")), } } pub(crate) fn check_object_lock_for_deletion_with_state( state: &ObjectLockConfigState, obj_info: &ObjectInfo, bypass_governance: bool, ) -> Result> { check_object_lock_for_deletion_with_default_retention( default_retention_from_state(state)?.as_ref(), obj_info, bypass_governance, ) } /// Compatibility wrapper for callers that predate fallible metadata lookup. /// An authority/read/parse failure is represented as a blocking reason rather /// than the old fail-open `None` result. pub async fn check_object_lock_for_deletion( bucket: &str, obj_info: &ObjectInfo, bypass_governance: bool, ) -> Option { match get_object_lock_config_state(bucket) .await .and_then(|state| check_object_lock_for_deletion_with_state(&state, obj_info, bypass_governance)) { Ok(reason) => reason, Err(_) => Some(ObjectLockBlockReason::LegalHold), } } #[cfg(test)] mod tests { use super::*; use crate::bucket::metadata_sys::configured_object_lock_state_for_tests; use rustfs_utils::http::headers::{ AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, }; 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() } fn default_retention(mode: RetentionMode) -> DefaultRetention { DefaultRetention { mode, days: Some(30), years: None, } } #[test] fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() { let retention = default_retention(RetentionMode::Compliance); let obj_info = ObjectInfo { mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }; let result = check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true); assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. })))); } #[test] fn deletion_with_config_allows_active_default_governance_with_bypass() { let retention = default_retention(RetentionMode::Governance); let obj_info = ObjectInfo { mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }; assert!(matches!( check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true), Ok(None) )); } #[test] fn deletion_with_default_retention_rejects_missing_object_mod_time() { let retention = default_retention(RetentionMode::Compliance); let err = check_object_lock_for_deletion_with_default_retention(Some(&retention), &ObjectInfo::default(), false) .expect_err("default retention needs an authoritative object modification time"); assert!(err.to_string().contains("modification time")); } #[test] fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() { let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30); let mut user_defined = std::collections::HashMap::new(); user_defined.insert("x-amz-object-lock-mode".to_string(), RetentionMode::COMPLIANCE.to_string()); user_defined.insert( "x-amz-object-lock-retain-until-date".to_string(), retain_until .format(&time::format_description::well_known::Rfc3339) .expect("retain-until date should format"), ); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, true); assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. })))); } #[test] fn deletion_with_fabricated_bucket_metadata_fails_closed() { let err = check_object_lock_for_deletion_with_state(&ObjectLockConfigState::Fabricated, &ObjectInfo::default(), false) .expect_err("non-authoritative Object Lock metadata must block deletion"); assert!(err.to_string().contains("not authoritative")); } #[test] fn recursive_force_delete_with_fabricated_bucket_metadata_fails_closed() { let err = ensure_recursive_force_delete_allowed_for_state("bucket", &ObjectLockConfigState::Fabricated) .expect_err("non-authoritative Object Lock metadata must block recursive deletion"); assert!(err.to_string().contains("not authoritative")); } #[test] fn deletion_rejects_incomplete_persisted_retention_metadata() { let mut user_defined = std::collections::HashMap::new(); user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), RetentionMode::COMPLIANCE.to_string()); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false) .expect_err("mode without retain-until date must fail closed"); assert!(err.to_string().contains("incomplete")); } #[test] fn deletion_rejects_each_malformed_persisted_retention_shape() { let valid_date = (OffsetDateTime::now_utc() + time::Duration::days(30)) .format(&time::format_description::well_known::Rfc3339) .expect("retain-until date should format"); let cases = [ ("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"), ("invalid date", Some(RetentionMode::COMPLIANCE), Some("not-a-date"), "retention date"), ("date only", None, Some(valid_date.as_str()), "incomplete"), ]; for (case, mode, retain_until, expected) in cases { let mut user_defined = std::collections::HashMap::new(); if let Some(mode) = mode { user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode.to_string()); } if let Some(retain_until) = retain_until { user_defined.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), retain_until.to_string()); } let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false).expect_err(case); assert!(err.to_string().contains(expected), "unexpected {case} error: {err}"); } } fn replication_opts(hold_ts: bool, retention_ts: bool) -> ObjectOptions { ObjectOptions { replication_request: true, replication_legalhold_timestamp: hold_ts.then_some(OffsetDateTime::UNIX_EPOCH), replication_retention_timestamp: retention_ts.then_some(OffsetDateTime::UNIX_EPOCH), ..Default::default() } } fn lock_metadata(entries: &[&[(&str, &str)]]) -> std::collections::HashMap { entries .iter() .flat_map(|entries| entries.iter()) .map(|(key, value)| (key.to_string(), value.to_string())) .collect() } fn lock_object_info(user_defined: std::collections::HashMap) -> ObjectInfo { ObjectInfo { user_defined: Arc::new(user_defined), mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() } } /// A replication write passes the WORM gate only when it carries the /// source timestamp of every category that currently locks the version. #[test] fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() { let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")]; let retention = [ (AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"), (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, "2099-01-01T00:00:00Z"), ]; let expired = [ (AMZ_OBJECT_LOCK_MODE_LOWER, "COMPLIANCE"), (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, "2000-01-01T00:00:00Z"), ]; let absent = ObjectLockConfigState::ConfirmedAbsent; let passes = |state: &ObjectLockConfigState, entries: &[&[(&str, &str)]], opts: &ObjectOptions| { replication_write_may_pass_worm_gate(state, &lock_object_info(lock_metadata(entries)), opts) .expect("well-formed lock metadata must be judged") }; assert!(passes(&absent, &[&hold, &retention], &replication_opts(true, true))); assert!(!passes(&absent, &[&hold, &retention], &replication_opts(true, false))); assert!(!passes(&absent, &[&hold, &retention], &replication_opts(false, true))); assert!(passes(&absent, &[&hold], &replication_opts(true, false))); assert!(!passes(&absent, &[&hold], &replication_opts(false, true))); assert!(passes(&absent, &[&retention], &replication_opts(false, true))); assert!(!passes(&absent, &[&retention], &replication_opts(true, false))); // Expired retention and a released hold no longer lock anything. assert!(passes( &absent, &[&expired, &[(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "OFF")]], &replication_opts(false, false) )); // Never for a non-replication write, whatever it carries. let local = ObjectOptions { replication_request: false, ..replication_opts(true, true) }; assert!(!passes(&absent, &[&hold], &local)); } /// The bucket default retention locks a version that carries no explicit /// retention keys (`check_object_lock_for_deletion_with_default_retention` /// judges it from the modification time), so the replication bypass must /// demand the retention source timestamp for it too — a tagging-only /// replication write must not overwrite the default-protected version /// unjudged. #[test] fn replication_write_under_bucket_default_retention_requires_retention_timestamp() { for mode in [RetentionMode::Compliance, RetentionMode::Governance] { let state = configured_object_lock_state_for_tests(mode, 30); let no_keys = lock_object_info(std::collections::HashMap::new()); assert!( check_object_lock_for_deletion_with_state(&state, &no_keys, false) .expect("default retention must be judged") .is_some(), "{mode}: the gate must report the default retention lock" ); let tagging_only = ObjectOptions { replication_request: true, replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), ..Default::default() }; assert!( !replication_write_may_pass_worm_gate(&state, &no_keys, &tagging_only).expect("judged"), "{mode}: a tagging-only replication write must not pass the default retention lock" ); assert!( replication_write_may_pass_worm_gate(&state, &no_keys, &replication_opts(false, true)).expect("judged"), "{mode}: the retention source timestamp lets LWW judge the default retention" ); // Default retention plus a legal hold: both categories need a timestamp. let held = lock_object_info(lock_metadata(&[&[(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")]])); assert!(!replication_write_may_pass_worm_gate(&state, &held, &replication_opts(false, true)).expect("judged")); assert!(!replication_write_may_pass_worm_gate(&state, &held, &replication_opts(true, false)).expect("judged")); assert!(replication_write_may_pass_worm_gate(&state, &held, &replication_opts(true, true)).expect("judged")); // A version whose default retention has already expired (old // mod_time) is not locked by the default any more. let expired_default = ObjectInfo { mod_time: Some(make_datetime(2000, 1, 1)), ..lock_object_info(std::collections::HashMap::new()) }; assert!(replication_write_may_pass_worm_gate(&state, &expired_default, &tagging_only).expect("judged")); // A delete marker is never locked, so there is nothing to judge. let delete_marker = ObjectInfo { delete_marker: true, ..lock_object_info(std::collections::HashMap::new()) }; assert!(replication_write_may_pass_worm_gate(&state, &delete_marker, &tagging_only).expect("judged")); // Cleared (empty) explicit keys fall back to the bucket default. let cleared = lock_object_info(lock_metadata(&[&[(AMZ_OBJECT_LOCK_MODE_LOWER, "")]])); assert!(!replication_write_may_pass_worm_gate(&state, &cleared, &tagging_only).expect("judged")); } } /// The replication bypass never judges from a non-authoritative bucket /// state or malformed persisted lock metadata; both are errors, not a pass. #[test] fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() { let opts = replication_opts(true, true); let err = replication_write_may_pass_worm_gate( &ObjectLockConfigState::Fabricated, &lock_object_info(std::collections::HashMap::new()), &opts, ) .expect_err("fabricated bucket lock metadata must not be judged"); assert!(err.to_string().contains("not authoritative")); let malformed = lock_object_info(lock_metadata(&[&[(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "MAYBE")]])); let err = replication_write_may_pass_worm_gate(&ObjectLockConfigState::ConfirmedAbsent, &malformed, &opts) .expect_err("malformed legal hold must not be judged"); assert!(err.to_string().contains("legal-hold")); let state = configured_object_lock_state_for_tests(RetentionMode::Compliance, 30); let no_mod_time = ObjectInfo::default(); let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts) .expect_err("default retention without a modification time must not be judged"); assert!(err.to_string().contains("modification time")); } /// The replicated-purge GOVERNANCE bypass (#6850) applies only to an /// authorized replication delete addressed to an explicit version: a /// local delete never gets it, and a replicated delete without a version /// id creates a delete marker rather than purging anything. #[test] fn replication_delete_bypasses_governance_only_for_authorized_version_purges() { let version_purge = ObjectOptions { replication_request: true, version_id: Some("6b6ffbc0-b0d3-4a86-8f6c-fe19163b8dcd".to_string()), ..Default::default() }; assert!(replication_delete_may_bypass_governance(&version_purge)); let local_version_delete = ObjectOptions { replication_request: false, ..version_purge.clone() }; assert!(!replication_delete_may_bypass_governance(&local_version_delete)); let replicated_marker_creation = ObjectOptions { version_id: None, ..version_purge }; assert!(!replication_delete_may_bypass_governance(&replicated_marker_creation)); } /// A local PutObjectRetention / PutObjectLegalHold "clear" persists the /// lock keys as empty strings (the MinIO on-disk shape, see /// `parse_object_lock_retention`); that is "no lock", not corruption, and /// must not wedge later explicit-version PUTs or deletes /// (rustfs/backlog#1953). #[test] fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() { let cases: [(&str, &[&str]); 3] = [ ( "cleared retention", &[AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER], ), ("cleared legal hold", &[AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER]), ( "all cleared", &[ AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, ], ), ]; for (case, keys) in cases { let user_defined = keys.iter().map(|key| (key.to_string(), String::new())).collect(); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false); assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}"); } } #[test] fn deletion_rejects_invalid_persisted_legal_hold_metadata() { let mut user_defined = std::collections::HashMap::new(); user_defined.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "INVALID".to_string()); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false) .expect_err("invalid legal-hold value must fail closed"); assert!(err.to_string().contains("legal-hold")); } #[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_no_date() { // Valid mode but no retain_until_date should return false assert!(!is_retention_active(RetentionMode::Compliance, None)); assert!(!is_retention_active(RetentionMode::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); assert!(is_retention_active(RetentionMode::Compliance, Some(future_date))); assert!(is_retention_active(RetentionMode::Governance, Some(future_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); assert!(!is_retention_active(RetentionMode::Compliance, Some(past_date))); assert!(!is_retention_active(RetentionMode::Governance, Some(past_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, None, 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, Some(RetentionMode::Compliance), 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, Some(RetentionMode::Compliance), 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, 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, Some(RetentionMode::Governance), 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, Some(RetentionMode::Governance), 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, Some(RetentionMode::Governance), new_retain, true).is_none()); } #[test] fn test_check_retention_for_modification_governance_mode_change_without_bypass() { 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(), ); let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), false); assert!(result.is_some()); } #[test] fn test_check_retention_for_modification_governance_mode_change_with_bypass() { 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(), ); assert!( check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), true) .is_none() ); } #[test] fn test_check_retention_for_modification_compliance_mode_change() { 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(), ); let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), Some(existing_retain), true); assert!(result.is_some()); } #[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)); } }