From d0792b87befd1e7759497f406c78003894e030af Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Sat, 4 Jul 2026 20:05:56 +0800 Subject: [PATCH] refactor(lifecycle): extract core rule contracts (#4258) --- Cargo.lock | 20 + Cargo.toml | 2 + crates/ecstore/Cargo.toml | 1 + .../bucket/lifecycle/bucket_lifecycle_ops.rs | 6 +- crates/ecstore/src/bucket/lifecycle/core.rs | 2973 +---------------- .../ecstore/src/bucket/lifecycle/evaluator.rs | 404 +-- .../bucket/lifecycle/object_lock_boundary.rs | 2 +- crates/ecstore/src/bucket/lifecycle/rule.rs | 190 +- crates/lifecycle/Cargo.toml | 49 + crates/lifecycle/src/core.rs | 2951 ++++++++++++++++ crates/lifecycle/src/evaluator.rs | 448 +++ crates/lifecycle/src/lib.rs | 24 + crates/lifecycle/src/object_lock.rs | 63 + crates/lifecycle/src/rule.rs | 204 ++ crates/lifecycle/src/tagging.rs | 45 + crates/scanner/src/lib.rs | 7 +- crates/scanner/src/scanner_folder.rs | 2 +- crates/scanner/src/storage_api.rs | 8 +- .../architecture/ecstore-module-split-plan.md | 41 +- rustfs/src/app/object_usecase.rs | 2 +- rustfs/src/app/storage_api.rs | 3 + scripts/check_architecture_migration_rules.sh | 26 +- 22 files changed, 3900 insertions(+), 3571 deletions(-) create mode 100644 crates/lifecycle/Cargo.toml create mode 100644 crates/lifecycle/src/core.rs create mode 100644 crates/lifecycle/src/evaluator.rs create mode 100644 crates/lifecycle/src/lib.rs create mode 100644 crates/lifecycle/src/object_lock.rs create mode 100644 crates/lifecycle/src/rule.rs create mode 100644 crates/lifecycle/src/tagging.rs diff --git a/Cargo.lock b/Cargo.lock index 9b1dbcfbf..6cef700a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9073,6 +9073,7 @@ dependencies = [ "rustfs-filemeta", "rustfs-io-metrics", "rustfs-kms", + "rustfs-lifecycle", "rustfs-lock", "rustfs-madmin", "rustfs-object-capacity", @@ -9360,6 +9361,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustfs-lifecycle" +version = "1.0.0-beta.8" +dependencies = [ + "async-trait", + "rustfs-common", + "rustfs-config", + "rustfs-replication", + "rustfs-storage-api", + "s3s", + "serial_test", + "temp-env", + "time", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "rustfs-lock" version = "1.0.0-beta.8" diff --git a/Cargo.toml b/Cargo.toml index 22c1228aa..f22a3c26c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/heal", # Erasure set and object healing "crates/iam", # Identity and Access Management "crates/keystone", # OpenStack Keystone integration + "crates/lifecycle", # Lifecycle rule evaluation contracts "crates/kms", # Key Management Service "crates/lock", # Distributed locking implementation "crates/madmin", # Management dashboard and admin API interface @@ -97,6 +98,7 @@ rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.8" } rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.8" } rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.8" } rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.8" } +rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.8" } rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.8" } rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.8" } rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.8" } diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index 49930c17e..cd18d3c61 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -50,6 +50,7 @@ rustfs-common.workspace = true rustfs-policy.workspace = true rustfs-protos.workspace = true rustfs-replication.workspace = true +rustfs-lifecycle.workspace = true rustfs-kms.workspace = true rustfs-s3-types = { workspace = true } rustfs-data-usage.workspace = true diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs index 5b1b58130..bfd7bef39 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs @@ -1965,7 +1965,7 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) { }; let object_opts = object_infos .iter() - .map(ObjectOpts::from_object_info) + .map(lifecycle::object_opts_from_object_info) .collect::>(); let Ok(events) = Evaluator::new(Arc::new(lifecycle)) .with_lock_retention(lock_config) @@ -2086,7 +2086,7 @@ async fn enqueue_expiry_for_existing_object_group( let object_opts = object_infos .iter() - .map(ObjectOpts::from_object_info) + .map(lifecycle::object_opts_from_object_info) .collect::>(); let events = match Evaluator::new(context.lc.clone()) .with_lock_retention(context.lock_config.clone()) @@ -2559,7 +2559,7 @@ pub trait LifecycleOps { impl LifecycleOps for ObjectInfo { fn to_lifecycle_opts(&self) -> lifecycle::ObjectOpts { - lifecycle::ObjectOpts::from_object_info(self) + lifecycle::object_opts_from_object_info(self) } fn is_remote(&self) -> bool { diff --git a/crates/ecstore/src/bucket/lifecycle/core.rs b/crates/ecstore/src/bucket/lifecycle/core.rs index f3c277eac..c6c970da3 100644 --- a/crates/ecstore/src/bucket/lifecycle/core.rs +++ b/crates/ecstore/src/bucket/lifecycle/core.rs @@ -12,2958 +12,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -use rustfs_config::{DEFAULT_ILM_PROCESS_TIME_SECS, ENV_ILM_PROCESS_TIME, ENV_ILM_PROCESS_TIME_DEPRECATED}; -use s3s::dto::{ - BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, - NoncurrentVersionTransition, ObjectLockConfiguration, ObjectLockEnabled, RestoreRequest, Transition, -}; -use std::cmp::Ordering; -use std::collections::HashMap; -use std::sync::Arc; -use time::macros::offset; -use time::{self, Duration, OffsetDateTime}; -use tracing::debug; -use uuid::Uuid; - -use crate::bucket::lifecycle::replication_sink::{ReplicationStatusType, VersionPurgeStatusType}; -use crate::bucket::lifecycle::rule::{NoncurrentVersionTransitionOps, TransitionOps}; use crate::object_api::ObjectInfo; -pub const TRANSITION_COMPLETE: &str = "complete"; -pub const TRANSITION_PENDING: &str = "pending"; -const LOG_COMPONENT_ECSTORE: &str = "ecstore"; -const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle"; -const EVENT_LIFECYCLE_EXPIRY_COMPUTED: &str = "lifecycle_expiry_computed"; -const ERR_LIFECYCLE_NO_RULE: &str = "Lifecycle configuration should have at least one rule"; -const ERR_LIFECYCLE_DUPLICATE_ID: &str = "Rule ID must be unique. Found same ID for more than one rule"; -const _ERR_XML_NOT_WELL_FORMED: &str = - "The XML you provided was not well-formed or did not validate against our published schema"; -const ERR_LIFECYCLE_BUCKET_LOCKED: &str = - "ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket"; -const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules"; -const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "Lifecycle expiration days must not be negative"; -const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str = "Lifecycle noncurrent expiration days must not be negative"; -const ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS: &str = - "DaysAfterInitiation must be 0 or greater when used with AbortIncompleteMultipartUpload"; -const ERR_LIFECYCLE_INVALID_EXPIRATION_DATE_NOT_MIDNIGHT: &str = "Expiration.Date must be at midnight UTC"; -const ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER: &str = - "ExpiredObjectDeleteMarker cannot be specified with Days or Date"; -const ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG: &str = "Rule ID must be at most 255 characters"; -const ERR_LIFECYCLE_INVALID_RULE_STATUS: &str = "Rule status must be either Enabled or Disabled"; -const ERR_LIFECYCLE_DEL_MARKER_WITH_TAGS: &str = "Rule with DelMarkerExpiration cannot have tags based filtering"; -const ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS: &str = - "Rule with ExpiredObjectDeleteMarker cannot have tags based filtering"; -const ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION: &str = "Rule must have at least one of Expiration, Transition, NoncurrentVersionExpiration, NoncurrentVersionTransition, or DelMarkerExpiration"; -const ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT: &str = "Legacy Prefix and Filter cannot both be present in a lifecycle rule. Use Filter.Prefix instead of the top-level Prefix element."; +pub use rustfs_lifecycle::{ + Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE, + TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time, +}; -pub use rustfs_common::metrics::IlmAction; - -#[async_trait::async_trait] -pub trait RuleValidate { - fn validate(&self) -> Result<(), std::io::Error>; -} - -#[async_trait::async_trait] -impl RuleValidate for LifecycleRule { - /*fn validate_id(&self) -> Result<()> { - if self.id.len() > 255 { - return errInvalidRuleID; - } - Ok(()) - } - - fn validate_status(&self) -> Result<()> { - if self.status.len() == 0 { - return ErrEmptyRuleStatus; - } - - if self.status != Enabled && self.status != Disabled { - return ErrInvalidRuleStatus; - } - Ok(()) - } - - fn validate_expiration(&self) -> Result<()> { - self.expiration.validate(); - } - - fn validate_noncurrent_expiration(&self) -> Result<()> { - self.noncurrent_version_expiration.validate() - } - - fn validate_prefix_and_filter(&self) -> Result<()> { - if !self.prefix.set && self.Filter.isempty() || self.prefix.set && !self.filter.isempty() { - return ErrXMLNotWellFormed; - } - if !self.prefix.set { - return self.filter.validate(); - } - Ok(()) - } - - fn validate_transition(&self) -> Result<()> { - self.Transition.Validate() - } - - fn validate_noncurrent_transition(&self) -> Result<()> { - self.NoncurrentVersionTransition.Validate() - } - - fn get_prefix(&self) -> String { - if p := self.Prefix.String(); p != "" { - return p - } - if p := self.Filter.Prefix.String(); p != "" { - return p - } - if p := self.Filter.And.Prefix.String(); p != "" { - return p - } - "".to_string() - }*/ - - fn validate(&self) -> Result<(), std::io::Error> { - // S3 standard: Legacy Prefix and Filter cannot both be present. - // An empty prefix is treated as "not set" and is allowed with a Filter. - let has_legacy_prefix = self.prefix.as_deref().is_some_and(|p| !p.is_empty()); - let has_filter = self.filter.is_some(); - if has_legacy_prefix && has_filter { - return Err(std::io::Error::other(ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT)); - } - - // Rule with DelMarkerExpiration cannot have tags based filtering - let has_tag_filter = self - .filter - .as_ref() - .is_some_and(|f| f.tag.is_some() || f.and.as_ref().and_then(|a| a.tags.as_ref()).is_some()); - if has_tag_filter && self.del_marker_expiration.is_some() { - return Err(std::io::Error::other(ERR_LIFECYCLE_DEL_MARKER_WITH_TAGS)); - } - if has_tag_filter - && self - .expiration - .as_ref() - .is_some_and(|expiration| expiration.expired_object_delete_marker.is_some_and(|v| v)) - { - return Err(std::io::Error::other(ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS)); - } - if let Some(expiration) = &self.expiration - && expiration.expired_object_delete_marker.is_some_and(|v| v) - && (expiration.days.is_some() || expiration.date.is_some()) - { - return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER)); - } - // Rule must have at least one action - let has_expiration = self.expiration.is_some(); - let has_transition = self.transitions.as_ref().is_some_and(|t| !t.is_empty()); - let has_noncurrent_expiration = self - .noncurrent_version_expiration - .as_ref() - .and_then(|e| e.noncurrent_days) - .is_some(); - let has_noncurrent_transition = self - .noncurrent_version_transitions - .as_ref() - .and_then(|t| t.first()) - .and_then(|t| t.storage_class.as_ref()) - .is_some(); - let has_abort_incomplete_multipart_upload = self.abort_incomplete_multipart_upload.is_some(); - let has_del_marker_expiration = self - .del_marker_expiration - .as_ref() - .and_then(|d| d.days) - .is_some_and(|d| d > 0); - if !has_expiration - && !has_transition - && !has_noncurrent_expiration - && !has_noncurrent_transition - && !has_abort_incomplete_multipart_upload - && !has_del_marker_expiration - { - return Err(std::io::Error::other(ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION)); - } - Ok(()) - } -} - -fn lifecycle_rule_prefix(rule: &LifecycleRule) -> Option<&str> { - // Prefer a non-empty legacy prefix; treat an empty legacy prefix as if it were not set - if let Some(p) = rule.prefix.as_deref() - && !p.is_empty() - { - return Some(p); - } - - let filter = rule.filter.as_ref()?; - - if let Some(p) = filter.prefix.as_deref() { - return Some(p); - } - - filter.and.as_ref().and_then(|and| and.prefix.as_deref()) -} - -#[async_trait::async_trait] -pub trait Lifecycle { - async fn has_transition(&self) -> bool; - fn has_expiry(&self) -> bool; - fn has_active_rules(&self, prefix: &str) -> bool; - async fn validate(&self, lr: &ObjectLockConfiguration) -> Result<(), std::io::Error>; - async fn filter_rules(&self, obj: &ObjectOpts) -> Option>; - async fn eval(&self, obj: &ObjectOpts) -> Event; - async fn predict_expiration(&self, obj: &ObjectOpts) -> Event; - async fn eval_inner(&self, obj: &ObjectOpts, now: OffsetDateTime, newer_noncurrent_versions: usize) -> Event; - //fn set_prediction_headers(&self, w: http.ResponseWriter, obj: ObjectOpts); - async fn noncurrent_versions_expiration_limit(self: Arc, obj: &ObjectOpts) -> Event; -} - -#[async_trait::async_trait] -impl Lifecycle for BucketLifecycleConfiguration { - async fn has_transition(&self) -> bool { - for rule in self.rules.iter() { - if rule.transitions.as_ref().is_some_and(|transitions| !transitions.is_empty()) { - return true; - } - } - false - } - - fn has_expiry(&self) -> bool { - for rule in self.rules.iter() { - if rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some() { - return true; - } - } - false - } - - fn has_active_rules(&self, prefix: &str) -> bool { - if self.rules.is_empty() { - return false; - } - for rule in self.rules.iter() { - if rule.status.as_str() == ExpirationStatus::DISABLED { - continue; - } - - let rule_prefix = lifecycle_rule_prefix(rule).unwrap_or(""); - if !prefix.is_empty() - && !rule_prefix.is_empty() - && !prefix.starts_with(rule_prefix) - && !rule_prefix.starts_with(prefix) - { - continue; - } - - if let Some(rule_noncurrent_version_expiration) = &rule.noncurrent_version_expiration { - if let Some(noncurrent_days) = rule_noncurrent_version_expiration.noncurrent_days - && noncurrent_days >= 0 - { - return true; - } - if let Some(newer_noncurrent_versions) = rule_noncurrent_version_expiration.newer_noncurrent_versions - && newer_noncurrent_versions > 0 - { - return true; - } - } - if rule - .noncurrent_version_transitions - .as_ref() - .is_some_and(|transitions| !transitions.is_empty()) - { - return true; - } - if let Some(rule_expiration) = &rule.expiration { - if let Some(date1) = rule_expiration.date.clone() - && OffsetDateTime::from(date1).unix_timestamp() < OffsetDateTime::now_utc().unix_timestamp() - { - return true; - } - if rule_expiration.date.is_some() { - return true; - } - if rule_expiration.days.is_some() { - return true; - } - if let Some(expired_object_delete_marker) = rule_expiration.expired_object_delete_marker - && expired_object_delete_marker - { - return true; - } - } - if let Some(rule_transition) = rule.transitions.as_ref().and_then(|transitions| transitions.first()) { - if let Some(date1) = rule_transition.date.clone() - && OffsetDateTime::from(date1).unix_timestamp() < OffsetDateTime::now_utc().unix_timestamp() - { - return true; - } - return true; - } - } - false - } - - async fn validate(&self, lr: &ObjectLockConfiguration) -> Result<(), std::io::Error> { - if self.rules.len() > 1000 { - return Err(std::io::Error::other(ERR_LIFECYCLE_TOO_MANY_RULES)); - } - if self.rules.is_empty() { - return Err(std::io::Error::other(ERR_LIFECYCLE_NO_RULE)); - } - - for r in &self.rules { - if r.status != ExpirationStatus::from_static(ExpirationStatus::ENABLED) - && r.status != ExpirationStatus::from_static(ExpirationStatus::DISABLED) - { - return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_STATUS)); - } - - if let Some(expiration) = &r.expiration { - if let Some(expiration_date) = &expiration.date { - let date = OffsetDateTime::from(expiration_date.clone()); - if date.hour() != 0 || date.minute() != 0 || date.second() != 0 || date.nanosecond() != 0 { - return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRATION_DATE_NOT_MIDNIGHT)); - } - } - if let Some(days) = expiration.days - && days < 0 - { - return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS)); - } - } - if let Some(noncurrent_version_expiration) = &r.noncurrent_version_expiration - && let Some(noncurrent_days) = noncurrent_version_expiration.noncurrent_days - && noncurrent_days < 0 - { - return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS)); - } - if let Some(abort_incomplete_multipart_upload) = &r.abort_incomplete_multipart_upload { - match abort_incomplete_multipart_upload.days_after_initiation { - Some(days) if days >= 0 => {} - _ => return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS)), - } - } - if let Some(transitions) = &r.transitions { - for transition in transitions { - TransitionOps::validate(transition)?; - } - } - if let Some(noncurrent_transitions) = &r.noncurrent_version_transitions { - for transition in noncurrent_transitions { - NoncurrentVersionTransitionOps::validate(transition)?; - } - } - if let Some(id) = &r.id - && id.len() > 255 - { - return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG)); - } - r.validate()?; - if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref() - && object_lock_enabled.as_str() == ObjectLockEnabled::ENABLED - { - if let Some(expiration) = r.expiration.as_ref() { - // Object Lock + ExpiredObjectAllVersions conflict (MinIO extension) - if expiration.expired_object_all_versions.is_some_and(|v| v) { - return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED)); - } - } - // Object Lock + DelMarkerExpiration conflict - if r.del_marker_expiration.is_some() { - return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED)); - } - } - } - for (i, _) in self.rules.iter().enumerate() { - if i == self.rules.len() - 1 { - break; - } - let other_rules = &self.rules[i + 1..]; - for other_rule in other_rules { - if let (Some(id1), Some(id2)) = (&self.rules[i].id, &other_rule.id) - && id1 == id2 - { - return Err(std::io::Error::other(ERR_LIFECYCLE_DUPLICATE_ID)); - } - } - } - Ok(()) - } - - async fn filter_rules(&self, obj: &ObjectOpts) -> Option> { - if obj.name.is_empty() { - return None; - } - let mut rules = Vec::::new(); - for rule in self.rules.iter() { - if rule.status.as_str() == ExpirationStatus::DISABLED { - continue; - } - if let Some(rule_prefix) = lifecycle_rule_prefix(rule) - && !obj.name.starts_with(rule_prefix) - { - continue; - } - if let Some(filter) = rule.filter.as_ref() { - if !::test_tags(filter, &obj.user_tags) { - continue; - } - if !obj.delete_marker - && !::by_size(filter, obj.size as i64) - { - continue; - } - } - rules.push(rule.clone()); - } - Some(rules) - } - - async fn eval(&self, obj: &ObjectOpts) -> Event { - self.eval_inner(obj, OffsetDateTime::now_utc(), 0).await - } - - async fn predict_expiration(&self, obj: &ObjectOpts) -> Event { - let mod_time = match obj.mod_time { - Some(time) => { - if time.unix_timestamp() == 0 { - return Event::default(); - } - time - } - None => return Event::default(), - }; - - if obj.delete_marker || !(obj.is_latest || obj.version_id.is_none_or(|v| v.is_nil())) { - return Event::default(); - } - - let Some(lc_rules) = self.filter_rules(obj).await else { - return Event::default(); - }; - - let mut event: Option = None; - for rule in lc_rules { - let Some(expiration) = rule.expiration else { - continue; - }; - if expiration.expired_object_delete_marker.is_some_and(|v| v) { - continue; - } - let Some(due) = (match (&expiration.date, expiration.days) { - (Some(date), _) => Some(OffsetDateTime::from(date.clone())), - (None, Some(days)) => Some(expected_expiry_time(mod_time, days)), - _ => None, - }) else { - continue; - }; - - let predicted = Event { - action: IlmAction::DeleteAction, - rule_id: rule.id.clone().unwrap_or_default(), - due: Some(due), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }; - let predicted_due = predicted.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp(); - let should_replace = event - .as_ref() - .is_none_or(|e| predicted_due < e.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp()); - if should_replace { - event = Some(predicted); - } - } - - event.unwrap_or_default() - } - - async fn eval_inner(&self, obj: &ObjectOpts, now: OffsetDateTime, newer_noncurrent_versions: usize) -> Event { - let mut events = Vec::::new(); - debug!( - "eval_inner: object={}, mod_time={:?}, successor_mod_time={:?}, now={:?}, is_latest={}, delete_marker={}", - obj.name, obj.mod_time, obj.successor_mod_time, now, obj.is_latest, obj.delete_marker - ); - - // Gracefully handle missing mod_time instead of panicking - let mod_time = match obj.mod_time { - Some(t) => t, - None => { - debug!("eval_inner: mod_time is None for object={}, returning default event", obj.name); - return Event::default(); - } - }; - - if mod_time.unix_timestamp() == 0 { - debug!("eval_inner: mod_time is 0, returning default event"); - return Event::default(); - } - - if let Some(restore_expires) = obj.restore_expires - && restore_expires.unix_timestamp() != 0 - && now.unix_timestamp() > restore_expires.unix_timestamp() - { - let mut action = IlmAction::DeleteRestoredAction; - if !obj.is_latest { - action = IlmAction::DeleteRestoredVersionAction; - } - - events.push(Event { - action, - due: Some(now), - rule_id: "".into(), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }); - } - - if let Some(ref lc_rules) = self.filter_rules(obj).await { - for rule in lc_rules.iter() { - if obj.is_latest && obj.expired_object_deletemarker() { - if let Some(expiration) = rule.expiration.as_ref() - && expiration.expired_object_delete_marker.is_some_and(|v| v) - { - // Preserve explicit date/days scheduling when configured. - // If only ExpiredObjectDeleteMarker=true is set, delete immediately. - let due = expiration.next_due(obj).unwrap_or(now); - if now.unix_timestamp() >= due.unix_timestamp() { - events.push(Event { - action: IlmAction::DeleteVersionAction, - rule_id: rule.id.clone().unwrap_or_default(), - due: Some(due), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }); - // Stop after scheduling an expired delete-marker event. - break; - } - } - // DelMarkerExpiration: expire delete marker after N days from mod_time - if obj.delete_marker - && let Some(ref dme) = rule.del_marker_expiration - && let Some(days) = dme.days - && days > 0 - { - let due = expected_expiry_time(mod_time, days); - if now.unix_timestamp() >= due.unix_timestamp() { - events.push(Event { - action: IlmAction::DelMarkerDeleteAllVersionsAction, - rule_id: rule.id.clone().unwrap_or_default(), - due: Some(due), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }); - } - continue; - } - } - - if !obj.is_latest - && let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration - && let Some(retain_newer_noncurrent_versions) = noncurrent_version_expiration.newer_noncurrent_versions - && newer_noncurrent_versions < retain_newer_noncurrent_versions as usize - { - continue; - } - - if !obj.is_latest - && let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration - && let Some(noncurrent_days) = noncurrent_version_expiration.noncurrent_days - && let Some(successor_mod_time) = obj.successor_mod_time - { - let expected_expiry = expected_expiry_time(successor_mod_time, noncurrent_days); - if now.unix_timestamp() >= expected_expiry.unix_timestamp() { - events.push(Event { - action: IlmAction::DeleteVersionAction, - rule_id: rule.id.clone().unwrap_or_default(), - due: Some(expected_expiry), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }); - } - } - - if !obj.is_latest - && let Some(noncurrent_version_transition) = rule - .noncurrent_version_transitions - .as_ref() - .and_then(|transitions| transitions.first()) - && let Some(storage_class) = noncurrent_version_transition.storage_class.as_ref() - && !storage_class.as_str().is_empty() - && !obj.delete_marker - && obj.transition_status != TRANSITION_COMPLETE - { - let due = noncurrent_version_transition.next_due(obj); - if let Some(due0) = due - && (now.unix_timestamp() == 0 || now.unix_timestamp() > due0.unix_timestamp()) - { - events.push(Event { - action: IlmAction::TransitionVersionAction, - rule_id: rule.id.clone().unwrap_or_default(), - due, - storage_class: storage_class.as_str().to_string(), - ..Default::default() - }); - } - } - - debug!( - "eval_inner: checking expiration condition - is_latest={}, delete_marker={}, version_id={:?}, condition_met={}", - obj.is_latest, - obj.delete_marker, - obj.version_id, - (obj.is_latest || obj.version_id.is_none_or(|v| v.is_nil())) && !obj.delete_marker - ); - // Allow expiration for latest objects OR non-versioned objects (empty version_id) - if (obj.is_latest || obj.version_id.is_none_or(|v| v.is_nil())) && !obj.delete_marker { - debug!("eval_inner: entering expiration check"); - if let Some(ref expiration) = rule.expiration { - if let Some(ref date) = expiration.date { - let date0 = OffsetDateTime::from(date.clone()); - if date0.unix_timestamp() != 0 && (now.unix_timestamp() >= date0.unix_timestamp()) { - debug!("eval_inner: expiration by date - date0={:?}", date0); - events.push(Event { - action: IlmAction::DeleteAction, - rule_id: rule.id.clone().unwrap_or_default(), - due: Some(date0), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }); - } - } else if let Some(days) = expiration.days { - let expected_expiry: OffsetDateTime = expected_expiry_time(mod_time, days); - debug!( - "eval_inner: expiration check - days={}, obj_time={:?}, expiry_time={:?}, now={:?}, should_expire={}", - days, - mod_time, - expected_expiry, - now, - now.unix_timestamp() > expected_expiry.unix_timestamp() - ); - if now.unix_timestamp() >= expected_expiry.unix_timestamp() { - debug!("eval_inner: object should expire, adding DeleteAction"); - let mut event = Event { - action: IlmAction::DeleteAction, - rule_id: rule.id.clone().unwrap_or_default(), - due: Some(expected_expiry), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - }; - // MinIO extension: ExpiredObjectAllVersions deletes all versions - if rule - .expiration - .as_ref() - .and_then(|e| e.expired_object_all_versions) - .unwrap_or(false) - { - event.action = IlmAction::DeleteAllVersionsAction; - } - events.push(event); - } - } else { - debug!("eval_inner: expiration.days is None"); - } - } else { - debug!("eval_inner: rule.expiration is None"); - } - - if obj.transition_status != TRANSITION_COMPLETE - && let Some(transition) = rule.transitions.as_ref().and_then(|transitions| transitions.first()) - && let Some(storage_class) = transition.storage_class.as_ref() - && !storage_class.as_str().is_empty() - { - let due = transition.next_due(obj); - if let Some(due0) = due - && (now.unix_timestamp() == 0 || now.unix_timestamp() > due0.unix_timestamp()) - { - events.push(Event { - action: IlmAction::TransitionAction, - rule_id: rule.id.clone().unwrap_or_default(), - due, - storage_class: storage_class.as_str().to_string(), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - }); - } - } - } - } - } - - if !events.is_empty() { - events.sort_by(|a, b| { - if now.unix_timestamp() > a.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() - && now.unix_timestamp() > b.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() - || a.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() - == b.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() - { - match a.action { - IlmAction::DeleteAllVersionsAction - | IlmAction::DelMarkerDeleteAllVersionsAction - | IlmAction::DeleteAction - | IlmAction::DeleteVersionAction => { - return Ordering::Less; - } - _ => (), - } - match b.action { - IlmAction::DeleteAllVersionsAction - | IlmAction::DelMarkerDeleteAllVersionsAction - | IlmAction::DeleteAction - | IlmAction::DeleteVersionAction => { - return Ordering::Greater; - } - _ => (), - } - return Ordering::Less; - } - - if a.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() - < b.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() - { - return Ordering::Less; - } - Ordering::Greater - }); - return events[0].clone(); - } - - Event::default() - } - - async fn noncurrent_versions_expiration_limit(self: Arc, obj: &ObjectOpts) -> Event { - if let Some(filter_rules) = self.filter_rules(obj).await { - for rule in filter_rules.iter() { - if let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration { - return if let Some(newer_noncurrent_versions) = noncurrent_version_expiration.newer_noncurrent_versions { - if newer_noncurrent_versions == 0 { - continue; - } - Event { - action: IlmAction::DeleteVersionAction, - rule_id: rule.id.clone().unwrap_or_default(), - noncurrent_days: noncurrent_version_expiration.noncurrent_days.unwrap_or(0) as u32, - newer_noncurrent_versions: newer_noncurrent_versions as usize, - due: Some(OffsetDateTime::UNIX_EPOCH), - storage_class: "".into(), - } - } else { - Event { - action: IlmAction::DeleteVersionAction, - rule_id: rule.id.clone().unwrap_or_default(), - noncurrent_days: noncurrent_version_expiration.noncurrent_days.unwrap_or(0) as u32, - newer_noncurrent_versions: 0, - due: Some(OffsetDateTime::UNIX_EPOCH), - storage_class: "".into(), - } - }; - } - } - } - Event::default() - } -} - -#[async_trait::async_trait] -pub trait LifecycleCalculate { - fn next_due(&self, obj: &ObjectOpts) -> Option; -} - -#[async_trait::async_trait] -impl LifecycleCalculate for LifecycleExpiration { - fn next_due(&self, obj: &ObjectOpts) -> Option { - if !obj.is_latest || !obj.delete_marker { - return None; - } - // Check date first (date-based expiration takes priority over days). - // A zero unix timestamp means "not set" (default value) and is skipped. - if let Some(ref date) = self.date { - let expiry_date = OffsetDateTime::from(date.clone()); - if expiry_date.unix_timestamp() != 0 { - return Some(expiry_date); - } - } - match self.days { - Some(days) => obj.mod_time.map(|mod_time| expected_expiry_time(mod_time, days)), - None => None, - } - } -} - -#[async_trait::async_trait] -impl LifecycleCalculate for NoncurrentVersionTransition { - fn next_due(&self, obj: &ObjectOpts) -> Option { - if obj.is_latest || self.storage_class.is_none() { - return None; - } - match self.noncurrent_days { - Some(noncurrent_days) => { - if let Some(successor_mod_time) = obj.successor_mod_time { - Some(expected_expiry_time(successor_mod_time, noncurrent_days)) - } else { - Some(expected_expiry_time(OffsetDateTime::now_utc(), noncurrent_days)) - } - } - None => obj.successor_mod_time, - } - } -} - -#[async_trait::async_trait] -impl LifecycleCalculate for Transition { - fn next_due(&self, obj: &ObjectOpts) -> Option { - if !obj.is_latest { - return None; - } - - if let Some(date) = self.date.clone() { - return Some(date.into()); - } - - match self.days { - Some(days) => Some(expected_expiry_time(obj.mod_time.unwrap(), days)), - None => obj.mod_time, - } - } -} - -pub fn expected_expiry_time(mod_time: OffsetDateTime, days: i32) -> OffsetDateTime { - if days == 0 { - debug!( - event = EVENT_LIFECYCLE_EXPIRY_COMPUTED, - component = LOG_COMPONENT_ECSTORE, - subsystem = LOG_SUBSYSTEM_LIFECYCLE, - days, - result = "unix_epoch", - "Computed immediate lifecycle expiry time" - ); - return OffsetDateTime::UNIX_EPOCH; // Return epoch time to ensure immediate expiry - } - let t = mod_time - .to_offset(offset!(-0:00:00)) - .saturating_add(Duration::days(days as i64)); - - // Round up to the next processing boundary per S3-compatible Days semantics. - // Canonical key: RUSTFS_ILM_PROCESS_TIME; deprecated alias: _RUSTFS_ILM_PROCESS_TIME. - // TODO(GA): Remove ENV_ILM_PROCESS_TIME_DEPRECATED compatibility during GA release. - let process_interval_secs = rustfs_utils::get_env_i32_with_aliases( - ENV_ILM_PROCESS_TIME, - &[ENV_ILM_PROCESS_TIME_DEPRECATED], - DEFAULT_ILM_PROCESS_TIME_SECS, - ); - let process_interval_secs = if process_interval_secs > 0 { - process_interval_secs as u32 - } else { - DEFAULT_ILM_PROCESS_TIME_SECS as u32 - }; - - let boundary_nanos = i128::from(process_interval_secs) * 1_000_000_000; - let timestamp_nanos = t.unix_timestamp_nanos(); - let remainder = timestamp_nanos.rem_euclid(boundary_nanos); - let rounded_nanos = if remainder == 0 { - timestamp_nanos - } else { - timestamp_nanos + (boundary_nanos - remainder) - }; - OffsetDateTime::from_unix_timestamp_nanos(rounded_nanos).unwrap_or(t) -} - -pub async fn abort_incomplete_multipart_upload_due( - lc: &BucketLifecycleConfiguration, - obj: &ObjectOpts, -) -> Option<(OffsetDateTime, String)> { - let initiated = obj.mod_time?; - let rules = lc.filter_rules(obj).await?; - - rules - .into_iter() - .filter_map(|rule| { - let days = rule - .abort_incomplete_multipart_upload - .as_ref()? - .days_after_initiation - .filter(|days| *days >= 0)?; - Some((expected_expiry_time(initiated, days), rule.id.unwrap_or_default())) - }) - .min_by_key(|(due, _)| due.unix_timestamp_nanos()) -} - -#[derive(Default)] -pub struct ObjectOpts { - pub name: String, - pub user_tags: String, - pub mod_time: Option, - pub size: usize, - pub version_id: Option, - pub is_latest: bool, - pub delete_marker: bool, - pub num_versions: usize, - pub successor_mod_time: Option, - pub transition_status: String, - pub restore_ongoing: bool, - pub restore_expires: Option, - pub versioned: bool, - pub version_suspended: bool, - pub user_defined: HashMap, - pub version_purge_status: VersionPurgeStatusType, - pub replication_status: ReplicationStatusType, -} - -impl ObjectOpts { - pub fn expired_object_deletemarker(&self) -> bool { - self.delete_marker && self.is_latest && self.num_versions == 1 - } - - pub fn from_object_info(oi: &ObjectInfo) -> Self { - Self { - name: oi.name.clone(), - user_tags: (*oi.user_tags).clone(), - mod_time: oi.mod_time, - size: oi.size as usize, - version_id: oi.version_id, - is_latest: oi.is_latest, - delete_marker: oi.delete_marker, - num_versions: oi.num_versions, - successor_mod_time: oi.successor_mod_time, - transition_status: oi.transitioned_object.status.clone(), - restore_ongoing: oi.restore_ongoing, - restore_expires: oi.restore_expires, - versioned: false, - version_suspended: false, - user_defined: (*oi.user_defined).clone(), - version_purge_status: oi.version_purge_status.clone(), - replication_status: oi.replication_status.clone(), - } - } -} - -#[derive(Debug, Clone)] -pub struct Event { - pub action: IlmAction, - pub rule_id: String, - pub due: Option, - pub noncurrent_days: u32, - pub newer_noncurrent_versions: usize, - pub storage_class: String, -} - -impl Default for Event { - fn default() -> Self { - Self { - action: IlmAction::NoneAction, - rule_id: "".into(), - due: Some(OffsetDateTime::UNIX_EPOCH), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: "".into(), - } - } -} - -pub use crate::storage_api_contracts::lifecycle::ExpirationOptions; - -#[derive(Debug, Clone)] -pub struct TransitionOptions { - pub status: String, - pub tier: String, - pub etag: String, - pub restore_request: RestoreRequest, - pub restore_expiry: OffsetDateTime, - pub expire_restored: bool, -} - -impl Default for TransitionOptions { - fn default() -> Self { - Self { - status: Default::default(), - tier: Default::default(), - etag: Default::default(), - restore_request: Default::default(), - restore_expiry: OffsetDateTime::now_utc(), - expire_restored: Default::default(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use s3s::dto::{LifecycleRuleFilter, TransitionStorageClass}; - use serial_test::serial; - use std::sync::Arc; - use time::macros::datetime; - - fn with_default_ilm_process_time(test: impl FnOnce()) { - temp_env::with_var_unset(ENV_ILM_PROCESS_TIME, || { - temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, test); - }); - } - - #[tokio::test] - #[serial] - async fn validate_accepts_zero_expiration_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(0), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("zero-day expiration should be accepted"); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_negative_expiration_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(-1), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let err = lc - .validate(&ObjectLockConfiguration::default()) - .await - .expect_err("expected validation error"); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS); - } - - #[tokio::test] - #[serial] - async fn validate_accepts_positive_expiration_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("expected validation to pass"); - } - - #[tokio::test] - #[serial] - async fn has_active_rules_accepts_zero_day_expiration() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(0), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("zero-day-active".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("test/".to_string()), - transitions: None, - }], - }; - - assert!(lc.has_active_rules("test/")); - } - - #[tokio::test] - #[serial] - async fn validate_accepts_zero_noncurrent_expiration_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { - noncurrent_days: Some(0), - newer_noncurrent_versions: None, - }), - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("zero-day noncurrent expiration should be accepted"); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_negative_noncurrent_expiration_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { - noncurrent_days: Some(-1), - newer_noncurrent_versions: None, - }), - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let err = lc - .validate(&ObjectLockConfiguration::default()) - .await - .expect_err("expected validation error"); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS); - } - - #[tokio::test] - #[serial] - async fn validate_accepts_abort_incomplete_multipart_upload_only_rule() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { - days_after_initiation: Some(2), - }), - del_marker_expiration: None, - filter: None, - id: Some("abort-only".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("test/".to_string()), - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("expected validation to pass"); - } - - #[tokio::test] - #[serial] - async fn validate_accepts_zero_abort_incomplete_multipart_upload_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { - days_after_initiation: Some(0), - }), - del_marker_expiration: None, - filter: None, - id: Some("abort-zero".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("test/".to_string()), - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("zero-day abort incomplete multipart upload should be accepted"); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_missing_abort_incomplete_multipart_upload_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { - days_after_initiation: None, - }), - del_marker_expiration: None, - filter: None, - id: Some("abort-missing".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("test/".to_string()), - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_negative_abort_incomplete_multipart_upload_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { - days_after_initiation: Some(-1), - }), - del_marker_expiration: None, - filter: None, - id: Some("abort-negative".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("test/".to_string()), - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS); - } - - #[tokio::test] - #[serial] - async fn abort_incomplete_multipart_upload_due_accepts_zero_days() { - let initiated = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { - days_after_initiation: Some(0), - }), - del_marker_expiration: None, - filter: None, - id: Some("abort-zero".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("test/".to_string()), - transitions: None, - }], - }; - let opts = ObjectOpts { - name: "test/object".to_string(), - mod_time: Some(initiated), - ..Default::default() - }; - - let (due, rule_id) = abort_incomplete_multipart_upload_due(&lc, &opts) - .await - .expect("zero-day abort rule should be due"); - - assert_eq!(rule_id, "abort-zero"); - assert_eq!(due, OffsetDateTime::UNIX_EPOCH); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_non_midnight_expiration_date() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - date: Some(OffsetDateTime::from_unix_timestamp(20_000_101).unwrap().into()), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRATION_DATE_NOT_MIDNIGHT); - } - - #[tokio::test] - #[serial] - async fn predict_expiration_selects_closest_expiry_for_put_object() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![ - LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("rule-days".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }, - LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - date: Some((base_time + Duration::days(1)).into()), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("rule-date".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }, - ], - }; - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - version_id: None, - ..Default::default() - }; - - let event = lc.predict_expiration(&opts).await; - let expected = base_time + Duration::days(1); - - assert_eq!(event.action, IlmAction::DeleteAction); - assert_eq!(event.rule_id, "rule-date"); - assert_eq!(event.due, Some(expected)); - } - - #[tokio::test] - #[serial] - async fn validate_accepts_multiple_rules_without_ids() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![ - LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }, - LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(31), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }, - ], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("expected validation to pass"); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_rule_id_too_long() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("a".repeat(256)), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_duplicate_rule_ids() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![ - LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("dup-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }, - LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(2), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("dup-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }, - ], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - assert_eq!(err.to_string(), ERR_LIFECYCLE_DUPLICATE_ID); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_transition_without_storage_class() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("transition-no-storage".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: Some(vec![Transition { - days: Some(1), - date: None, - storage_class: None, - }]), - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), "ERR_XML_NOT_WELL_FORMED"); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_transition_without_date_or_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("transition-no-schedule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: Some(vec![Transition { - days: None, - date: None, - storage_class: Some(TransitionStorageClass::from_static("WARM")), - }]), - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert!(err.to_string().contains("Exactly one of Days")); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_noncurrent_transition_without_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("noncurrent-transition-no-days".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: Some(vec![NoncurrentVersionTransition { - noncurrent_days: None, - newer_noncurrent_versions: None, - storage_class: Some(TransitionStorageClass::from_static("COLD")), - }]), - prefix: None, - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert!(err.to_string().contains("Exactly one of Days")); - } - - #[tokio::test] - #[serial] - async fn empty_transition_vectors_are_not_active_or_due() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("empty-transition".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: Some(vec![]), - prefix: None, - transitions: Some(vec![]), - }], - }; - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }; - - assert!(!lc.has_transition().await); - assert!(!lc.has_active_rules("obj")); - assert_eq!(lc.eval_inner(&opts, OffsetDateTime::now_utc(), 0).await.action, IlmAction::NoneAction); - } - - #[tokio::test] - #[serial] - async fn eval_inner_expires_latest_object_after_days_due() { - let base_time = datetime!(2025-01-15 10:30:45 UTC); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("expire-days".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - ..Default::default() - }; - let event = lc.eval_inner(&opts, datetime!(2025-01-17 00:00:00 UTC), 0).await; - - assert_eq!(event.action, IlmAction::DeleteAction); - assert_eq!(event.rule_id, "expire-days"); - assert_eq!(event.due, Some(datetime!(2025-01-17 00:00:00 UTC))); - } - - #[tokio::test] - #[serial] - async fn eval_inner_keeps_latest_object_before_days_due() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(2), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("expire-days".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - ..Default::default() - }; - let event = lc.eval_inner(&opts, base_time + Duration::hours(12), 0).await; - - assert_eq!(event.action, IlmAction::NoneAction); - } - - #[tokio::test] - #[serial] - async fn eval_inner_transitions_latest_object_after_days_due() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("transition-days".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: Some(vec![Transition { - days: Some(1), - date: None, - storage_class: Some(TransitionStorageClass::from_static("COLDTIER44")), - }]), - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - transition_status: "".to_string(), - ..Default::default() - }; - let event = lc.eval_inner(&opts, base_time + Duration::days(2), 0).await; - - assert_eq!(event.action, IlmAction::TransitionAction); - assert_eq!(event.rule_id, "transition-days"); - assert_eq!(event.storage_class, "COLDTIER44"); - } - - #[tokio::test] - #[serial] - async fn eval_inner_transitions_latest_object_after_date_due() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let transition_date = base_time - Duration::days(1); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("transition-date".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: Some(vec![Transition { - days: None, - date: Some(transition_date.into()), - storage_class: Some(TransitionStorageClass::from_static("WARM")), - }]), - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - transition_status: "".to_string(), - ..Default::default() - }; - let event = lc.eval_inner(&opts, base_time + Duration::days(1), 0).await; - - assert_eq!(event.action, IlmAction::TransitionAction); - assert_eq!(event.rule_id, "transition-date"); - assert_eq!(event.storage_class, "WARM"); - assert_eq!(event.due, Some(transition_date)); - } - - #[tokio::test] - #[serial] - async fn eval_inner_expires_noncurrent_version_after_due() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("noncurrent-expire".to_string()), - noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { - noncurrent_days: Some(1), - newer_noncurrent_versions: None, - }), - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - successor_mod_time: Some(base_time), - is_latest: false, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - let event = lc.eval_inner(&opts, base_time + Duration::days(2), 0).await; - - assert_eq!(event.action, IlmAction::DeleteVersionAction); - assert_eq!(event.rule_id, "noncurrent-expire"); - assert_eq!(event.due, Some(expected_expiry_time(base_time, 1))); - } - - #[tokio::test] - #[serial] - async fn eval_inner_expires_noncurrent_version_immediately_when_zero_days() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("noncurrent-expire-immediate".to_string()), - noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { - noncurrent_days: Some(0), - newer_noncurrent_versions: None, - }), - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - successor_mod_time: Some(base_time), - is_latest: false, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - let event = lc.eval_inner(&opts, base_time, 0).await; - - assert_eq!(event.action, IlmAction::DeleteVersionAction); - assert_eq!(event.rule_id, "noncurrent-expire-immediate"); - assert_eq!(event.due, Some(expected_expiry_time(base_time, 0))); - } - - #[tokio::test] - #[serial] - async fn eval_inner_transitions_noncurrent_version_after_due() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("noncurrent-transition".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: Some(vec![NoncurrentVersionTransition { - noncurrent_days: Some(1), - newer_noncurrent_versions: None, - storage_class: Some(TransitionStorageClass::from_static("COLDTIER44")), - }]), - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - successor_mod_time: Some(base_time), - is_latest: false, - transition_status: "".to_string(), - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - let event = lc.eval_inner(&opts, base_time + Duration::days(2), 0).await; - - assert_eq!(event.action, IlmAction::TransitionVersionAction); - assert_eq!(event.rule_id, "noncurrent-transition"); - assert_eq!(event.storage_class, "COLDTIER44"); - } - - #[tokio::test] - #[serial] - async fn noncurrent_versions_expiration_limit_returns_configured_limits() { - let lc = Arc::new(BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("noncurrent-limit".to_string()), - noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { - noncurrent_days: Some(7), - newer_noncurrent_versions: Some(3), - }), - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }); - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: false, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - let event = lc.noncurrent_versions_expiration_limit(&opts).await; - - assert_eq!(event.action, IlmAction::DeleteVersionAction); - assert_eq!(event.rule_id, "noncurrent-limit"); - assert_eq!(event.noncurrent_days, 7); - assert_eq!(event.newer_noncurrent_versions, 3); - } - - #[tokio::test] - #[serial] - async fn evaluator_honors_newer_noncurrent_versions_retention_count() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = Arc::new(BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("retain-two-noncurrent".to_string()), - noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { - noncurrent_days: Some(1), - newer_noncurrent_versions: Some(2), - }), - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }); - let mut objs = vec![ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time + Duration::days(4)), - successor_mod_time: None, - is_latest: true, - version_id: Some(Uuid::new_v4()), - num_versions: 5, - ..Default::default() - }]; - for days_ago in (0..4).rev() { - objs.push(ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time + Duration::days(days_ago)), - successor_mod_time: Some(base_time + Duration::days(days_ago + 1)), - is_latest: false, - version_id: Some(Uuid::new_v4()), - num_versions: 5, - ..Default::default() - }); - } - - let events = crate::bucket::lifecycle::evaluator::Evaluator::new(lc) - .eval(&objs) - .await - .expect("version group should evaluate"); - - assert_eq!(events[1].action, IlmAction::NoneAction); - assert_eq!(events[2].action, IlmAction::NoneAction); - assert_eq!(events[3].action, IlmAction::DeleteVersionAction); - assert_eq!(events[4].action, IlmAction::DeleteVersionAction); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_invalid_status_case_sensitive() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static("enabled"), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: None, - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_RULE_STATUS); - } - - #[tokio::test] - #[serial] - async fn filter_rules_respects_filter_prefix() { - let filter = LifecycleRuleFilter { - prefix: Some("prefix".to_string()), - ..Default::default() - }; - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: Some(filter), - id: Some("rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let match_obj = ObjectOpts { - name: "prefix/file".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }; - let matched = lc.filter_rules(&match_obj).await.unwrap(); - assert_eq!(matched.len(), 1); - - let non_match_obj = ObjectOpts { - name: "other/file".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }; - let not_matched = lc.filter_rules(&non_match_obj).await.unwrap(); - assert_eq!(not_matched.len(), 0); - } - - #[tokio::test] - #[serial] - async fn filter_rules_respects_filter_and_prefix() { - let and = s3s::dto::LifecycleRuleAndOperator { - prefix: Some("prefix".to_string()), - ..Default::default() - }; - let filter = LifecycleRuleFilter { - and: Some(and), - ..Default::default() - }; - - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: Some(filter), - id: Some("rule-and-prefix".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let match_obj = ObjectOpts { - name: "prefix/file".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }; - let matched = lc.filter_rules(&match_obj).await.unwrap(); - assert_eq!(matched.len(), 1); - - let non_match_obj = ObjectOpts { - name: "other/file".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }; - let not_matched = lc.filter_rules(&non_match_obj).await.unwrap(); - assert_eq!(not_matched.len(), 0); - } - - #[tokio::test] - #[serial] - async fn filter_rules_respects_filter_tag() { - let filter = LifecycleRuleFilter { - tag: Some(s3s::dto::Tag { - key: Some("env".to_string()), - value: Some("prod".to_string()), - }), - ..Default::default() - }; - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: Some(filter), - id: Some("rule-tag".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let matched = lc - .filter_rules(&ObjectOpts { - name: "obj".to_string(), - user_tags: "env=prod&team=storage".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(matched.len(), 1); - - let not_matched = lc - .filter_rules(&ObjectOpts { - name: "obj".to_string(), - user_tags: "env=dev&team=storage".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(not_matched.len(), 0); - } - - #[tokio::test] - #[serial] - async fn filter_rules_respects_filter_and_tags() { - let filter = LifecycleRuleFilter { - and: Some(s3s::dto::LifecycleRuleAndOperator { - tags: Some(vec![ - s3s::dto::Tag { - key: Some("env".to_string()), - value: Some("prod".to_string()), - }, - s3s::dto::Tag { - key: Some("team".to_string()), - value: Some("storage".to_string()), - }, - ]), - ..Default::default() - }), - ..Default::default() - }; - - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: Some(filter), - id: Some("rule-and-tags".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let matched = lc - .filter_rules(&ObjectOpts { - name: "obj".to_string(), - user_tags: "env=prod&team=storage".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(matched.len(), 1); - - let not_matched = lc - .filter_rules(&ObjectOpts { - name: "obj".to_string(), - user_tags: "env=prod&team=platform".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), - is_latest: true, - ..Default::default() - }) - .await - .unwrap(); - assert_eq!(not_matched.len(), 0); - } - - #[tokio::test] - #[serial] - async fn expired_object_delete_marker_ignores_marker_with_noncurrent_versions_present() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("rule-expired-del-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - delete_marker: true, - num_versions: 2, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - - let now = base_time + Duration::days(2); - let event = lc.eval_inner(&opts, now, 0).await; - assert_eq!(event.action, IlmAction::NoneAction); - assert_eq!(event.due, Some(OffsetDateTime::UNIX_EPOCH)); - } - - #[tokio::test] - async fn expired_object_delete_marker_ignores_unknown_version_count() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("rule-expired-del-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - delete_marker: true, - num_versions: 0, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - - let now = base_time + Duration::days(2); - let event = lc.eval_inner(&opts, now, 0).await; - assert_eq!(event.action, IlmAction::NoneAction); - assert_eq!(event.due, Some(OffsetDateTime::UNIX_EPOCH)); - } - - #[tokio::test] - #[serial] - async fn expired_object_delete_marker_deletes_only_delete_marker_immediately() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("rule-expired-del-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - delete_marker: true, - num_versions: 1, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - - let now = base_time + Duration::days(2); - let event = lc.eval_inner(&opts, now, 0).await; - - assert_eq!(event.action, IlmAction::DeleteVersionAction); - assert_eq!(event.due, Some(now)); - } - - #[tokio::test] - async fn expired_object_delete_marker_without_date_or_days_deletes_immediately() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("rule-expired-del-marker-immediate".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - delete_marker: true, - num_versions: 1, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - - let now = base_time + Duration::days(2); - let event = lc.eval_inner(&opts, now, 0).await; - assert_eq!(event.action, IlmAction::DeleteVersionAction); - assert_eq!(event.due, Some(now)); - } - - #[tokio::test] - async fn validate_rejects_expired_object_delete_marker_with_date() { - let future_date = OffsetDateTime::from_unix_timestamp(86_400 * 10).expect("valid midnight UTC test timestamp"); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - date: Some(future_date.into()), - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("rule-date-del-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER); - } - - #[tokio::test] - async fn validate_rejects_expired_object_delete_marker_with_days() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: None, - id: Some("rule-days-del-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER); - } - - #[tokio::test] - async fn validate_rejects_expired_object_delete_marker_with_tag_filter() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - filter: Some(LifecycleRuleFilter { - tag: Some(s3s::dto::Tag { - key: Some("env".to_string()), - value: Some("prod".to_string()), - }), - ..Default::default() - }), - id: Some("rule-tag-del-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - del_marker_expiration: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - - assert_eq!(err.to_string(), ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS); - } - - // --- TASK-002 tests: Object Lock + ExpiredObjectDeleteMarker compatibility --- - - #[tokio::test] - #[serial] - async fn validate_allows_expired_object_delete_marker_on_locked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let locked_config = ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - ..Default::default() - }; - - lc.validate(&locked_config) - .await - .expect("expected validation to pass for ExpiredObjectDeleteMarker on locked bucket"); - } - - #[tokio::test] - #[serial] - async fn validate_allows_expired_object_delete_marker_on_unlocked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - // Default ObjectLockConfiguration (no lock enabled) should pass - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("expected validation to pass on unlocked bucket"); - } - - #[tokio::test] - #[serial] - async fn validate_allows_non_delete_marker_expiration_on_locked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let locked_config = ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - ..Default::default() - }; - - // Days-based expiration (not DeleteMarker) should be allowed on locked bucket - lc.validate(&locked_config) - .await - .expect("expected days-based expiration to pass on locked bucket"); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_del_marker_expiration_on_locked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(1) }), - filter: None, - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let locked_config = ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - ..Default::default() - }; - - let err = lc.validate(&locked_config).await.unwrap_err(); - assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); - } - - #[tokio::test] - #[serial] - async fn validate_rejects_zero_day_del_marker_expiration_on_locked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(0) }), - filter: None, - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let locked_config = ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - ..Default::default() - }; - - let err = lc.validate(&locked_config).await.unwrap_err(); - assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); - } - - // --- TASK-003 tests: Round up to next UTC processing boundary --- - - #[test] - #[serial] - fn expected_expiry_time_rounds_up_to_next_midnight_utc() { - with_default_ilm_process_time(|| { - // Object created at 2025-01-15T10:30:45Z, expire in 30 days - let mod_time = datetime!(2025-01-15 10:30:45 UTC); - let result = expected_expiry_time(mod_time, 30); - - // Should round up to the next midnight: 2025-02-15T00:00:00Z - assert_eq!(result.hour(), 0); - assert_eq!(result.minute(), 0); - assert_eq!(result.second(), 0); - assert_eq!(result, datetime!(2025-02-15 00:00:00 UTC)); - }); - } - - #[test] - #[serial] - fn expected_expiry_time_immediate_expiry_returns_epoch() { - with_default_ilm_process_time(|| { - let mod_time = datetime!(2025-06-01 12:00:00 UTC); - let result = expected_expiry_time(mod_time, 0); - assert_eq!(result, OffsetDateTime::UNIX_EPOCH); - }); - } - - #[test] - #[serial] - fn expected_expiry_time_preserves_exact_midnight_boundary() { - with_default_ilm_process_time(|| { - let mod_time = datetime!(2025-03-01 00:00:00 UTC); - let result = expected_expiry_time(mod_time, 1); - assert_eq!(result, datetime!(2025-03-02 00:00:00 UTC)); - }); - } - - #[test] - #[serial] - fn expected_expiry_time_rounds_end_of_day_to_following_midnight() { - with_default_ilm_process_time(|| { - let mod_time = datetime!(2025-06-15 23:59:59 UTC); - let result = expected_expiry_time(mod_time, 1); - assert_eq!(result, datetime!(2025-06-17 00:00:00 UTC)); - }); - } - - #[test] - #[serial] - fn expected_expiry_time_uses_canonical_process_time_boundary() { - let mod_time = datetime!(2025-01-15 10:30:45 UTC); - - temp_env::with_var(ENV_ILM_PROCESS_TIME, Some("3600"), || { - temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, || { - let result = expected_expiry_time(mod_time, 1); - assert_eq!(result, datetime!(2025-01-16 11:00:00 UTC)); - }); - }); - } - - #[test] - #[serial] - fn expected_expiry_time_uses_deprecated_process_time_alias() { - let mod_time = datetime!(2025-01-15 10:30:45 UTC); - - temp_env::with_var_unset(ENV_ILM_PROCESS_TIME, || { - temp_env::with_var(ENV_ILM_PROCESS_TIME_DEPRECATED, Some("3600"), || { - let result = expected_expiry_time(mod_time, 1); - assert_eq!(result, datetime!(2025-01-16 11:00:00 UTC)); - }); - }); - } - - #[test] - #[serial] - fn expected_expiry_time_uses_default_boundary_when_process_time_is_zero_or_invalid() { - let mod_time = datetime!(2025-01-15 10:30:45 UTC); - - temp_env::with_var(ENV_ILM_PROCESS_TIME, Some("0"), || { - temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, || { - let result = expected_expiry_time(mod_time, 30); - assert_eq!(result, datetime!(2025-02-15 00:00:00 UTC)); - }); - }); - - temp_env::with_var(ENV_ILM_PROCESS_TIME, Some("not-a-number"), || { - temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, || { - let result = expected_expiry_time(mod_time, 30); - assert_eq!(result, datetime!(2025-02-15 00:00:00 UTC)); - }); - }); - } - - // --- TASK-007 tests: Legacy Prefix/Filter conflict --- - - #[tokio::test] - #[serial] - async fn validate_rejects_prefix_and_filter_both_present() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: Some(LifecycleRuleFilter { - prefix: Some("logs/".to_string()), - ..Default::default() - }), - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("archive/".to_string()), - transitions: None, - }], - }; - - let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); - assert_eq!(err.to_string(), ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT); - } - - #[tokio::test] - #[serial] - async fn validate_allows_prefix_without_filter() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("logs/".to_string()), - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("prefix without filter should be valid"); - } - - #[tokio::test] - #[serial] - async fn validate_allows_filter_without_prefix() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: Some(LifecycleRuleFilter { - prefix: Some("logs/".to_string()), - ..Default::default() - }), - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("filter without prefix should be valid"); - } - - #[tokio::test] - #[serial] - async fn validate_allows_empty_prefix_with_filter() { - // Empty prefix should be treated as "not set" - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: Some(LifecycleRuleFilter { - prefix: Some("logs/".to_string()), - ..Default::default() - }), - id: Some("test-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: Some("".to_string()), // empty = not set - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("empty prefix with filter should be valid"); - } - - // --- TASK-004 tests: ExpiredObjectAllVersions --- - - #[tokio::test] - #[serial] - async fn validate_rejects_expired_object_all_versions_on_locked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - expired_object_all_versions: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("all-versions-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let locked_config = ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - ..Default::default() - }; - - let err = lc.validate(&locked_config).await.unwrap_err(); - assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); - } - - #[tokio::test] - #[serial] - async fn validate_allows_expired_object_all_versions_on_unlocked_bucket() { - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(30), - expired_object_all_versions: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("all-versions-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - lc.validate(&ObjectLockConfiguration::default()) - .await - .expect("ExpiredObjectAllVersions should be allowed on unlocked bucket"); - } - - #[tokio::test] - #[serial] - async fn eval_inner_triggers_delete_all_versions_when_expired_object_all_versions_set() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - expired_object_all_versions: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("all-versions-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - version_id: None, - ..Default::default() - }; - - // now is after the expiry time - let now = base_time + Duration::days(2); - let event = lc.eval_inner(&opts, now, 0).await; - assert_eq!(event.action, IlmAction::DeleteAllVersionsAction); - assert_eq!(event.rule_id, "all-versions-rule"); - } - - #[tokio::test] - #[serial] - async fn eval_inner_uses_delete_action_when_all_versions_not_set() { - let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); - let lc = BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - expired_object_all_versions: None, // not set - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("normal-rule".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }; - - let opts = ObjectOpts { - name: "obj".to_string(), - mod_time: Some(base_time), - is_latest: true, - version_id: None, - ..Default::default() - }; - - let now = base_time + Duration::days(2); - let event = lc.eval_inner(&opts, now, 0).await; - // Without ExpiredObjectAllVersions, should use normal DeleteAction - assert_eq!(event.action, IlmAction::DeleteAction); +pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts { + ObjectOpts { + name: oi.name.clone(), + user_tags: (*oi.user_tags).clone(), + mod_time: oi.mod_time, + size: usize::try_from(oi.size).unwrap_or(usize::MAX), + version_id: oi.version_id, + is_latest: oi.is_latest, + delete_marker: oi.delete_marker, + num_versions: oi.num_versions, + successor_mod_time: oi.successor_mod_time, + transition_status: oi.transitioned_object.status.clone(), + restore_ongoing: oi.restore_ongoing, + restore_expires: oi.restore_expires, + versioned: false, + version_suspended: false, + user_defined: (*oi.user_defined).clone(), + version_purge_status: oi.version_purge_status.clone(), + replication_status: oi.replication_status.clone(), } } diff --git a/crates/ecstore/src/bucket/lifecycle/evaluator.rs b/crates/ecstore/src/bucket/lifecycle/evaluator.rs index 8ee3509db..d04102e1e 100644 --- a/crates/ecstore/src/bucket/lifecycle/evaluator.rs +++ b/crates/ecstore/src/bucket/lifecycle/evaluator.rs @@ -12,406 +12,4 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::sync::Arc; - -use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled}; -use time::OffsetDateTime; -use tracing::info; - -use super::object_lock_boundary; -use crate::bucket::lifecycle::lifecycle::{Event, Lifecycle, ObjectOpts}; -use crate::bucket::lifecycle::replication_sink::{self, LifecycleReplicationConfig}; -use rustfs_common::metrics::IlmAction; - -const LOG_COMPONENT_ECSTORE: &str = "ecstore"; -const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle"; -const EVENT_LIFECYCLE_VERSION_SCAN_SKIPPED: &str = "lifecycle_version_scan_skipped"; - -/// Evaluator - evaluates lifecycle policy on objects for the given lifecycle -/// configuration and lock retention configuration. -pub struct Evaluator { - policy: Arc, - lock_retention: Option>, -} - -impl Evaluator { - /// NewEvaluator - creates a new evaluator with the given lifecycle - pub fn new(policy: Arc) -> Self { - Self { - policy, - lock_retention: None, - } - } - - /// WithLockRetention - sets the lock retention configuration for the evaluator - pub fn with_lock_retention(mut self, lr: Option>) -> Self { - self.lock_retention = lr; - self - } - - /// WithReplicationConfig is retained for caller compatibility. - /// Lifecycle replication guards are evaluated from per-object replication state. - pub fn with_replication_config(self, _rcfg: Option>) -> Self { - self - } - - /// IsPendingReplication checks if the object is pending replication. - pub fn is_pending_replication(&self, obj: &ObjectOpts) -> bool { - replication_sink::has_pending_lifecycle_replication(obj) - } - - fn any_version_has_pending_replication(&self, objs: &[ObjectOpts]) -> bool { - objs.iter().any(|obj| self.is_pending_replication(obj)) - } - - /// IsObjectLocked checks if it is appropriate to remove an - /// 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() - .is_none_or(|v| v.as_str() != ObjectLockEnabled::ENABLED) - }) { - return false; - } - - // Use the common function to check if the object is locked - object_lock_boundary::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. - async fn eval_inner(&self, objs: &[ObjectOpts], now: OffsetDateTime) -> Vec { - let mut events = vec![Event::default(); objs.len()]; - let mut newer_noncurrent_versions = 0; - - 'top_loop: { - for (i, obj) in objs.iter().enumerate() { - let mut event = self.policy.eval_inner(obj, now, newer_noncurrent_versions).await; - if replication_sink::lifecycle_action_waits_for_replication(event.action) && self.is_pending_replication(obj) { - event = Event::default(); - } - match event.action { - IlmAction::DeleteAllVersionsAction | IlmAction::DelMarkerDeleteAllVersionsAction => { - // Skip if bucket has object locking enabled; To prevent the - // possibility of violating an object retention on one of the - // noncurrent versions of this object. - if self.lock_retention.as_ref().is_some_and(|v| { - v.object_lock_enabled - .as_ref() - .is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED) - }) || self.any_version_has_pending_replication(objs) - { - event = Event::default(); - } else { - // No need to evaluate remaining versions' lifecycle - // events after DeleteAllVersionsAction* - events[i] = event; - - info!( - event = EVENT_LIFECYCLE_VERSION_SCAN_SKIPPED, - component = LOG_COMPONENT_ECSTORE, - subsystem = LOG_SUBSYSTEM_LIFECYCLE, - reason = "delete_all_versions_action", - action = ?events[i].action, - "Skipped remaining lifecycle version scan" - ); - - break 'top_loop; - } - } - IlmAction::DeleteVersionAction | IlmAction::DeleteRestoredVersionAction => { - // Defensive code, should never happen - if obj.version_id.is_none_or(|v| v.is_nil()) { - event.action = IlmAction::NoneAction; - } - if self.is_object_locked(obj) { - event = Event::default(); - } - } - _ => {} - } - - if !obj.is_latest { - match event.action { - IlmAction::DeleteVersionAction => { - // this noncurrent version will be expired, nothing to add - } - _ => { - // this noncurrent version will be spared - newer_noncurrent_versions += 1; - } - } - } - events[i] = event; - } - } - events - } - - /// Eval will return a lifecycle event for each object in objs - pub async fn eval(&self, objs: &[ObjectOpts]) -> Result, std::io::Error> { - if objs.is_empty() { - return Ok(vec![]); - } - if objs.len() != objs[0].num_versions { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("number of versions mismatch, expected {}, got {}", objs[0].num_versions, objs.len()), - )); - } - Ok(self.eval_inner(objs, OffsetDateTime::now_utc()).await) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use rustfs_common::metrics::IlmAction; - use s3s::dto::{ - BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, Transition, TransitionStorageClass, - }; - use time::OffsetDateTime; - use uuid::Uuid; - - use super::*; - use crate::bucket::lifecycle::replication_sink::{ReplicationStatusType, VersionPurgeStatusType}; - fn expired_marker_lifecycle() -> Arc { - Arc::new(BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - expired_object_delete_marker: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("expired-marker".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }) - } - - fn latest_expiration_lifecycle() -> Arc { - Arc::new(BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("expire-current".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }) - } - - fn latest_transition_lifecycle() -> Arc { - Arc::new(BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: None, - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("transition-current".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: Some(vec![Transition { - days: Some(1), - date: None, - storage_class: Some(TransitionStorageClass::from_static("WARM")), - }]), - }], - }) - } - - fn all_versions_expiration_lifecycle() -> Arc { - Arc::new(BucketLifecycleConfiguration { - expiry_updated_at: None, - rules: vec![LifecycleRule { - status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), - expiration: Some(LifecycleExpiration { - days: Some(1), - expired_object_all_versions: Some(true), - ..Default::default() - }), - abort_incomplete_multipart_upload: None, - del_marker_expiration: None, - filter: None, - id: Some("delete-all".to_string()), - noncurrent_version_expiration: None, - noncurrent_version_transitions: None, - prefix: None, - transitions: None, - }], - }) - } - - fn object_opts(replication_status: ReplicationStatusType, version_purge_status: VersionPurgeStatusType) -> ObjectOpts { - ObjectOpts { - name: "logs/object".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).expect("valid fixed test timestamp")), - version_id: Some(Uuid::new_v4()), - is_latest: true, - delete_marker: true, - num_versions: 1, - replication_status, - version_purge_status, - ..Default::default() - } - } - - fn current_object_opts(replication_status: ReplicationStatusType) -> ObjectOpts { - ObjectOpts { - name: "logs/object".to_string(), - mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).expect("valid fixed test timestamp")), - version_id: Some(Uuid::new_v4()), - is_latest: true, - num_versions: 1, - replication_status, - ..Default::default() - } - } - - fn versioned_object_opts(replication_status: ReplicationStatusType, is_latest: bool) -> ObjectOpts { - ObjectOpts { - num_versions: 2, - is_latest, - ..current_object_opts(replication_status) - } - } - - #[tokio::test] - async fn evaluator_allows_expired_delete_marker_after_replication_completed() { - let evaluator = Evaluator::new(expired_marker_lifecycle()); - - let events = evaluator - .eval(&[object_opts( - ReplicationStatusType::Completed, - VersionPurgeStatusType::Complete, - )]) - .await - .expect("completed replication should allow lifecycle evaluation"); - - assert_eq!(events[0].action, IlmAction::DeleteVersionAction); - } - - #[tokio::test] - async fn evaluator_skips_expired_delete_marker_while_replication_pending() { - let evaluator = Evaluator::new(expired_marker_lifecycle()); - - let events = evaluator - .eval(&[object_opts(ReplicationStatusType::Pending, VersionPurgeStatusType::default())]) - .await - .expect("pending replication should still return a lifecycle decision"); - - assert_eq!(events[0].action, IlmAction::NoneAction); - } - - #[tokio::test] - async fn evaluator_skips_expired_delete_marker_while_version_purge_pending() { - let evaluator = Evaluator::new(expired_marker_lifecycle()); - - let events = evaluator - .eval(&[object_opts(ReplicationStatusType::Completed, VersionPurgeStatusType::Pending)]) - .await - .expect("pending version purge should still return a lifecycle decision"); - - assert_eq!(events[0].action, IlmAction::NoneAction); - } - - #[tokio::test] - async fn evaluator_skips_latest_expiration_while_replication_failed() { - let evaluator = Evaluator::new(latest_expiration_lifecycle()); - - let events = evaluator - .eval(&[current_object_opts(ReplicationStatusType::Failed)]) - .await - .expect("failed replication should still return a lifecycle decision"); - - assert_eq!(events[0].action, IlmAction::NoneAction); - } - - #[tokio::test] - async fn evaluator_allows_latest_expiration_after_replication_completed() { - let evaluator = Evaluator::new(latest_expiration_lifecycle()); - - let events = evaluator - .eval(&[current_object_opts(ReplicationStatusType::Completed)]) - .await - .expect("completed replication should allow latest expiration"); - - assert_eq!(events[0].action, IlmAction::DeleteAction); - } - - #[tokio::test] - async fn evaluator_skips_transition_while_replication_pending() { - let evaluator = Evaluator::new(latest_transition_lifecycle()); - - let events = evaluator - .eval(&[current_object_opts(ReplicationStatusType::Pending)]) - .await - .expect("pending replication should still return a lifecycle decision"); - - assert_eq!(events[0].action, IlmAction::NoneAction); - } - - #[tokio::test] - async fn evaluator_allows_transition_after_replication_completed() { - let evaluator = Evaluator::new(latest_transition_lifecycle()); - - let events = evaluator - .eval(&[current_object_opts(ReplicationStatusType::Completed)]) - .await - .expect("completed replication should allow transition"); - - assert_eq!(events[0].action, IlmAction::TransitionAction); - } - - #[tokio::test] - async fn evaluator_skips_delete_all_versions_when_any_version_replication_pending() { - let evaluator = Evaluator::new(all_versions_expiration_lifecycle()); - let latest = versioned_object_opts(ReplicationStatusType::Completed, true); - let noncurrent = versioned_object_opts(ReplicationStatusType::Pending, false); - - let events = evaluator - .eval(&[latest, noncurrent]) - .await - .expect("pending noncurrent replication should still return lifecycle decisions"); - - assert_eq!(events[0].action, IlmAction::NoneAction); - assert_eq!(events[1].action, IlmAction::NoneAction); - } - - #[tokio::test] - async fn evaluator_allows_delete_all_versions_when_all_versions_replication_completed() { - let evaluator = Evaluator::new(all_versions_expiration_lifecycle()); - let latest = versioned_object_opts(ReplicationStatusType::Completed, true); - let noncurrent = versioned_object_opts(ReplicationStatusType::Completed, false); - - let events = evaluator - .eval(&[latest, noncurrent]) - .await - .expect("completed replication should allow delete-all lifecycle decision"); - - assert_eq!(events[0].action, IlmAction::DeleteAllVersionsAction); - assert_eq!(events[1].action, IlmAction::NoneAction); - } -} +pub use rustfs_lifecycle::Evaluator; diff --git a/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs b/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs index 4aa598228..166371607 100644 --- a/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs +++ b/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs @@ -18,7 +18,7 @@ use crate::bucket::object_lock::objectlock_sys::{self, ObjectLockBlockReason}; use crate::object_api::ObjectInfo; pub(crate) fn is_object_locked_by_metadata(user_defined: &HashMap, is_delete_marker: bool) -> bool { - objectlock_sys::is_object_locked_by_metadata(user_defined, is_delete_marker) + rustfs_lifecycle::object_lock::is_object_locked_by_metadata(user_defined, is_delete_marker) } pub(crate) async fn check_object_lock_for_deletion( diff --git a/crates/ecstore/src/bucket/lifecycle/rule.rs b/crates/ecstore/src/bucket/lifecycle/rule.rs index e137c5413..ee7b77e1c 100644 --- a/crates/ecstore/src/bucket/lifecycle/rule.rs +++ b/crates/ecstore/src/bucket/lifecycle/rule.rs @@ -11,193 +11,5 @@ // 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 super::tagging_boundary; -use s3s::dto::{LifecycleRuleAndOperator, LifecycleRuleFilter, NoncurrentVersionTransition, Tag, Transition}; -use time::OffsetDateTime; -const _ERR_TRANSITION_INVALID_DAYS: &str = "Days must be 0 or greater when used with Transition"; -const _ERR_TRANSITION_INVALID_DATE: &str = "Date must be provided in ISO 8601 format"; -const ERR_TRANSITION_INVALID: &str = - "Exactly one of Days (0 or greater) or Date (positive ISO 8601 format) should be present in Transition."; -const _ERR_TRANSITION_DATE_NOT_MIDNIGHT: &str = "'Date' must be at midnight GMT"; - -pub trait Filter { - fn test_tags(&self, user_tags: &str) -> bool; - fn by_size(&self, sz: i64) -> bool; -} - -impl Filter for LifecycleRuleFilter { - fn test_tags(&self, user_tags: &str) -> bool { - if !requires_tag_matching(self) { - return true; - } - - let user_tags = tagging_boundary::decode_tags_to_map(user_tags); - - self.tag.as_ref().is_none_or(|tag| tag_matches(tag, &user_tags)) - && self.and.as_ref().is_none_or(|and| and_tags_match(and, &user_tags)) - } - - fn by_size(&self, sz: i64) -> bool { - let sz = sz.max(0); - - self.object_size_greater_than.is_none_or(|min| sz > min) - && self.object_size_less_than.is_none_or(|max| sz < max) - && self.and.as_ref().is_none_or(|and| and_size_matches(and, sz)) - } -} - -fn requires_tag_matching(filter: &LifecycleRuleFilter) -> bool { - filter.tag.is_some() - || filter - .and - .as_ref() - .and_then(|and| and.tags.as_ref()) - .is_some_and(|tags| !tags.is_empty()) -} - -fn tag_matches(tag: &Tag, user_tags: &std::collections::HashMap) -> bool { - let Some(key) = tag.key.as_deref() else { - return false; - }; - let Some(value) = tag.value.as_deref() else { - return false; - }; - - user_tags.get(key).is_some_and(|actual| actual == value) -} - -fn and_tags_match(and: &LifecycleRuleAndOperator, user_tags: &std::collections::HashMap) -> bool { - and.tags - .as_ref() - .is_none_or(|tags| tags.iter().all(|tag| tag_matches(tag, user_tags))) -} - -fn and_size_matches(and: &LifecycleRuleAndOperator, sz: i64) -> bool { - and.object_size_greater_than.is_none_or(|min| sz > min) && and.object_size_less_than.is_none_or(|max| sz < max) -} - -pub trait TransitionOps { - fn validate(&self) -> Result<(), std::io::Error>; -} - -pub trait NoncurrentVersionTransitionOps { - fn validate(&self) -> Result<(), std::io::Error>; -} - -fn is_midnight(date: OffsetDateTime) -> bool { - date.hour() == 0 && date.minute() == 0 && date.second() == 0 && date.nanosecond() == 0 -} - -impl TransitionOps for Transition { - fn validate(&self) -> Result<(), std::io::Error> { - if self - .storage_class - .as_ref() - .is_none_or(|storage_class| storage_class.as_str().is_empty()) - { - return Err(std::io::Error::other("ERR_XML_NOT_WELL_FORMED")); - } - if self.date.is_some() == self.days.is_some() { - return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); - } - if self.days.is_some_and(|days| days < 0) { - return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); - } - if let Some(date) = self.date.clone() { - let date = OffsetDateTime::from(date); - if date.unix_timestamp() <= 0 || !is_midnight(date) { - return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); - } - } - Ok(()) - } -} - -impl NoncurrentVersionTransitionOps for NoncurrentVersionTransition { - fn validate(&self) -> Result<(), std::io::Error> { - if self - .storage_class - .as_ref() - .is_none_or(|storage_class| storage_class.as_str().is_empty()) - { - return Err(std::io::Error::other("ERR_XML_NOT_WELL_FORMED")); - } - if self.noncurrent_days.is_none() { - return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); - } - if self.noncurrent_days.is_some_and(|days| days < 0) - || self.newer_noncurrent_versions.is_some_and(|versions| versions < 0) - { - return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); - } - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn lifecycle_rule_filter_matches_single_tag() { - let filter = LifecycleRuleFilter { - tag: Some(Tag { - key: Some("env".to_string()), - value: Some("prod".to_string()), - }), - ..Default::default() - }; - - assert!(::test_tags(&filter, "env=prod&team=storage")); - assert!(!::test_tags(&filter, "env=dev&team=storage")); - assert!(!::test_tags(&filter, "team=storage")); - } - - #[test] - fn lifecycle_rule_filter_matches_all_and_tags() { - let filter = LifecycleRuleFilter { - and: Some(LifecycleRuleAndOperator { - tags: Some(vec![ - Tag { - key: Some("env".to_string()), - value: Some("prod".to_string()), - }, - Tag { - key: Some("team".to_string()), - value: Some("storage".to_string()), - }, - ]), - ..Default::default() - }), - ..Default::default() - }; - - assert!(::test_tags(&filter, "env=prod&team=storage")); - assert!(!::test_tags(&filter, "env=prod&team=platform")); - } - - #[test] - fn lifecycle_rule_filter_respects_size_bounds() { - let filter = LifecycleRuleFilter { - object_size_greater_than: Some(5), - object_size_less_than: Some(10), - ..Default::default() - }; - - assert!(!filter.by_size(5)); - assert!(filter.by_size(6)); - assert!(!filter.by_size(10)); - } - - #[test] - fn lifecycle_rule_filter_without_tag_constraints_accepts_any_tags() { - let filter = LifecycleRuleFilter { - object_size_greater_than: Some(5), - ..Default::default() - }; - - assert!(::test_tags(&filter, "env=prod&team=storage")); - assert!(::test_tags(&filter, "")); - } -} +pub use rustfs_lifecycle::rule::{Filter, NoncurrentVersionTransitionOps, TransitionOps}; diff --git a/crates/lifecycle/Cargo.toml b/crates/lifecycle/Cargo.toml new file mode 100644 index 000000000..9f641d72f --- /dev/null +++ b/crates/lifecycle/Cargo.toml @@ -0,0 +1,49 @@ +# 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. + +[package] +name = "rustfs-lifecycle" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +homepage.workspace = true +description = "Lifecycle rule evaluation contracts for RustFS." +keywords = ["lifecycle", "storage", "rustfs", "Minio"] +categories = ["web-programming", "development-tools", "filesystem"] +documentation = "https://docs.rs/rustfs-lifecycle/latest/rustfs_lifecycle/" + +[dependencies] +async-trait.workspace = true +rustfs-common.workspace = true +rustfs-config = { workspace = true, features = ["constants"] } +rustfs-replication.workspace = true +rustfs-storage-api.workspace = true +s3s.workspace = true +time.workspace = true +tracing.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["v4", "serde"] } + +[dev-dependencies] +serial_test.workspace = true +temp-env.workspace = true +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true + +[lib] +doctest = false diff --git a/crates/lifecycle/src/core.rs b/crates/lifecycle/src/core.rs new file mode 100644 index 000000000..5ca007950 --- /dev/null +++ b/crates/lifecycle/src/core.rs @@ -0,0 +1,2951 @@ +// 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 rustfs_config::{DEFAULT_ILM_PROCESS_TIME_SECS, ENV_ILM_PROCESS_TIME, ENV_ILM_PROCESS_TIME_DEPRECATED}; +use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType}; +use s3s::dto::{ + BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, + NoncurrentVersionTransition, ObjectLockConfiguration, ObjectLockEnabled, RestoreRequest, Transition, +}; +use std::cmp::Ordering; +use std::collections::HashMap; +use std::sync::Arc; +use time::macros::offset; +use time::{self, Duration, OffsetDateTime}; +use tracing::debug; +use uuid::Uuid; + +use crate::rule::{Filter, NoncurrentVersionTransitionOps, TransitionOps}; + +pub const TRANSITION_COMPLETE: &str = "complete"; +pub const TRANSITION_PENDING: &str = "pending"; +const LOG_COMPONENT_ECSTORE: &str = "ecstore"; +const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle"; +const EVENT_LIFECYCLE_EXPIRY_COMPUTED: &str = "lifecycle_expiry_computed"; +const ERR_LIFECYCLE_NO_RULE: &str = "Lifecycle configuration should have at least one rule"; +const ERR_LIFECYCLE_DUPLICATE_ID: &str = "Rule ID must be unique. Found same ID for more than one rule"; +const _ERR_XML_NOT_WELL_FORMED: &str = + "The XML you provided was not well-formed or did not validate against our published schema"; +const ERR_LIFECYCLE_BUCKET_LOCKED: &str = + "ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket"; +const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules"; +const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "Lifecycle expiration days must not be negative"; +const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str = "Lifecycle noncurrent expiration days must not be negative"; +const ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS: &str = + "DaysAfterInitiation must be 0 or greater when used with AbortIncompleteMultipartUpload"; +const ERR_LIFECYCLE_INVALID_EXPIRATION_DATE_NOT_MIDNIGHT: &str = "Expiration.Date must be at midnight UTC"; +const ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER: &str = + "ExpiredObjectDeleteMarker cannot be specified with Days or Date"; +const ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG: &str = "Rule ID must be at most 255 characters"; +const ERR_LIFECYCLE_INVALID_RULE_STATUS: &str = "Rule status must be either Enabled or Disabled"; +const ERR_LIFECYCLE_DEL_MARKER_WITH_TAGS: &str = "Rule with DelMarkerExpiration cannot have tags based filtering"; +const ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS: &str = + "Rule with ExpiredObjectDeleteMarker cannot have tags based filtering"; +const ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION: &str = "Rule must have at least one of Expiration, Transition, NoncurrentVersionExpiration, NoncurrentVersionTransition, or DelMarkerExpiration"; +const ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT: &str = "Legacy Prefix and Filter cannot both be present in a lifecycle rule. Use Filter.Prefix instead of the top-level Prefix element."; + +pub use rustfs_common::metrics::IlmAction; + +#[async_trait::async_trait] +pub trait RuleValidate { + fn validate(&self) -> Result<(), std::io::Error>; +} + +#[async_trait::async_trait] +impl RuleValidate for LifecycleRule { + /*fn validate_id(&self) -> Result<()> { + if self.id.len() > 255 { + return errInvalidRuleID; + } + Ok(()) + } + + fn validate_status(&self) -> Result<()> { + if self.status.len() == 0 { + return ErrEmptyRuleStatus; + } + + if self.status != Enabled && self.status != Disabled { + return ErrInvalidRuleStatus; + } + Ok(()) + } + + fn validate_expiration(&self) -> Result<()> { + self.expiration.validate(); + } + + fn validate_noncurrent_expiration(&self) -> Result<()> { + self.noncurrent_version_expiration.validate() + } + + fn validate_prefix_and_filter(&self) -> Result<()> { + if !self.prefix.set && self.Filter.isempty() || self.prefix.set && !self.filter.isempty() { + return ErrXMLNotWellFormed; + } + if !self.prefix.set { + return self.filter.validate(); + } + Ok(()) + } + + fn validate_transition(&self) -> Result<()> { + self.Transition.Validate() + } + + fn validate_noncurrent_transition(&self) -> Result<()> { + self.NoncurrentVersionTransition.Validate() + } + + fn get_prefix(&self) -> String { + if p := self.Prefix.String(); p != "" { + return p + } + if p := self.Filter.Prefix.String(); p != "" { + return p + } + if p := self.Filter.And.Prefix.String(); p != "" { + return p + } + "".to_string() + }*/ + + fn validate(&self) -> Result<(), std::io::Error> { + // S3 standard: Legacy Prefix and Filter cannot both be present. + // An empty prefix is treated as "not set" and is allowed with a Filter. + let has_legacy_prefix = self.prefix.as_deref().is_some_and(|p| !p.is_empty()); + let has_filter = self.filter.is_some(); + if has_legacy_prefix && has_filter { + return Err(std::io::Error::other(ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT)); + } + + // Rule with DelMarkerExpiration cannot have tags based filtering + let has_tag_filter = self + .filter + .as_ref() + .is_some_and(|f| f.tag.is_some() || f.and.as_ref().and_then(|a| a.tags.as_ref()).is_some()); + if has_tag_filter && self.del_marker_expiration.is_some() { + return Err(std::io::Error::other(ERR_LIFECYCLE_DEL_MARKER_WITH_TAGS)); + } + if has_tag_filter + && self + .expiration + .as_ref() + .is_some_and(|expiration| expiration.expired_object_delete_marker.is_some_and(|v| v)) + { + return Err(std::io::Error::other(ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS)); + } + if let Some(expiration) = &self.expiration + && expiration.expired_object_delete_marker.is_some_and(|v| v) + && (expiration.days.is_some() || expiration.date.is_some()) + { + return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER)); + } + // Rule must have at least one action + let has_expiration = self.expiration.is_some(); + let has_transition = self.transitions.as_ref().is_some_and(|t| !t.is_empty()); + let has_noncurrent_expiration = self + .noncurrent_version_expiration + .as_ref() + .and_then(|e| e.noncurrent_days) + .is_some(); + let has_noncurrent_transition = self + .noncurrent_version_transitions + .as_ref() + .and_then(|t| t.first()) + .and_then(|t| t.storage_class.as_ref()) + .is_some(); + let has_abort_incomplete_multipart_upload = self.abort_incomplete_multipart_upload.is_some(); + let has_del_marker_expiration = self + .del_marker_expiration + .as_ref() + .and_then(|d| d.days) + .is_some_and(|d| d > 0); + if !has_expiration + && !has_transition + && !has_noncurrent_expiration + && !has_noncurrent_transition + && !has_abort_incomplete_multipart_upload + && !has_del_marker_expiration + { + return Err(std::io::Error::other(ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION)); + } + Ok(()) + } +} + +fn lifecycle_rule_prefix(rule: &LifecycleRule) -> Option<&str> { + // Prefer a non-empty legacy prefix; treat an empty legacy prefix as if it were not set + if let Some(p) = rule.prefix.as_deref() + && !p.is_empty() + { + return Some(p); + } + + let filter = rule.filter.as_ref()?; + + if let Some(p) = filter.prefix.as_deref() { + return Some(p); + } + + filter.and.as_ref().and_then(|and| and.prefix.as_deref()) +} + +#[async_trait::async_trait] +pub trait Lifecycle { + async fn has_transition(&self) -> bool; + fn has_expiry(&self) -> bool; + fn has_active_rules(&self, prefix: &str) -> bool; + async fn validate(&self, lr: &ObjectLockConfiguration) -> Result<(), std::io::Error>; + async fn filter_rules(&self, obj: &ObjectOpts) -> Option>; + async fn eval(&self, obj: &ObjectOpts) -> Event; + async fn predict_expiration(&self, obj: &ObjectOpts) -> Event; + async fn eval_inner(&self, obj: &ObjectOpts, now: OffsetDateTime, newer_noncurrent_versions: usize) -> Event; + //fn set_prediction_headers(&self, w: http.ResponseWriter, obj: ObjectOpts); + async fn noncurrent_versions_expiration_limit(self: Arc, obj: &ObjectOpts) -> Event; +} + +#[async_trait::async_trait] +impl Lifecycle for BucketLifecycleConfiguration { + async fn has_transition(&self) -> bool { + for rule in self.rules.iter() { + if rule.transitions.as_ref().is_some_and(|transitions| !transitions.is_empty()) { + return true; + } + } + false + } + + fn has_expiry(&self) -> bool { + for rule in self.rules.iter() { + if rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some() { + return true; + } + } + false + } + + fn has_active_rules(&self, prefix: &str) -> bool { + if self.rules.is_empty() { + return false; + } + for rule in self.rules.iter() { + if rule.status.as_str() == ExpirationStatus::DISABLED { + continue; + } + + let rule_prefix = lifecycle_rule_prefix(rule).unwrap_or(""); + if !prefix.is_empty() + && !rule_prefix.is_empty() + && !prefix.starts_with(rule_prefix) + && !rule_prefix.starts_with(prefix) + { + continue; + } + + if let Some(rule_noncurrent_version_expiration) = &rule.noncurrent_version_expiration { + if let Some(noncurrent_days) = rule_noncurrent_version_expiration.noncurrent_days + && noncurrent_days >= 0 + { + return true; + } + if let Some(newer_noncurrent_versions) = rule_noncurrent_version_expiration.newer_noncurrent_versions + && newer_noncurrent_versions > 0 + { + return true; + } + } + if rule + .noncurrent_version_transitions + .as_ref() + .is_some_and(|transitions| !transitions.is_empty()) + { + return true; + } + if let Some(rule_expiration) = &rule.expiration { + if let Some(date1) = rule_expiration.date.clone() + && OffsetDateTime::from(date1).unix_timestamp() < OffsetDateTime::now_utc().unix_timestamp() + { + return true; + } + if rule_expiration.date.is_some() { + return true; + } + if rule_expiration.days.is_some() { + return true; + } + if let Some(expired_object_delete_marker) = rule_expiration.expired_object_delete_marker + && expired_object_delete_marker + { + return true; + } + } + if let Some(rule_transition) = rule.transitions.as_ref().and_then(|transitions| transitions.first()) { + if let Some(date1) = rule_transition.date.clone() + && OffsetDateTime::from(date1).unix_timestamp() < OffsetDateTime::now_utc().unix_timestamp() + { + return true; + } + return true; + } + } + false + } + + async fn validate(&self, lr: &ObjectLockConfiguration) -> Result<(), std::io::Error> { + if self.rules.len() > 1000 { + return Err(std::io::Error::other(ERR_LIFECYCLE_TOO_MANY_RULES)); + } + if self.rules.is_empty() { + return Err(std::io::Error::other(ERR_LIFECYCLE_NO_RULE)); + } + + for r in &self.rules { + if r.status != ExpirationStatus::from_static(ExpirationStatus::ENABLED) + && r.status != ExpirationStatus::from_static(ExpirationStatus::DISABLED) + { + return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_STATUS)); + } + + if let Some(expiration) = &r.expiration { + if let Some(expiration_date) = &expiration.date { + let date = OffsetDateTime::from(expiration_date.clone()); + if date.hour() != 0 || date.minute() != 0 || date.second() != 0 || date.nanosecond() != 0 { + return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRATION_DATE_NOT_MIDNIGHT)); + } + } + if let Some(days) = expiration.days + && days < 0 + { + return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS)); + } + } + if let Some(noncurrent_version_expiration) = &r.noncurrent_version_expiration + && let Some(noncurrent_days) = noncurrent_version_expiration.noncurrent_days + && noncurrent_days < 0 + { + return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS)); + } + if let Some(abort_incomplete_multipart_upload) = &r.abort_incomplete_multipart_upload { + match abort_incomplete_multipart_upload.days_after_initiation { + Some(days) if days >= 0 => {} + _ => return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS)), + } + } + if let Some(transitions) = &r.transitions { + for transition in transitions { + TransitionOps::validate(transition)?; + } + } + if let Some(noncurrent_transitions) = &r.noncurrent_version_transitions { + for transition in noncurrent_transitions { + NoncurrentVersionTransitionOps::validate(transition)?; + } + } + if let Some(id) = &r.id + && id.len() > 255 + { + return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG)); + } + r.validate()?; + if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref() + && object_lock_enabled.as_str() == ObjectLockEnabled::ENABLED + { + if let Some(expiration) = r.expiration.as_ref() { + // Object Lock + ExpiredObjectAllVersions conflict (MinIO extension) + if expiration.expired_object_all_versions.is_some_and(|v| v) { + return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED)); + } + } + // Object Lock + DelMarkerExpiration conflict + if r.del_marker_expiration.is_some() { + return Err(std::io::Error::other(ERR_LIFECYCLE_BUCKET_LOCKED)); + } + } + } + for (i, _) in self.rules.iter().enumerate() { + if i == self.rules.len() - 1 { + break; + } + let other_rules = &self.rules[i + 1..]; + for other_rule in other_rules { + if let (Some(id1), Some(id2)) = (&self.rules[i].id, &other_rule.id) + && id1 == id2 + { + return Err(std::io::Error::other(ERR_LIFECYCLE_DUPLICATE_ID)); + } + } + } + Ok(()) + } + + async fn filter_rules(&self, obj: &ObjectOpts) -> Option> { + if obj.name.is_empty() { + return None; + } + let mut rules = Vec::::new(); + for rule in self.rules.iter() { + if rule.status.as_str() == ExpirationStatus::DISABLED { + continue; + } + if let Some(rule_prefix) = lifecycle_rule_prefix(rule) + && !obj.name.starts_with(rule_prefix) + { + continue; + } + if let Some(filter) = rule.filter.as_ref() { + if !::test_tags(filter, &obj.user_tags) { + continue; + } + if !obj.delete_marker + && !::by_size(filter, i64::try_from(obj.size).unwrap_or(i64::MAX)) + { + continue; + } + } + rules.push(rule.clone()); + } + Some(rules) + } + + async fn eval(&self, obj: &ObjectOpts) -> Event { + self.eval_inner(obj, OffsetDateTime::now_utc(), 0).await + } + + async fn predict_expiration(&self, obj: &ObjectOpts) -> Event { + let mod_time = match obj.mod_time { + Some(time) => { + if time.unix_timestamp() == 0 { + return Event::default(); + } + time + } + None => return Event::default(), + }; + + if obj.delete_marker || !(obj.is_latest || obj.version_id.is_none_or(|v| v.is_nil())) { + return Event::default(); + } + + let Some(lc_rules) = self.filter_rules(obj).await else { + return Event::default(); + }; + + let mut event: Option = None; + for rule in lc_rules { + let Some(expiration) = rule.expiration else { + continue; + }; + if expiration.expired_object_delete_marker.is_some_and(|v| v) { + continue; + } + let Some(due) = (match (&expiration.date, expiration.days) { + (Some(date), _) => Some(OffsetDateTime::from(date.clone())), + (None, Some(days)) => Some(expected_expiry_time(mod_time, days)), + _ => None, + }) else { + continue; + }; + + let predicted = Event { + action: IlmAction::DeleteAction, + rule_id: rule.id.clone().unwrap_or_default(), + due: Some(due), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }; + let predicted_due = predicted.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp(); + let should_replace = event + .as_ref() + .is_none_or(|e| predicted_due < e.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp()); + if should_replace { + event = Some(predicted); + } + } + + event.unwrap_or_default() + } + + async fn eval_inner(&self, obj: &ObjectOpts, now: OffsetDateTime, newer_noncurrent_versions: usize) -> Event { + let mut events = Vec::::new(); + debug!( + "eval_inner: object={}, mod_time={:?}, successor_mod_time={:?}, now={:?}, is_latest={}, delete_marker={}", + obj.name, obj.mod_time, obj.successor_mod_time, now, obj.is_latest, obj.delete_marker + ); + + // Gracefully handle missing mod_time instead of panicking + let mod_time = match obj.mod_time { + Some(t) => t, + None => { + debug!("eval_inner: mod_time is None for object={}, returning default event", obj.name); + return Event::default(); + } + }; + + if mod_time.unix_timestamp() == 0 { + debug!("eval_inner: mod_time is 0, returning default event"); + return Event::default(); + } + + if let Some(restore_expires) = obj.restore_expires + && restore_expires.unix_timestamp() != 0 + && now.unix_timestamp() > restore_expires.unix_timestamp() + { + let mut action = IlmAction::DeleteRestoredAction; + if !obj.is_latest { + action = IlmAction::DeleteRestoredVersionAction; + } + + events.push(Event { + action, + due: Some(now), + rule_id: "".into(), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }); + } + + if let Some(ref lc_rules) = self.filter_rules(obj).await { + for rule in lc_rules.iter() { + if obj.is_latest && obj.expired_object_deletemarker() { + if let Some(expiration) = rule.expiration.as_ref() + && expiration.expired_object_delete_marker.is_some_and(|v| v) + { + // Preserve explicit date/days scheduling when configured. + // If only ExpiredObjectDeleteMarker=true is set, delete immediately. + let due = expiration.next_due(obj).unwrap_or(now); + if now.unix_timestamp() >= due.unix_timestamp() { + events.push(Event { + action: IlmAction::DeleteVersionAction, + rule_id: rule.id.clone().unwrap_or_default(), + due: Some(due), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }); + // Stop after scheduling an expired delete-marker event. + break; + } + } + // DelMarkerExpiration: expire delete marker after N days from mod_time + if obj.delete_marker + && let Some(ref dme) = rule.del_marker_expiration + && let Some(days) = dme.days + && days > 0 + { + let due = expected_expiry_time(mod_time, days); + if now.unix_timestamp() >= due.unix_timestamp() { + events.push(Event { + action: IlmAction::DelMarkerDeleteAllVersionsAction, + rule_id: rule.id.clone().unwrap_or_default(), + due: Some(due), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }); + } + continue; + } + } + + if !obj.is_latest + && let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration + && let Some(retain_newer_noncurrent_versions) = noncurrent_version_expiration.newer_noncurrent_versions + && newer_noncurrent_versions < usize::try_from(retain_newer_noncurrent_versions).unwrap_or(usize::MAX) + { + continue; + } + + if !obj.is_latest + && let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration + && let Some(noncurrent_days) = noncurrent_version_expiration.noncurrent_days + && let Some(successor_mod_time) = obj.successor_mod_time + { + let expected_expiry = expected_expiry_time(successor_mod_time, noncurrent_days); + if now.unix_timestamp() >= expected_expiry.unix_timestamp() { + events.push(Event { + action: IlmAction::DeleteVersionAction, + rule_id: rule.id.clone().unwrap_or_default(), + due: Some(expected_expiry), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }); + } + } + + if !obj.is_latest + && let Some(noncurrent_version_transition) = rule + .noncurrent_version_transitions + .as_ref() + .and_then(|transitions| transitions.first()) + && let Some(storage_class) = noncurrent_version_transition.storage_class.as_ref() + && !storage_class.as_str().is_empty() + && !obj.delete_marker + && obj.transition_status != TRANSITION_COMPLETE + { + let due = noncurrent_version_transition.next_due(obj); + if let Some(due0) = due + && (now.unix_timestamp() == 0 || now.unix_timestamp() > due0.unix_timestamp()) + { + events.push(Event { + action: IlmAction::TransitionVersionAction, + rule_id: rule.id.clone().unwrap_or_default(), + due, + storage_class: storage_class.as_str().to_string(), + ..Default::default() + }); + } + } + + debug!( + "eval_inner: checking expiration condition - is_latest={}, delete_marker={}, version_id={:?}, condition_met={}", + obj.is_latest, + obj.delete_marker, + obj.version_id, + (obj.is_latest || obj.version_id.is_none_or(|v| v.is_nil())) && !obj.delete_marker + ); + // Allow expiration for latest objects OR non-versioned objects (empty version_id) + if (obj.is_latest || obj.version_id.is_none_or(|v| v.is_nil())) && !obj.delete_marker { + debug!("eval_inner: entering expiration check"); + if let Some(ref expiration) = rule.expiration { + if let Some(ref date) = expiration.date { + let date0 = OffsetDateTime::from(date.clone()); + if date0.unix_timestamp() != 0 && (now.unix_timestamp() >= date0.unix_timestamp()) { + debug!("eval_inner: expiration by date - date0={:?}", date0); + events.push(Event { + action: IlmAction::DeleteAction, + rule_id: rule.id.clone().unwrap_or_default(), + due: Some(date0), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }); + } + } else if let Some(days) = expiration.days { + let expected_expiry: OffsetDateTime = expected_expiry_time(mod_time, days); + debug!( + "eval_inner: expiration check - days={}, obj_time={:?}, expiry_time={:?}, now={:?}, should_expire={}", + days, + mod_time, + expected_expiry, + now, + now.unix_timestamp() > expected_expiry.unix_timestamp() + ); + if now.unix_timestamp() >= expected_expiry.unix_timestamp() { + debug!("eval_inner: object should expire, adding DeleteAction"); + let mut event = Event { + action: IlmAction::DeleteAction, + rule_id: rule.id.clone().unwrap_or_default(), + due: Some(expected_expiry), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + }; + // MinIO extension: ExpiredObjectAllVersions deletes all versions + if rule + .expiration + .as_ref() + .and_then(|e| e.expired_object_all_versions) + .unwrap_or(false) + { + event.action = IlmAction::DeleteAllVersionsAction; + } + events.push(event); + } + } else { + debug!("eval_inner: expiration.days is None"); + } + } else { + debug!("eval_inner: rule.expiration is None"); + } + + if obj.transition_status != TRANSITION_COMPLETE + && let Some(transition) = rule.transitions.as_ref().and_then(|transitions| transitions.first()) + && let Some(storage_class) = transition.storage_class.as_ref() + && !storage_class.as_str().is_empty() + { + let due = transition.next_due(obj); + if let Some(due0) = due + && (now.unix_timestamp() == 0 || now.unix_timestamp() > due0.unix_timestamp()) + { + events.push(Event { + action: IlmAction::TransitionAction, + rule_id: rule.id.clone().unwrap_or_default(), + due, + storage_class: storage_class.as_str().to_string(), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + }); + } + } + } + } + } + + if !events.is_empty() { + events.sort_by(|a, b| { + if now.unix_timestamp() > a.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() + && now.unix_timestamp() > b.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() + || a.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() + == b.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() + { + match a.action { + IlmAction::DeleteAllVersionsAction + | IlmAction::DelMarkerDeleteAllVersionsAction + | IlmAction::DeleteAction + | IlmAction::DeleteVersionAction => { + return Ordering::Less; + } + _ => (), + } + match b.action { + IlmAction::DeleteAllVersionsAction + | IlmAction::DelMarkerDeleteAllVersionsAction + | IlmAction::DeleteAction + | IlmAction::DeleteVersionAction => { + return Ordering::Greater; + } + _ => (), + } + return Ordering::Less; + } + + if a.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() + < b.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp() + { + return Ordering::Less; + } + Ordering::Greater + }); + return events[0].clone(); + } + + Event::default() + } + + async fn noncurrent_versions_expiration_limit(self: Arc, obj: &ObjectOpts) -> Event { + if let Some(filter_rules) = self.filter_rules(obj).await { + for rule in filter_rules.iter() { + if let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration { + return if let Some(newer_noncurrent_versions) = noncurrent_version_expiration.newer_noncurrent_versions { + if newer_noncurrent_versions == 0 { + continue; + } + Event { + action: IlmAction::DeleteVersionAction, + rule_id: rule.id.clone().unwrap_or_default(), + noncurrent_days: u32::try_from(noncurrent_version_expiration.noncurrent_days.unwrap_or(0)) + .unwrap_or(u32::MAX), + newer_noncurrent_versions: usize::try_from(newer_noncurrent_versions).unwrap_or(usize::MAX), + due: Some(OffsetDateTime::UNIX_EPOCH), + storage_class: "".into(), + } + } else { + Event { + action: IlmAction::DeleteVersionAction, + rule_id: rule.id.clone().unwrap_or_default(), + noncurrent_days: u32::try_from(noncurrent_version_expiration.noncurrent_days.unwrap_or(0)) + .unwrap_or(u32::MAX), + newer_noncurrent_versions: 0, + due: Some(OffsetDateTime::UNIX_EPOCH), + storage_class: "".into(), + } + }; + } + } + } + Event::default() + } +} + +#[async_trait::async_trait] +pub trait LifecycleCalculate { + fn next_due(&self, obj: &ObjectOpts) -> Option; +} + +#[async_trait::async_trait] +impl LifecycleCalculate for LifecycleExpiration { + fn next_due(&self, obj: &ObjectOpts) -> Option { + if !obj.is_latest || !obj.delete_marker { + return None; + } + // Check date first (date-based expiration takes priority over days). + // A zero unix timestamp means "not set" (default value) and is skipped. + if let Some(ref date) = self.date { + let expiry_date = OffsetDateTime::from(date.clone()); + if expiry_date.unix_timestamp() != 0 { + return Some(expiry_date); + } + } + match self.days { + Some(days) => obj.mod_time.map(|mod_time| expected_expiry_time(mod_time, days)), + None => None, + } + } +} + +#[async_trait::async_trait] +impl LifecycleCalculate for NoncurrentVersionTransition { + fn next_due(&self, obj: &ObjectOpts) -> Option { + if obj.is_latest || self.storage_class.is_none() { + return None; + } + match self.noncurrent_days { + Some(noncurrent_days) => { + if let Some(successor_mod_time) = obj.successor_mod_time { + Some(expected_expiry_time(successor_mod_time, noncurrent_days)) + } else { + Some(expected_expiry_time(OffsetDateTime::now_utc(), noncurrent_days)) + } + } + None => obj.successor_mod_time, + } + } +} + +#[async_trait::async_trait] +impl LifecycleCalculate for Transition { + fn next_due(&self, obj: &ObjectOpts) -> Option { + if !obj.is_latest { + return None; + } + + if let Some(date) = self.date.clone() { + return Some(date.into()); + } + + match (self.days, obj.mod_time) { + (Some(days), Some(mod_time)) => Some(expected_expiry_time(mod_time, days)), + (Some(_), None) => None, + (None, mod_time) => mod_time, + } + } +} + +pub fn expected_expiry_time(mod_time: OffsetDateTime, days: i32) -> OffsetDateTime { + if days == 0 { + debug!( + event = EVENT_LIFECYCLE_EXPIRY_COMPUTED, + component = LOG_COMPONENT_ECSTORE, + subsystem = LOG_SUBSYSTEM_LIFECYCLE, + days, + result = "unix_epoch", + "Computed immediate lifecycle expiry time" + ); + return OffsetDateTime::UNIX_EPOCH; // Return epoch time to ensure immediate expiry + } + let t = mod_time + .to_offset(offset!(-0:00:00)) + .saturating_add(Duration::days(i64::from(days))); + + // Round up to the next processing boundary per S3-compatible Days semantics. + // Canonical key: RUSTFS_ILM_PROCESS_TIME; deprecated alias: _RUSTFS_ILM_PROCESS_TIME. + // TODO(GA): Remove ENV_ILM_PROCESS_TIME_DEPRECATED compatibility during GA release. + let process_interval_secs = ilm_process_interval_secs(); + + let boundary_nanos = i128::from(process_interval_secs) * 1_000_000_000; + let timestamp_nanos = t.unix_timestamp_nanos(); + let remainder = timestamp_nanos.rem_euclid(boundary_nanos); + let rounded_nanos = if remainder == 0 { + timestamp_nanos + } else { + timestamp_nanos + (boundary_nanos - remainder) + }; + OffsetDateTime::from_unix_timestamp_nanos(rounded_nanos).unwrap_or(t) +} + +fn ilm_process_interval_secs() -> u32 { + let configured = std::env::var(ENV_ILM_PROCESS_TIME) + .ok() + .or_else(|| std::env::var(ENV_ILM_PROCESS_TIME_DEPRECATED).ok()) + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_ILM_PROCESS_TIME_SECS); + + u32::try_from(configured).unwrap_or_else(|_| u32::try_from(DEFAULT_ILM_PROCESS_TIME_SECS).unwrap_or(1)) +} + +pub async fn abort_incomplete_multipart_upload_due( + lc: &BucketLifecycleConfiguration, + obj: &ObjectOpts, +) -> Option<(OffsetDateTime, String)> { + let initiated = obj.mod_time?; + let rules = lc.filter_rules(obj).await?; + + rules + .into_iter() + .filter_map(|rule| { + let days = rule + .abort_incomplete_multipart_upload + .as_ref()? + .days_after_initiation + .filter(|days| *days >= 0)?; + Some((expected_expiry_time(initiated, days), rule.id.unwrap_or_default())) + }) + .min_by_key(|(due, _)| due.unix_timestamp_nanos()) +} + +#[derive(Default)] +pub struct ObjectOpts { + pub name: String, + pub user_tags: String, + pub mod_time: Option, + pub size: usize, + pub version_id: Option, + pub is_latest: bool, + pub delete_marker: bool, + pub num_versions: usize, + pub successor_mod_time: Option, + pub transition_status: String, + pub restore_ongoing: bool, + pub restore_expires: Option, + pub versioned: bool, + pub version_suspended: bool, + pub user_defined: HashMap, + pub version_purge_status: VersionPurgeStatusType, + pub replication_status: ReplicationStatusType, +} + +impl ObjectOpts { + pub fn expired_object_deletemarker(&self) -> bool { + self.delete_marker && self.is_latest && self.num_versions == 1 + } +} + +#[derive(Debug, Clone)] +pub struct Event { + pub action: IlmAction, + pub rule_id: String, + pub due: Option, + pub noncurrent_days: u32, + pub newer_noncurrent_versions: usize, + pub storage_class: String, +} + +impl Default for Event { + fn default() -> Self { + Self { + action: IlmAction::NoneAction, + rule_id: "".into(), + due: Some(OffsetDateTime::UNIX_EPOCH), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: "".into(), + } + } +} + +pub use rustfs_storage_api::ExpirationOptions; + +#[derive(Debug, Clone)] +pub struct TransitionOptions { + pub status: String, + pub tier: String, + pub etag: String, + pub restore_request: RestoreRequest, + pub restore_expiry: OffsetDateTime, + pub expire_restored: bool, +} + +impl Default for TransitionOptions { + fn default() -> Self { + Self { + status: Default::default(), + tier: Default::default(), + etag: Default::default(), + restore_request: Default::default(), + restore_expiry: OffsetDateTime::now_utc(), + expire_restored: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use s3s::dto::{LifecycleRuleFilter, TransitionStorageClass}; + use serial_test::serial; + use std::sync::Arc; + use time::macros::datetime; + + fn with_default_ilm_process_time(test: impl FnOnce()) { + temp_env::with_var_unset(ENV_ILM_PROCESS_TIME, || { + temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, test); + }); + } + + #[tokio::test] + #[serial] + async fn validate_accepts_zero_expiration_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(0), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("zero-day expiration should be accepted"); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_negative_expiration_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(-1), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let err = lc + .validate(&ObjectLockConfiguration::default()) + .await + .expect_err("expected validation error"); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS); + } + + #[tokio::test] + #[serial] + async fn validate_accepts_positive_expiration_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("expected validation to pass"); + } + + #[tokio::test] + #[serial] + async fn has_active_rules_accepts_zero_day_expiration() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(0), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("zero-day-active".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("test/".to_string()), + transitions: None, + }], + }; + + assert!(lc.has_active_rules("test/")); + } + + #[tokio::test] + #[serial] + async fn validate_accepts_zero_noncurrent_expiration_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { + noncurrent_days: Some(0), + newer_noncurrent_versions: None, + }), + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("zero-day noncurrent expiration should be accepted"); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_negative_noncurrent_expiration_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { + noncurrent_days: Some(-1), + newer_noncurrent_versions: None, + }), + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let err = lc + .validate(&ObjectLockConfiguration::default()) + .await + .expect_err("expected validation error"); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS); + } + + #[tokio::test] + #[serial] + async fn validate_accepts_abort_incomplete_multipart_upload_only_rule() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { + days_after_initiation: Some(2), + }), + del_marker_expiration: None, + filter: None, + id: Some("abort-only".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("test/".to_string()), + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("expected validation to pass"); + } + + #[tokio::test] + #[serial] + async fn validate_accepts_zero_abort_incomplete_multipart_upload_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { + days_after_initiation: Some(0), + }), + del_marker_expiration: None, + filter: None, + id: Some("abort-zero".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("test/".to_string()), + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("zero-day abort incomplete multipart upload should be accepted"); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_missing_abort_incomplete_multipart_upload_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { + days_after_initiation: None, + }), + del_marker_expiration: None, + filter: None, + id: Some("abort-missing".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("test/".to_string()), + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_negative_abort_incomplete_multipart_upload_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { + days_after_initiation: Some(-1), + }), + del_marker_expiration: None, + filter: None, + id: Some("abort-negative".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("test/".to_string()), + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS); + } + + #[tokio::test] + #[serial] + async fn abort_incomplete_multipart_upload_due_accepts_zero_days() { + let initiated = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: Some(s3s::dto::AbortIncompleteMultipartUpload { + days_after_initiation: Some(0), + }), + del_marker_expiration: None, + filter: None, + id: Some("abort-zero".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("test/".to_string()), + transitions: None, + }], + }; + let opts = ObjectOpts { + name: "test/object".to_string(), + mod_time: Some(initiated), + ..Default::default() + }; + + let (due, rule_id) = abort_incomplete_multipart_upload_due(&lc, &opts) + .await + .expect("zero-day abort rule should be due"); + + assert_eq!(rule_id, "abort-zero"); + assert_eq!(due, OffsetDateTime::UNIX_EPOCH); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_non_midnight_expiration_date() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + date: Some(OffsetDateTime::from_unix_timestamp(20_000_101).unwrap().into()), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRATION_DATE_NOT_MIDNIGHT); + } + + #[tokio::test] + #[serial] + async fn predict_expiration_selects_closest_expiry_for_put_object() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![ + LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("rule-days".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }, + LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + date: Some((base_time + Duration::days(1)).into()), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("rule-date".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }, + ], + }; + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + version_id: None, + ..Default::default() + }; + + let event = lc.predict_expiration(&opts).await; + let expected = base_time + Duration::days(1); + + assert_eq!(event.action, IlmAction::DeleteAction); + assert_eq!(event.rule_id, "rule-date"); + assert_eq!(event.due, Some(expected)); + } + + #[tokio::test] + #[serial] + async fn validate_accepts_multiple_rules_without_ids() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![ + LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }, + LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(31), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }, + ], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("expected validation to pass"); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_rule_id_too_long() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("a".repeat(256)), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_duplicate_rule_ids() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![ + LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("dup-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }, + LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(2), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("dup-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }, + ], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + assert_eq!(err.to_string(), ERR_LIFECYCLE_DUPLICATE_ID); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_transition_without_storage_class() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("transition-no-storage".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: Some(vec![Transition { + days: Some(1), + date: None, + storage_class: None, + }]), + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), "ERR_XML_NOT_WELL_FORMED"); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_transition_without_date_or_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("transition-no-schedule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: Some(vec![Transition { + days: None, + date: None, + storage_class: Some(TransitionStorageClass::from_static("WARM")), + }]), + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert!(err.to_string().contains("Exactly one of Days")); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_noncurrent_transition_without_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("noncurrent-transition-no-days".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: Some(vec![NoncurrentVersionTransition { + noncurrent_days: None, + newer_noncurrent_versions: None, + storage_class: Some(TransitionStorageClass::from_static("COLD")), + }]), + prefix: None, + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert!(err.to_string().contains("Exactly one of Days")); + } + + #[tokio::test] + #[serial] + async fn empty_transition_vectors_are_not_active_or_due() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("empty-transition".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: Some(vec![]), + prefix: None, + transitions: Some(vec![]), + }], + }; + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }; + + assert!(!lc.has_transition().await); + assert!(!lc.has_active_rules("obj")); + assert_eq!(lc.eval_inner(&opts, OffsetDateTime::now_utc(), 0).await.action, IlmAction::NoneAction); + } + + #[tokio::test] + #[serial] + async fn eval_inner_expires_latest_object_after_days_due() { + let base_time = datetime!(2025-01-15 10:30:45 UTC); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("expire-days".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + ..Default::default() + }; + let event = lc.eval_inner(&opts, datetime!(2025-01-17 00:00:00 UTC), 0).await; + + assert_eq!(event.action, IlmAction::DeleteAction); + assert_eq!(event.rule_id, "expire-days"); + assert_eq!(event.due, Some(datetime!(2025-01-17 00:00:00 UTC))); + } + + #[tokio::test] + #[serial] + async fn eval_inner_keeps_latest_object_before_days_due() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(2), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("expire-days".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + ..Default::default() + }; + let event = lc.eval_inner(&opts, base_time + Duration::hours(12), 0).await; + + assert_eq!(event.action, IlmAction::NoneAction); + } + + #[tokio::test] + #[serial] + async fn eval_inner_transitions_latest_object_after_days_due() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("transition-days".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: Some(vec![Transition { + days: Some(1), + date: None, + storage_class: Some(TransitionStorageClass::from_static("COLDTIER44")), + }]), + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + transition_status: "".to_string(), + ..Default::default() + }; + let event = lc.eval_inner(&opts, base_time + Duration::days(2), 0).await; + + assert_eq!(event.action, IlmAction::TransitionAction); + assert_eq!(event.rule_id, "transition-days"); + assert_eq!(event.storage_class, "COLDTIER44"); + } + + #[tokio::test] + #[serial] + async fn eval_inner_transitions_latest_object_after_date_due() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let transition_date = base_time - Duration::days(1); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("transition-date".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: Some(vec![Transition { + days: None, + date: Some(transition_date.into()), + storage_class: Some(TransitionStorageClass::from_static("WARM")), + }]), + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + transition_status: "".to_string(), + ..Default::default() + }; + let event = lc.eval_inner(&opts, base_time + Duration::days(1), 0).await; + + assert_eq!(event.action, IlmAction::TransitionAction); + assert_eq!(event.rule_id, "transition-date"); + assert_eq!(event.storage_class, "WARM"); + assert_eq!(event.due, Some(transition_date)); + } + + #[tokio::test] + #[serial] + async fn eval_inner_expires_noncurrent_version_after_due() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("noncurrent-expire".to_string()), + noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { + noncurrent_days: Some(1), + newer_noncurrent_versions: None, + }), + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + successor_mod_time: Some(base_time), + is_latest: false, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + let event = lc.eval_inner(&opts, base_time + Duration::days(2), 0).await; + + assert_eq!(event.action, IlmAction::DeleteVersionAction); + assert_eq!(event.rule_id, "noncurrent-expire"); + assert_eq!(event.due, Some(expected_expiry_time(base_time, 1))); + } + + #[tokio::test] + #[serial] + async fn eval_inner_expires_noncurrent_version_immediately_when_zero_days() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("noncurrent-expire-immediate".to_string()), + noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { + noncurrent_days: Some(0), + newer_noncurrent_versions: None, + }), + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + successor_mod_time: Some(base_time), + is_latest: false, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + let event = lc.eval_inner(&opts, base_time, 0).await; + + assert_eq!(event.action, IlmAction::DeleteVersionAction); + assert_eq!(event.rule_id, "noncurrent-expire-immediate"); + assert_eq!(event.due, Some(expected_expiry_time(base_time, 0))); + } + + #[tokio::test] + #[serial] + async fn eval_inner_transitions_noncurrent_version_after_due() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("noncurrent-transition".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: Some(vec![NoncurrentVersionTransition { + noncurrent_days: Some(1), + newer_noncurrent_versions: None, + storage_class: Some(TransitionStorageClass::from_static("COLDTIER44")), + }]), + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + successor_mod_time: Some(base_time), + is_latest: false, + transition_status: "".to_string(), + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + let event = lc.eval_inner(&opts, base_time + Duration::days(2), 0).await; + + assert_eq!(event.action, IlmAction::TransitionVersionAction); + assert_eq!(event.rule_id, "noncurrent-transition"); + assert_eq!(event.storage_class, "COLDTIER44"); + } + + #[tokio::test] + #[serial] + async fn noncurrent_versions_expiration_limit_returns_configured_limits() { + let lc = Arc::new(BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("noncurrent-limit".to_string()), + noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { + noncurrent_days: Some(7), + newer_noncurrent_versions: Some(3), + }), + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }); + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: false, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + let event = lc.noncurrent_versions_expiration_limit(&opts).await; + + assert_eq!(event.action, IlmAction::DeleteVersionAction); + assert_eq!(event.rule_id, "noncurrent-limit"); + assert_eq!(event.noncurrent_days, 7); + assert_eq!(event.newer_noncurrent_versions, 3); + } + + #[tokio::test] + #[serial] + async fn evaluator_honors_newer_noncurrent_versions_retention_count() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = Arc::new(BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("retain-two-noncurrent".to_string()), + noncurrent_version_expiration: Some(s3s::dto::NoncurrentVersionExpiration { + noncurrent_days: Some(1), + newer_noncurrent_versions: Some(2), + }), + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }); + let mut objs = vec![ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time + Duration::days(4)), + successor_mod_time: None, + is_latest: true, + version_id: Some(Uuid::new_v4()), + num_versions: 5, + ..Default::default() + }]; + for days_ago in (0..4).rev() { + objs.push(ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time + Duration::days(days_ago)), + successor_mod_time: Some(base_time + Duration::days(days_ago + 1)), + is_latest: false, + version_id: Some(Uuid::new_v4()), + num_versions: 5, + ..Default::default() + }); + } + + let events = crate::evaluator::Evaluator::new(lc) + .eval(&objs) + .await + .expect("version group should evaluate"); + + assert_eq!(events[1].action, IlmAction::NoneAction); + assert_eq!(events[2].action, IlmAction::NoneAction); + assert_eq!(events[3].action, IlmAction::DeleteVersionAction); + assert_eq!(events[4].action, IlmAction::DeleteVersionAction); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_invalid_status_case_sensitive() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static("enabled"), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_RULE_STATUS); + } + + #[tokio::test] + #[serial] + async fn filter_rules_respects_filter_prefix() { + let filter = LifecycleRuleFilter { + prefix: Some("prefix".to_string()), + ..Default::default() + }; + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: Some(filter), + id: Some("rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let match_obj = ObjectOpts { + name: "prefix/file".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }; + let matched = lc.filter_rules(&match_obj).await.unwrap(); + assert_eq!(matched.len(), 1); + + let non_match_obj = ObjectOpts { + name: "other/file".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }; + let not_matched = lc.filter_rules(&non_match_obj).await.unwrap(); + assert_eq!(not_matched.len(), 0); + } + + #[tokio::test] + #[serial] + async fn filter_rules_respects_filter_and_prefix() { + let and = s3s::dto::LifecycleRuleAndOperator { + prefix: Some("prefix".to_string()), + ..Default::default() + }; + let filter = LifecycleRuleFilter { + and: Some(and), + ..Default::default() + }; + + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: Some(filter), + id: Some("rule-and-prefix".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let match_obj = ObjectOpts { + name: "prefix/file".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }; + let matched = lc.filter_rules(&match_obj).await.unwrap(); + assert_eq!(matched.len(), 1); + + let non_match_obj = ObjectOpts { + name: "other/file".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }; + let not_matched = lc.filter_rules(&non_match_obj).await.unwrap(); + assert_eq!(not_matched.len(), 0); + } + + #[tokio::test] + #[serial] + async fn filter_rules_respects_filter_tag() { + let filter = LifecycleRuleFilter { + tag: Some(s3s::dto::Tag { + key: Some("env".to_string()), + value: Some("prod".to_string()), + }), + ..Default::default() + }; + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: Some(filter), + id: Some("rule-tag".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let matched = lc + .filter_rules(&ObjectOpts { + name: "obj".to_string(), + user_tags: "env=prod&team=storage".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(matched.len(), 1); + + let not_matched = lc + .filter_rules(&ObjectOpts { + name: "obj".to_string(), + user_tags: "env=dev&team=storage".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(not_matched.len(), 0); + } + + #[tokio::test] + #[serial] + async fn filter_rules_respects_filter_and_tags() { + let filter = LifecycleRuleFilter { + and: Some(s3s::dto::LifecycleRuleAndOperator { + tags: Some(vec![ + s3s::dto::Tag { + key: Some("env".to_string()), + value: Some("prod".to_string()), + }, + s3s::dto::Tag { + key: Some("team".to_string()), + value: Some("storage".to_string()), + }, + ]), + ..Default::default() + }), + ..Default::default() + }; + + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: Some(filter), + id: Some("rule-and-tags".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let matched = lc + .filter_rules(&ObjectOpts { + name: "obj".to_string(), + user_tags: "env=prod&team=storage".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(matched.len(), 1); + + let not_matched = lc + .filter_rules(&ObjectOpts { + name: "obj".to_string(), + user_tags: "env=prod&team=platform".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).unwrap()), + is_latest: true, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(not_matched.len(), 0); + } + + #[tokio::test] + #[serial] + async fn expired_object_delete_marker_ignores_marker_with_noncurrent_versions_present() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("rule-expired-del-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + delete_marker: true, + num_versions: 2, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + + let now = base_time + Duration::days(2); + let event = lc.eval_inner(&opts, now, 0).await; + assert_eq!(event.action, IlmAction::NoneAction); + assert_eq!(event.due, Some(OffsetDateTime::UNIX_EPOCH)); + } + + #[tokio::test] + async fn expired_object_delete_marker_ignores_unknown_version_count() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("rule-expired-del-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + delete_marker: true, + num_versions: 0, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + + let now = base_time + Duration::days(2); + let event = lc.eval_inner(&opts, now, 0).await; + assert_eq!(event.action, IlmAction::NoneAction); + assert_eq!(event.due, Some(OffsetDateTime::UNIX_EPOCH)); + } + + #[tokio::test] + #[serial] + async fn expired_object_delete_marker_deletes_only_delete_marker_immediately() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("rule-expired-del-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + delete_marker: true, + num_versions: 1, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + + let now = base_time + Duration::days(2); + let event = lc.eval_inner(&opts, now, 0).await; + + assert_eq!(event.action, IlmAction::DeleteVersionAction); + assert_eq!(event.due, Some(now)); + } + + #[tokio::test] + async fn expired_object_delete_marker_without_date_or_days_deletes_immediately() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("rule-expired-del-marker-immediate".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + delete_marker: true, + num_versions: 1, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + + let now = base_time + Duration::days(2); + let event = lc.eval_inner(&opts, now, 0).await; + assert_eq!(event.action, IlmAction::DeleteVersionAction); + assert_eq!(event.due, Some(now)); + } + + #[tokio::test] + async fn validate_rejects_expired_object_delete_marker_with_date() { + let future_date = OffsetDateTime::from_unix_timestamp(86_400 * 10).expect("valid midnight UTC test timestamp"); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + date: Some(future_date.into()), + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("rule-date-del-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER); + } + + #[tokio::test] + async fn validate_rejects_expired_object_delete_marker_with_days() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: None, + id: Some("rule-days-del-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_DELETE_MARKER); + } + + #[tokio::test] + async fn validate_rejects_expired_object_delete_marker_with_tag_filter() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + filter: Some(LifecycleRuleFilter { + tag: Some(s3s::dto::Tag { + key: Some("env".to_string()), + value: Some("prod".to_string()), + }), + ..Default::default() + }), + id: Some("rule-tag-del-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + del_marker_expiration: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + + assert_eq!(err.to_string(), ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS); + } + + // --- TASK-002 tests: Object Lock + ExpiredObjectDeleteMarker compatibility --- + + #[tokio::test] + #[serial] + async fn validate_allows_expired_object_delete_marker_on_locked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let locked_config = ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + ..Default::default() + }; + + lc.validate(&locked_config) + .await + .expect("expected validation to pass for ExpiredObjectDeleteMarker on locked bucket"); + } + + #[tokio::test] + #[serial] + async fn validate_allows_expired_object_delete_marker_on_unlocked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + // Default ObjectLockConfiguration (no lock enabled) should pass + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("expected validation to pass on unlocked bucket"); + } + + #[tokio::test] + #[serial] + async fn validate_allows_non_delete_marker_expiration_on_locked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let locked_config = ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + ..Default::default() + }; + + // Days-based expiration (not DeleteMarker) should be allowed on locked bucket + lc.validate(&locked_config) + .await + .expect("expected days-based expiration to pass on locked bucket"); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_del_marker_expiration_on_locked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(1) }), + filter: None, + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let locked_config = ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + ..Default::default() + }; + + let err = lc.validate(&locked_config).await.unwrap_err(); + assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); + } + + #[tokio::test] + #[serial] + async fn validate_rejects_zero_day_del_marker_expiration_on_locked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: Some(s3s::dto::DelMarkerExpiration { days: Some(0) }), + filter: None, + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let locked_config = ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + ..Default::default() + }; + + let err = lc.validate(&locked_config).await.unwrap_err(); + assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); + } + + // --- TASK-003 tests: Round up to next UTC processing boundary --- + + #[test] + #[serial] + fn expected_expiry_time_rounds_up_to_next_midnight_utc() { + with_default_ilm_process_time(|| { + // Object created at 2025-01-15T10:30:45Z, expire in 30 days + let mod_time = datetime!(2025-01-15 10:30:45 UTC); + let result = expected_expiry_time(mod_time, 30); + + // Should round up to the next midnight: 2025-02-15T00:00:00Z + assert_eq!(result.hour(), 0); + assert_eq!(result.minute(), 0); + assert_eq!(result.second(), 0); + assert_eq!(result, datetime!(2025-02-15 00:00:00 UTC)); + }); + } + + #[test] + #[serial] + fn expected_expiry_time_immediate_expiry_returns_epoch() { + with_default_ilm_process_time(|| { + let mod_time = datetime!(2025-06-01 12:00:00 UTC); + let result = expected_expiry_time(mod_time, 0); + assert_eq!(result, OffsetDateTime::UNIX_EPOCH); + }); + } + + #[test] + #[serial] + fn expected_expiry_time_preserves_exact_midnight_boundary() { + with_default_ilm_process_time(|| { + let mod_time = datetime!(2025-03-01 00:00:00 UTC); + let result = expected_expiry_time(mod_time, 1); + assert_eq!(result, datetime!(2025-03-02 00:00:00 UTC)); + }); + } + + #[test] + #[serial] + fn expected_expiry_time_rounds_end_of_day_to_following_midnight() { + with_default_ilm_process_time(|| { + let mod_time = datetime!(2025-06-15 23:59:59 UTC); + let result = expected_expiry_time(mod_time, 1); + assert_eq!(result, datetime!(2025-06-17 00:00:00 UTC)); + }); + } + + #[test] + #[serial] + fn expected_expiry_time_uses_canonical_process_time_boundary() { + let mod_time = datetime!(2025-01-15 10:30:45 UTC); + + temp_env::with_var(ENV_ILM_PROCESS_TIME, Some("3600"), || { + temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, || { + let result = expected_expiry_time(mod_time, 1); + assert_eq!(result, datetime!(2025-01-16 11:00:00 UTC)); + }); + }); + } + + #[test] + #[serial] + fn expected_expiry_time_uses_deprecated_process_time_alias() { + let mod_time = datetime!(2025-01-15 10:30:45 UTC); + + temp_env::with_var_unset(ENV_ILM_PROCESS_TIME, || { + temp_env::with_var(ENV_ILM_PROCESS_TIME_DEPRECATED, Some("3600"), || { + let result = expected_expiry_time(mod_time, 1); + assert_eq!(result, datetime!(2025-01-16 11:00:00 UTC)); + }); + }); + } + + #[test] + #[serial] + fn expected_expiry_time_uses_default_boundary_when_process_time_is_zero_or_invalid() { + let mod_time = datetime!(2025-01-15 10:30:45 UTC); + + temp_env::with_var(ENV_ILM_PROCESS_TIME, Some("0"), || { + temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, || { + let result = expected_expiry_time(mod_time, 30); + assert_eq!(result, datetime!(2025-02-15 00:00:00 UTC)); + }); + }); + + temp_env::with_var(ENV_ILM_PROCESS_TIME, Some("not-a-number"), || { + temp_env::with_var_unset(ENV_ILM_PROCESS_TIME_DEPRECATED, || { + let result = expected_expiry_time(mod_time, 30); + assert_eq!(result, datetime!(2025-02-15 00:00:00 UTC)); + }); + }); + } + + // --- TASK-007 tests: Legacy Prefix/Filter conflict --- + + #[tokio::test] + #[serial] + async fn validate_rejects_prefix_and_filter_both_present() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: Some(LifecycleRuleFilter { + prefix: Some("logs/".to_string()), + ..Default::default() + }), + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("archive/".to_string()), + transitions: None, + }], + }; + + let err = lc.validate(&ObjectLockConfiguration::default()).await.unwrap_err(); + assert_eq!(err.to_string(), ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT); + } + + #[tokio::test] + #[serial] + async fn validate_allows_prefix_without_filter() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("logs/".to_string()), + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("prefix without filter should be valid"); + } + + #[tokio::test] + #[serial] + async fn validate_allows_filter_without_prefix() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: Some(LifecycleRuleFilter { + prefix: Some("logs/".to_string()), + ..Default::default() + }), + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("filter without prefix should be valid"); + } + + #[tokio::test] + #[serial] + async fn validate_allows_empty_prefix_with_filter() { + // Empty prefix should be treated as "not set" + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: Some(LifecycleRuleFilter { + prefix: Some("logs/".to_string()), + ..Default::default() + }), + id: Some("test-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: Some("".to_string()), // empty = not set + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("empty prefix with filter should be valid"); + } + + // --- TASK-004 tests: ExpiredObjectAllVersions --- + + #[tokio::test] + #[serial] + async fn validate_rejects_expired_object_all_versions_on_locked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + expired_object_all_versions: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("all-versions-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let locked_config = ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + ..Default::default() + }; + + let err = lc.validate(&locked_config).await.unwrap_err(); + assert_eq!(err.to_string(), ERR_LIFECYCLE_BUCKET_LOCKED); + } + + #[tokio::test] + #[serial] + async fn validate_allows_expired_object_all_versions_on_unlocked_bucket() { + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(30), + expired_object_all_versions: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("all-versions-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + lc.validate(&ObjectLockConfiguration::default()) + .await + .expect("ExpiredObjectAllVersions should be allowed on unlocked bucket"); + } + + #[tokio::test] + #[serial] + async fn eval_inner_triggers_delete_all_versions_when_expired_object_all_versions_set() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + expired_object_all_versions: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("all-versions-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + version_id: None, + ..Default::default() + }; + + // now is after the expiry time + let now = base_time + Duration::days(2); + let event = lc.eval_inner(&opts, now, 0).await; + assert_eq!(event.action, IlmAction::DeleteAllVersionsAction); + assert_eq!(event.rule_id, "all-versions-rule"); + } + + #[tokio::test] + #[serial] + async fn eval_inner_uses_delete_action_when_all_versions_not_set() { + let base_time = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap(); + let lc = BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + expired_object_all_versions: None, // not set + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("normal-rule".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }; + + let opts = ObjectOpts { + name: "obj".to_string(), + mod_time: Some(base_time), + is_latest: true, + version_id: None, + ..Default::default() + }; + + let now = base_time + Duration::days(2); + let event = lc.eval_inner(&opts, now, 0).await; + // Without ExpiredObjectAllVersions, should use normal DeleteAction + assert_eq!(event.action, IlmAction::DeleteAction); + } +} diff --git a/crates/lifecycle/src/evaluator.rs b/crates/lifecycle/src/evaluator.rs new file mode 100644 index 000000000..c295ac07b --- /dev/null +++ b/crates/lifecycle/src/evaluator.rs @@ -0,0 +1,448 @@ +// 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 std::sync::Arc; + +use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled}; +use time::OffsetDateTime; +use tracing::info; + +use rustfs_common::metrics::IlmAction; +use rustfs_replication::ReplicationStatusType; + +use crate::object_lock; +use crate::{Event, Lifecycle, ObjectOpts}; + +const LOG_COMPONENT_ECSTORE: &str = "ecstore"; +const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle"; +const EVENT_LIFECYCLE_VERSION_SCAN_SKIPPED: &str = "lifecycle_version_scan_skipped"; + +/// Evaluator - evaluates lifecycle policy on objects for the given lifecycle +/// configuration and lock retention configuration. +pub struct Evaluator { + policy: Arc, + lock_retention: Option>, +} + +impl Evaluator { + /// NewEvaluator - creates a new evaluator with the given lifecycle + pub fn new(policy: Arc) -> Self { + Self { + policy, + lock_retention: None, + } + } + + /// WithLockRetention - sets the lock retention configuration for the evaluator + pub fn with_lock_retention(mut self, lr: Option>) -> Self { + self.lock_retention = lr; + self + } + + /// WithReplicationConfig is retained for caller compatibility. + /// Lifecycle replication guards are evaluated from per-object replication state. + pub fn with_replication_config(self, _rcfg: Option>) -> Self { + self + } + + /// IsPendingReplication checks if the object is pending replication. + pub fn is_pending_replication(&self, obj: &ObjectOpts) -> bool { + has_pending_lifecycle_replication(obj) + } + + fn any_version_has_pending_replication(&self, objs: &[ObjectOpts]) -> bool { + objs.iter().any(|obj| self.is_pending_replication(obj)) + } + + /// IsObjectLocked checks if it is appropriate to remove an + /// 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() + .is_none_or(|v| v.as_str() != ObjectLockEnabled::ENABLED) + }) { + return false; + } + + // Use the common function to check if the object is locked + object_lock::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. + async fn eval_inner(&self, objs: &[ObjectOpts], now: OffsetDateTime) -> Vec { + let mut events = vec![Event::default(); objs.len()]; + let mut newer_noncurrent_versions = 0; + + 'top_loop: { + for (i, obj) in objs.iter().enumerate() { + let mut event = self.policy.eval_inner(obj, now, newer_noncurrent_versions).await; + if lifecycle_action_waits_for_replication(event.action) && self.is_pending_replication(obj) { + event = Event::default(); + } + match event.action { + IlmAction::DeleteAllVersionsAction | IlmAction::DelMarkerDeleteAllVersionsAction => { + // Skip if bucket has object locking enabled; To prevent the + // possibility of violating an object retention on one of the + // noncurrent versions of this object. + if self.lock_retention.as_ref().is_some_and(|v| { + v.object_lock_enabled + .as_ref() + .is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED) + }) || self.any_version_has_pending_replication(objs) + { + event = Event::default(); + } else { + // No need to evaluate remaining versions' lifecycle + // events after DeleteAllVersionsAction* + events[i] = event; + + info!( + event = EVENT_LIFECYCLE_VERSION_SCAN_SKIPPED, + component = LOG_COMPONENT_ECSTORE, + subsystem = LOG_SUBSYSTEM_LIFECYCLE, + reason = "delete_all_versions_action", + action = ?events[i].action, + "Skipped remaining lifecycle version scan" + ); + + break 'top_loop; + } + } + IlmAction::DeleteVersionAction | IlmAction::DeleteRestoredVersionAction => { + // Defensive code, should never happen + if obj.version_id.is_none_or(|v| v.is_nil()) { + event.action = IlmAction::NoneAction; + } + if self.is_object_locked(obj) { + event = Event::default(); + } + } + _ => {} + } + + if !obj.is_latest { + match event.action { + IlmAction::DeleteVersionAction => { + // this noncurrent version will be expired, nothing to add + } + _ => { + // this noncurrent version will be spared + newer_noncurrent_versions += 1; + } + } + } + events[i] = event; + } + } + events + } + + /// Eval will return a lifecycle event for each object in objs + pub async fn eval(&self, objs: &[ObjectOpts]) -> Result, std::io::Error> { + if objs.is_empty() { + return Ok(vec![]); + } + if objs.len() != objs[0].num_versions { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("number of versions mismatch, expected {}, got {}", objs[0].num_versions, objs.len()), + )); + } + Ok(self.eval_inner(objs, OffsetDateTime::now_utc()).await) + } +} + +fn has_pending_version_purge(obj: &ObjectOpts) -> bool { + obj.version_purge_status.is_pending() +} + +fn has_pending_object_replication(obj: &ObjectOpts) -> bool { + replication_status_blocks_lifecycle(&obj.replication_status) +} + +fn has_pending_lifecycle_replication(obj: &ObjectOpts) -> bool { + has_pending_object_replication(obj) || has_pending_version_purge(obj) +} + +fn replication_status_blocks_lifecycle(status: &ReplicationStatusType) -> bool { + matches!(status, ReplicationStatusType::Pending | ReplicationStatusType::Failed) +} + +fn lifecycle_action_waits_for_replication(action: IlmAction) -> bool { + matches!( + action, + IlmAction::DeleteAction + | IlmAction::DeleteVersionAction + | IlmAction::DeleteRestoredAction + | IlmAction::DeleteRestoredVersionAction + | IlmAction::DeleteAllVersionsAction + | IlmAction::DelMarkerDeleteAllVersionsAction + | IlmAction::TransitionAction + | IlmAction::TransitionVersionAction + ) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use rustfs_common::metrics::IlmAction; + use s3s::dto::{ + BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, Transition, TransitionStorageClass, + }; + use time::OffsetDateTime; + use uuid::Uuid; + + use super::*; + use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType}; + fn expired_marker_lifecycle() -> Arc { + Arc::new(BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + expired_object_delete_marker: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("expired-marker".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }) + } + + fn latest_expiration_lifecycle() -> Arc { + Arc::new(BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("expire-current".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }) + } + + fn latest_transition_lifecycle() -> Arc { + Arc::new(BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: None, + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("transition-current".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: Some(vec![Transition { + days: Some(1), + date: None, + storage_class: Some(TransitionStorageClass::from_static("WARM")), + }]), + }], + }) + } + + fn all_versions_expiration_lifecycle() -> Arc { + Arc::new(BucketLifecycleConfiguration { + expiry_updated_at: None, + rules: vec![LifecycleRule { + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + expiration: Some(LifecycleExpiration { + days: Some(1), + expired_object_all_versions: Some(true), + ..Default::default() + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + id: Some("delete-all".to_string()), + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + prefix: None, + transitions: None, + }], + }) + } + + fn object_opts(replication_status: ReplicationStatusType, version_purge_status: VersionPurgeStatusType) -> ObjectOpts { + ObjectOpts { + name: "logs/object".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).expect("valid fixed test timestamp")), + version_id: Some(Uuid::new_v4()), + is_latest: true, + delete_marker: true, + num_versions: 1, + replication_status, + version_purge_status, + ..Default::default() + } + } + + fn current_object_opts(replication_status: ReplicationStatusType) -> ObjectOpts { + ObjectOpts { + name: "logs/object".to_string(), + mod_time: Some(OffsetDateTime::from_unix_timestamp(1_000_000).expect("valid fixed test timestamp")), + version_id: Some(Uuid::new_v4()), + is_latest: true, + num_versions: 1, + replication_status, + ..Default::default() + } + } + + fn versioned_object_opts(replication_status: ReplicationStatusType, is_latest: bool) -> ObjectOpts { + ObjectOpts { + num_versions: 2, + is_latest, + ..current_object_opts(replication_status) + } + } + + #[tokio::test] + async fn evaluator_allows_expired_delete_marker_after_replication_completed() { + let evaluator = Evaluator::new(expired_marker_lifecycle()); + + let events = evaluator + .eval(&[object_opts( + ReplicationStatusType::Completed, + VersionPurgeStatusType::Complete, + )]) + .await + .expect("completed replication should allow lifecycle evaluation"); + + assert_eq!(events[0].action, IlmAction::DeleteVersionAction); + } + + #[tokio::test] + async fn evaluator_skips_expired_delete_marker_while_replication_pending() { + let evaluator = Evaluator::new(expired_marker_lifecycle()); + + let events = evaluator + .eval(&[object_opts(ReplicationStatusType::Pending, VersionPurgeStatusType::default())]) + .await + .expect("pending replication should still return a lifecycle decision"); + + assert_eq!(events[0].action, IlmAction::NoneAction); + } + + #[tokio::test] + async fn evaluator_skips_expired_delete_marker_while_version_purge_pending() { + let evaluator = Evaluator::new(expired_marker_lifecycle()); + + let events = evaluator + .eval(&[object_opts(ReplicationStatusType::Completed, VersionPurgeStatusType::Pending)]) + .await + .expect("pending version purge should still return a lifecycle decision"); + + assert_eq!(events[0].action, IlmAction::NoneAction); + } + + #[tokio::test] + async fn evaluator_skips_latest_expiration_while_replication_failed() { + let evaluator = Evaluator::new(latest_expiration_lifecycle()); + + let events = evaluator + .eval(&[current_object_opts(ReplicationStatusType::Failed)]) + .await + .expect("failed replication should still return a lifecycle decision"); + + assert_eq!(events[0].action, IlmAction::NoneAction); + } + + #[tokio::test] + async fn evaluator_allows_latest_expiration_after_replication_completed() { + let evaluator = Evaluator::new(latest_expiration_lifecycle()); + + let events = evaluator + .eval(&[current_object_opts(ReplicationStatusType::Completed)]) + .await + .expect("completed replication should allow latest expiration"); + + assert_eq!(events[0].action, IlmAction::DeleteAction); + } + + #[tokio::test] + async fn evaluator_skips_transition_while_replication_pending() { + let evaluator = Evaluator::new(latest_transition_lifecycle()); + + let events = evaluator + .eval(&[current_object_opts(ReplicationStatusType::Pending)]) + .await + .expect("pending replication should still return a lifecycle decision"); + + assert_eq!(events[0].action, IlmAction::NoneAction); + } + + #[tokio::test] + async fn evaluator_allows_transition_after_replication_completed() { + let evaluator = Evaluator::new(latest_transition_lifecycle()); + + let events = evaluator + .eval(&[current_object_opts(ReplicationStatusType::Completed)]) + .await + .expect("completed replication should allow transition"); + + assert_eq!(events[0].action, IlmAction::TransitionAction); + } + + #[tokio::test] + async fn evaluator_skips_delete_all_versions_when_any_version_replication_pending() { + let evaluator = Evaluator::new(all_versions_expiration_lifecycle()); + let latest = versioned_object_opts(ReplicationStatusType::Completed, true); + let noncurrent = versioned_object_opts(ReplicationStatusType::Pending, false); + + let events = evaluator + .eval(&[latest, noncurrent]) + .await + .expect("pending noncurrent replication should still return lifecycle decisions"); + + assert_eq!(events[0].action, IlmAction::NoneAction); + assert_eq!(events[1].action, IlmAction::NoneAction); + } + + #[tokio::test] + async fn evaluator_allows_delete_all_versions_when_all_versions_replication_completed() { + let evaluator = Evaluator::new(all_versions_expiration_lifecycle()); + let latest = versioned_object_opts(ReplicationStatusType::Completed, true); + let noncurrent = versioned_object_opts(ReplicationStatusType::Completed, false); + + let events = evaluator + .eval(&[latest, noncurrent]) + .await + .expect("completed replication should allow delete-all lifecycle decision"); + + assert_eq!(events[0].action, IlmAction::DeleteAllVersionsAction); + assert_eq!(events[1].action, IlmAction::NoneAction); + } +} diff --git a/crates/lifecycle/src/lib.rs b/crates/lifecycle/src/lib.rs new file mode 100644 index 000000000..8986c9523 --- /dev/null +++ b/crates/lifecycle/src/lib.rs @@ -0,0 +1,24 @@ +// 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. + +pub mod core; +pub mod evaluator; +pub mod object_lock; +pub mod rule; +mod tagging; + +pub use core::*; +pub use evaluator::Evaluator; +pub use rustfs_common::metrics::IlmAction; +pub use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType}; diff --git a/crates/lifecycle/src/object_lock.rs b/crates/lifecycle/src/object_lock.rs new file mode 100644 index 000000000..619e8d1cf --- /dev/null +++ b/crates/lifecycle/src/object_lock.rs @@ -0,0 +1,63 @@ +// 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 std::collections::HashMap; + +use s3s::dto::ObjectLockRetentionMode; +use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE}; +use time::{OffsetDateTime, format_description}; + +pub fn is_object_locked_by_metadata(user_defined: &HashMap, is_delete_marker: bool) -> bool { + if is_delete_marker { + return false; + } + + if user_defined + .get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("ON")) + { + return true; + } + + let Some(mode) = user_defined.get(X_AMZ_OBJECT_LOCK_MODE.as_str()) else { + return false; + }; + if !is_retention_mode(mode) { + return false; + } + + user_defined + .get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str()) + .and_then(|value| OffsetDateTime::parse(value, &format_description::well_known::Iso8601::DEFAULT).ok()) + .is_some_and(|retain_until| retain_until.unix_timestamp() > OffsetDateTime::now_utc().unix_timestamp()) +} + +fn is_retention_mode(mode: &str) -> bool { + mode.eq_ignore_ascii_case(ObjectLockRetentionMode::COMPLIANCE) + || mode.eq_ignore_ascii_case(ObjectLockRetentionMode::GOVERNANCE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_object_locked_by_metadata_preserves_object_lock_parser_behavior() { + let mut user_defined = HashMap::new(); + user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "ON".to_string()); + + assert!(is_object_locked_by_metadata(&user_defined, false)); + assert!(!is_object_locked_by_metadata(&user_defined, true)); + } +} diff --git a/crates/lifecycle/src/rule.rs b/crates/lifecycle/src/rule.rs new file mode 100644 index 000000000..cb9473db7 --- /dev/null +++ b/crates/lifecycle/src/rule.rs @@ -0,0 +1,204 @@ +// 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 s3s::dto::{LifecycleRuleAndOperator, LifecycleRuleFilter, NoncurrentVersionTransition, Tag, Transition}; +use time::OffsetDateTime; + +use crate::tagging; + +const _ERR_TRANSITION_INVALID_DAYS: &str = "Days must be 0 or greater when used with Transition"; +const _ERR_TRANSITION_INVALID_DATE: &str = "Date must be provided in ISO 8601 format"; +const ERR_TRANSITION_INVALID: &str = + "Exactly one of Days (0 or greater) or Date (positive ISO 8601 format) should be present in Transition."; +const _ERR_TRANSITION_DATE_NOT_MIDNIGHT: &str = "'Date' must be at midnight GMT"; + +pub trait Filter { + fn test_tags(&self, user_tags: &str) -> bool; + fn by_size(&self, sz: i64) -> bool; +} + +impl Filter for LifecycleRuleFilter { + fn test_tags(&self, user_tags: &str) -> bool { + if !requires_tag_matching(self) { + return true; + } + + let user_tags = tagging::decode_tags_to_map(user_tags); + + self.tag.as_ref().is_none_or(|tag| tag_matches(tag, &user_tags)) + && self.and.as_ref().is_none_or(|and| and_tags_match(and, &user_tags)) + } + + fn by_size(&self, sz: i64) -> bool { + let sz = sz.max(0); + + self.object_size_greater_than.is_none_or(|min| sz > min) + && self.object_size_less_than.is_none_or(|max| sz < max) + && self.and.as_ref().is_none_or(|and| and_size_matches(and, sz)) + } +} + +fn requires_tag_matching(filter: &LifecycleRuleFilter) -> bool { + filter.tag.is_some() + || filter + .and + .as_ref() + .and_then(|and| and.tags.as_ref()) + .is_some_and(|tags| !tags.is_empty()) +} + +fn tag_matches(tag: &Tag, user_tags: &std::collections::HashMap) -> bool { + let Some(key) = tag.key.as_deref() else { + return false; + }; + let Some(value) = tag.value.as_deref() else { + return false; + }; + + user_tags.get(key).is_some_and(|actual| actual == value) +} + +fn and_tags_match(and: &LifecycleRuleAndOperator, user_tags: &std::collections::HashMap) -> bool { + and.tags + .as_ref() + .is_none_or(|tags| tags.iter().all(|tag| tag_matches(tag, user_tags))) +} + +fn and_size_matches(and: &LifecycleRuleAndOperator, sz: i64) -> bool { + and.object_size_greater_than.is_none_or(|min| sz > min) && and.object_size_less_than.is_none_or(|max| sz < max) +} + +pub trait TransitionOps { + fn validate(&self) -> Result<(), std::io::Error>; +} + +pub trait NoncurrentVersionTransitionOps { + fn validate(&self) -> Result<(), std::io::Error>; +} + +fn is_midnight(date: OffsetDateTime) -> bool { + date.hour() == 0 && date.minute() == 0 && date.second() == 0 && date.nanosecond() == 0 +} + +impl TransitionOps for Transition { + fn validate(&self) -> Result<(), std::io::Error> { + if self + .storage_class + .as_ref() + .is_none_or(|storage_class| storage_class.as_str().is_empty()) + { + return Err(std::io::Error::other("ERR_XML_NOT_WELL_FORMED")); + } + if self.date.is_some() == self.days.is_some() { + return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); + } + if self.days.is_some_and(|days| days < 0) { + return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); + } + if let Some(date) = self.date.clone() { + let date = OffsetDateTime::from(date); + if date.unix_timestamp() <= 0 || !is_midnight(date) { + return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); + } + } + Ok(()) + } +} + +impl NoncurrentVersionTransitionOps for NoncurrentVersionTransition { + fn validate(&self) -> Result<(), std::io::Error> { + if self + .storage_class + .as_ref() + .is_none_or(|storage_class| storage_class.as_str().is_empty()) + { + return Err(std::io::Error::other("ERR_XML_NOT_WELL_FORMED")); + } + if self.noncurrent_days.is_none() { + return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); + } + if self.noncurrent_days.is_some_and(|days| days < 0) + || self.newer_noncurrent_versions.is_some_and(|versions| versions < 0) + { + return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn lifecycle_rule_filter_matches_single_tag() { + let filter = LifecycleRuleFilter { + tag: Some(Tag { + key: Some("env".to_string()), + value: Some("prod".to_string()), + }), + ..Default::default() + }; + + assert!(::test_tags(&filter, "env=prod&team=storage")); + assert!(!::test_tags(&filter, "env=dev&team=storage")); + assert!(!::test_tags(&filter, "team=storage")); + } + + #[test] + fn lifecycle_rule_filter_matches_all_and_tags() { + let filter = LifecycleRuleFilter { + and: Some(LifecycleRuleAndOperator { + tags: Some(vec![ + Tag { + key: Some("env".to_string()), + value: Some("prod".to_string()), + }, + Tag { + key: Some("team".to_string()), + value: Some("storage".to_string()), + }, + ]), + ..Default::default() + }), + ..Default::default() + }; + + assert!(::test_tags(&filter, "env=prod&team=storage")); + assert!(!::test_tags(&filter, "env=prod&team=platform")); + } + + #[test] + fn lifecycle_rule_filter_respects_size_bounds() { + let filter = LifecycleRuleFilter { + object_size_greater_than: Some(5), + object_size_less_than: Some(10), + ..Default::default() + }; + + assert!(!filter.by_size(5)); + assert!(filter.by_size(6)); + assert!(!filter.by_size(10)); + } + + #[test] + fn lifecycle_rule_filter_without_tag_constraints_accepts_any_tags() { + let filter = LifecycleRuleFilter { + object_size_greater_than: Some(5), + ..Default::default() + }; + + assert!(::test_tags(&filter, "env=prod&team=storage")); + assert!(::test_tags(&filter, "")); + } +} diff --git a/crates/lifecycle/src/tagging.rs b/crates/lifecycle/src/tagging.rs new file mode 100644 index 000000000..155965900 --- /dev/null +++ b/crates/lifecycle/src/tagging.rs @@ -0,0 +1,45 @@ +// 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 std::collections::HashMap; + +use url::form_urlencoded; + +pub(crate) fn decode_tags_to_map(tags: &str) -> HashMap { + let mut list = HashMap::new(); + + for (k, v) in form_urlencoded::parse(tags.as_bytes()) { + if k.is_empty() { + continue; + } + + list.insert(k.to_string(), v.to_string()); + } + + list +} + +#[cfg(test)] +mod tests { + use super::decode_tags_to_map; + + #[test] + fn decode_tags_to_map_preserves_bucket_tagging_parser_behavior() { + let tags = decode_tags_to_map("env=prod&encoded=a%2Fb&=ignored"); + + assert_eq!(tags.get("env").map(String::as_str), Some("prod")); + assert_eq!(tags.get("encoded").map(String::as_str), Some("a/b")); + assert!(!tags.contains_key("")); + } +} diff --git a/crates/scanner/src/lib.rs b/crates/scanner/src/lib.rs index 214ea83d8..e4c3dd5d2 100644 --- a/crates/scanner/src/lib.rs +++ b/crates/scanner/src/lib.rs @@ -35,9 +35,10 @@ use storage_api::owner::{ ScannerReplicationHealResult, ScannerReplicationQueueAdmission, ecstore_apply_expiry_rule, ecstore_apply_transition_rule, ecstore_expiry_state_handle, ecstore_get_global_tier_config_mgr, ecstore_get_lifecycle_config, ecstore_get_object_lock_config, ecstore_get_replication_config, ecstore_is_erasure, ecstore_is_erasure_sd, - ecstore_is_reserved_or_invalid_bucket, ecstore_list_path_raw, ecstore_path2_bucket_object, - ecstore_path2_bucket_object_with_base_path, ecstore_read_config, ecstore_replace_bucket_usage_memory_from_info, - ecstore_resolve_object_store_handle, ecstore_save_config, scanner_replication_config_for_lifecycle_eval, + ecstore_is_reserved_or_invalid_bucket, ecstore_list_path_raw, ecstore_object_opts_from_object_info, + ecstore_path2_bucket_object, ecstore_path2_bucket_object_with_base_path, ecstore_read_config, + ecstore_replace_bucket_usage_memory_from_info, ecstore_resolve_object_store_handle, ecstore_save_config, + scanner_replication_config_for_lifecycle_eval, }; #[cfg(test)] use storage_api::owner::{EcstoreDiskOption, EcstoreDiskStore, EcstoreEndpoint, ecstore_config_init, ecstore_new_disk}; diff --git a/crates/scanner/src/scanner_folder.rs b/crates/scanner/src/scanner_folder.rs index 3386440d6..618ee65ac 100644 --- a/crates/scanner/src/scanner_folder.rs +++ b/crates/scanner/src/scanner_folder.rs @@ -795,7 +795,7 @@ impl ScannerItem { let object_opts = object_infos .iter() - .map(ObjectOpts::from_object_info) + .map(crate::ecstore_object_opts_from_object_info) .collect::>(); let events = match Evaluator::new(lifecycle.clone()) diff --git a/crates/scanner/src/storage_api.rs b/crates/scanner/src/storage_api.rs index b7f574508..fd1ea75e4 100644 --- a/crates/scanner/src/storage_api.rs +++ b/crates/scanner/src/storage_api.rs @@ -25,7 +25,7 @@ pub(crate) use rustfs_ecstore::api::bucket::lifecycle::bucket_lifecycle_ops::{ pub(crate) use rustfs_ecstore::api::bucket::lifecycle::evaluator::Evaluator as EcstoreEvaluator; pub(crate) use rustfs_ecstore::api::bucket::lifecycle::lifecycle::{ Event as EcstoreEvent, Lifecycle as EcstoreLifecycle, ObjectOpts as EcstoreObjectOpts, - TRANSITION_COMPLETE as ECSTORE_TRANSITION_COMPLETE, + TRANSITION_COMPLETE as ECSTORE_TRANSITION_COMPLETE, object_opts_from_object_info as ecstore_object_opts_from_object_info, }; pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{ get_lifecycle_config as ecstore_get_lifecycle_config, get_object_lock_config as ecstore_get_object_lock_config, @@ -96,9 +96,9 @@ pub(crate) mod owner { ScannerReplicationQueueAdmission, ecstore_apply_expiry_rule, ecstore_apply_transition_rule, ecstore_expiry_state_handle, ecstore_get_global_tier_config_mgr, ecstore_get_lifecycle_config, ecstore_get_object_lock_config, ecstore_get_replication_config, ecstore_is_erasure, ecstore_is_erasure_sd, ecstore_is_reserved_or_invalid_bucket, - ecstore_list_path_raw, ecstore_path2_bucket_object, ecstore_path2_bucket_object_with_base_path, ecstore_read_config, - ecstore_replace_bucket_usage_memory_from_info, ecstore_resolve_object_store_handle, ecstore_save_config, - scanner_replication_config_for_lifecycle_eval, + ecstore_list_path_raw, ecstore_object_opts_from_object_info, ecstore_path2_bucket_object, + ecstore_path2_bucket_object_with_base_path, ecstore_read_config, ecstore_replace_bucket_usage_memory_from_info, + ecstore_resolve_object_store_handle, ecstore_save_config, scanner_replication_config_for_lifecycle_eval, }; #[cfg(test)] diff --git a/docs/architecture/ecstore-module-split-plan.md b/docs/architecture/ecstore-module-split-plan.md index 1834686e6..913cea48b 100644 --- a/docs/architecture/ecstore-module-split-plan.md +++ b/docs/architecture/ecstore-module-split-plan.md @@ -1,15 +1,15 @@ # ECStore Module Split Plan This plan records the remaining ECStore split work after the final audit -remediation pass. It is intentionally design-only: no crate movement should -start until each candidate boundary has explicit contracts, compatibility -coverage, dependency evidence, and rollback steps. +remediation pass. Runtime movement must still wait until each candidate +boundary has explicit contracts, compatibility coverage, dependency evidence, +and rollback steps. ## Current Shape | Area | Current owner | Size | Split status | |---|---|---:|---| -| Bucket lifecycle | `crates/ecstore/src/bucket/lifecycle/` | 8,657 lines | Proposal only | +| Bucket lifecycle | `crates/lifecycle/` + `crates/ecstore/src/bucket/lifecycle/` | core contracts + ECStore runtime | Core contract extracted | | Bucket replication | `crates/ecstore/src/bucket/replication/` | 8,730 lines | Proposal only | | Set disks | `crates/ecstore/src/set_disk/` | state carrier plus operation modules | Keep in ECStore | | Public ECStore facade | `crates/ecstore/src/api/mod.rs` | broad compatibility surface | Shrink only through guarded PRs | @@ -50,7 +50,10 @@ not a runtime split PR. ## Lifecycle Candidate -`bucket/lifecycle` is not ready for a standalone crate yet. +`rustfs-lifecycle` now owns the pure lifecycle rule, event, evaluator, tag +filtering, object-lock metadata check, and expiry-time contracts. ECStore keeps +the object-store runtime, queues, tiering, audit/notification, metadata, and +replication scheduling adapters. Current coupling: @@ -61,9 +64,20 @@ Current coupling: through the lifecycle metadata boundary; - lifecycle expiry schedules bucket replication delete work through the replication lifecycle bridge contract; -- lifecycle evaluation shares S3 DTOs, object metadata, object lock, replication - config reads through lifecycle-local boundaries, scanner metrics, - notification/audit side effects, and tier services. +- lifecycle evaluation uses S3 DTOs and replication status contracts from the + independent `rustfs-lifecycle`/`rustfs-replication` crates, while ECStore maps + `ObjectInfo` into lifecycle object options at the compatibility boundary; +- lifecycle runtime still coordinates scanner metrics, notification/audit side + effects, metadata access, replication delete scheduling, and tier services. + +Current extracted contracts: + +- `LifecycleCrateCoreIndependence`: lifecycle rule validation, filtering, + event evaluation, transition/expiration options, tag decoding, object-lock + metadata checks, and ILM expiry-time rounding live in `rustfs-lifecycle`. + `rustfs-lifecycle` must not import ECStore internals, file metadata, or + `rustfs-utils`; ECStore owns the `ObjectInfo` adapter in + `crates/ecstore/src/bucket/lifecycle/core.rs`. Required contracts before crate movement: @@ -79,12 +93,13 @@ Required contracts before crate movement: without depending on the replication implementation module. - `LifecycleAuditSink`: lifecycle audit and notification emission boundary. -First safe PR: +Next safe PR: -- add a lifecycle extraction inventory section or module-level README; -- list current ECStore/runtime dependencies and the target contract owner for - each dependency; -- add no code movement and no behavior changes. +- move one runtime-facing dependency behind a trait or adapter owned by + `rustfs-lifecycle` without changing queue, transition, or delete behavior; +- keep ECStore compatibility shims until scanner and RustFS app consumers stop + depending on `rustfs_ecstore::api::bucket::lifecycle` paths; +- add focused tests for the moved contract and keep architecture guard coverage. The module-level inventory lives in `crates/ecstore/src/bucket/lifecycle/README.md`. diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index cdb157319..c5bc53de3 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -2017,7 +2017,7 @@ async fn resolve_put_object_expiration(bucket: &str, obj_info: &ObjectInfo) -> O return None; }; - let obj_opts = lifecycle::ObjectOpts::from_object_info(obj_info); + let obj_opts = lifecycle::object_opts_from_object_info(obj_info); let event = predict_lifecycle_expiration(&lifecycle_config, &obj_opts).await; debug!( bucket, diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index b69eddf1f..1b2555ae2 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -374,6 +374,9 @@ pub(crate) mod bucket { #[cfg(test)] pub(crate) const TRANSITION_PENDING: &str = crate::storage::storage_api::ecstore_bucket::lifecycle::lifecycle::TRANSITION_PENDING; + pub(crate) fn object_opts_from_object_info(obj_info: &crate::storage::storage_api::StorageObjectInfo) -> ObjectOpts { + crate::storage::storage_api::ecstore_bucket::lifecycle::lifecycle::object_opts_from_object_info(obj_info) + } pub(crate) fn expected_expiry_time(mod_time: time::OffsetDateTime, days: i32) -> time::OffsetDateTime { crate::storage::storage_api::ecstore_bucket::lifecycle::lifecycle::expected_expiry_time(mod_time, days) } diff --git a/scripts/check_architecture_migration_rules.sh b/scripts/check_architecture_migration_rules.sh index 8772a8a17..3a6077e81 100755 --- a/scripts/check_architecture_migration_rules.sh +++ b/scripts/check_architecture_migration_rules.sh @@ -89,6 +89,7 @@ require_source_contains "docs/architecture/ecstore-module-split-plan.md" "Runtim require_source_contains "docs/architecture/ecstore-module-split-plan.md" "ReplicationCrateFileMetaIndependence" "ECStore split plan replication crate filemeta independence section" require_source_contains "docs/architecture/ecstore-module-split-plan.md" "ReplicationCrateStorageApiIndependence" "ECStore split plan replication crate storage-api independence section" require_source_contains "docs/architecture/ecstore-module-split-plan.md" "ReplicationCrateUtilsIndependence" "ECStore split plan replication crate utils independence section" +require_source_contains "docs/architecture/ecstore-module-split-plan.md" "LifecycleCrateCoreIndependence" "ECStore split plan lifecycle crate core independence section" require_source_contains "docs/architecture/ecstore-module-split-plan.md" "StorageApiReplicationContracts" "ECStore split plan storage-api replication contract section" require_source_contains "docs/architecture/ecstore-api-facade-inventory.md" "## Facade Group Inventory" "ECStore facade inventory group section" require_source_contains "docs/architecture/ecstore-api-facade-inventory.md" "## External Consumer Boundaries" "ECStore facade inventory consumer boundary section" @@ -143,6 +144,7 @@ LIFECYCLE_TAGGING_BOUNDARY_BYPASS_HITS_FILE="${TMP_DIR}/lifecycle_tagging_bounda LIFECYCLE_OBJECT_LOCK_BOUNDARY_BYPASS_HITS_FILE="${TMP_DIR}/lifecycle_object_lock_boundary_bypass_hits.txt" LIFECYCLE_REPLICATION_SINK_BYPASS_HITS_FILE="${TMP_DIR}/lifecycle_replication_sink_bypass_hits.txt" LIFECYCLE_REPLICATION_CRATE_BYPASS_HITS_FILE="${TMP_DIR}/lifecycle_replication_crate_bypass_hits.txt" +LIFECYCLE_CRATE_CORE_BYPASS_HITS_FILE="${TMP_DIR}/lifecycle_crate_core_bypass_hits.txt" ECSTORE_REPLICATION_CONTRACT_BYPASS_HITS_FILE="${TMP_DIR}/ecstore_replication_contract_bypass_hits.txt" STORAGE_API_REPLICATION_CONTRACT_BYPASS_HITS_FILE="${TMP_DIR}/storage_api_replication_contract_bypass_hits.txt" STORE_API_EXTERNAL_LIST_CONSUMER_HITS_FILE="${TMP_DIR}/store_api_external_list_consumer_hits.txt" @@ -863,9 +865,9 @@ if [[ -s "$STORE_API_LIFECYCLE_HELPER_DEFINITION_HITS_FILE" ]]; then fi require_source_line \ - "crates/ecstore/src/bucket/lifecycle/core.rs" \ - "pub use crate::storage_api_contracts::lifecycle::ExpirationOptions;" \ - "ECStore ExpirationOptions compatibility re-export" + "crates/lifecycle/src/core.rs" \ + "pub use rustfs_storage_api::ExpirationOptions;" \ + "rustfs-lifecycle ExpirationOptions storage-api re-export" require_source_line \ "crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs" \ "pub use crate::storage_api_contracts::lifecycle::TransitionedObject;" \ @@ -969,6 +971,24 @@ if [[ -s "$LIFECYCLE_REPLICATION_CRATE_BYPASS_HITS_FILE" ]]; then report_failure "lifecycle replication status/state contracts must stay behind lifecycle replication_sink: $(paste -sd '; ' "$LIFECYCLE_REPLICATION_CRATE_BYPASS_HITS_FILE")" fi +if [[ -d "${ROOT_DIR}/crates/lifecycle" ]]; then + ( + cd "$ROOT_DIR" + { + rg -n --with-filename 'rustfs_ecstore::|use\s+rustfs_ecstore\b|rustfs_filemeta::|use\s+rustfs_filemeta\b|rustfs_utils::|use\s+rustfs_utils\b' \ + crates/lifecycle/Cargo.toml crates/lifecycle/src \ + --glob '*.rs' || true + rg -n --with-filename 'crate::bucket::|crate::object_api|crate::storage_api_contracts' \ + crates/lifecycle/src \ + --glob '*.rs' || true + } + ) >"$LIFECYCLE_CRATE_CORE_BYPASS_HITS_FILE" + + if [[ -s "$LIFECYCLE_CRATE_CORE_BYPASS_HITS_FILE" ]]; then + report_failure "rustfs-lifecycle core contracts must not import ECStore/filemeta/utils internals: $(paste -sd '; ' "$LIFECYCLE_CRATE_CORE_BYPASS_HITS_FILE")" + fi +fi + ( cd "$ROOT_DIR" {