diff --git a/crates/ecstore/src/bucket/lifecycle/lifecycle.rs b/crates/ecstore/src/bucket/lifecycle/lifecycle.rs index 55787b684..68d705ca7 100644 --- a/crates/ecstore/src/bucket/lifecycle/lifecycle.rs +++ b/crates/ecstore/src/bucket/lifecycle/lifecycle.rs @@ -21,7 +21,8 @@ use rustfs_filemeta::{ReplicationStatusType, VersionPurgeStatusType}; use s3s::dto::{ BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleAndOperator, - NoncurrentVersionTransition, ObjectLockConfiguration, ObjectLockEnabled, RestoreRequest, Transition, TransitionStorageClass, + LifecycleRuleFilter, NoncurrentVersionTransition, ObjectLockConfiguration, ObjectLockEnabled, RestoreRequest, Transition, + TransitionStorageClass, }; use std::cmp::Ordering; use std::collections::HashMap; @@ -350,12 +351,15 @@ impl Lifecycle for BucketLifecycleConfiguration { continue; } } - /*if !rule.filter.test_tags(obj.user_tags) { - continue; - }*/ - //if !obj.delete_marker && !rule.filter.BySize(obj.size) { - if !obj.delete_marker && false { - 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()); } @@ -1567,6 +1571,122 @@ mod tests { 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 mut filter = LifecycleRuleFilter::default(); + filter.and = Some(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() + }); + + 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_requires_single_version() { diff --git a/crates/ecstore/src/bucket/lifecycle/rule.rs b/crates/ecstore/src/bucket/lifecycle/rule.rs index eaa24acfa..dee800343 100644 --- a/crates/ecstore/src/bucket/lifecycle/rule.rs +++ b/crates/ecstore/src/bucket/lifecycle/rule.rs @@ -1,4 +1,3 @@ -#![allow(unused_imports)] // Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,13 +11,8 @@ // 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. -#![allow(unused_variables)] -#![allow(unused_mut)] -#![allow(unused_assignments)] -#![allow(unused_must_use)] -#![allow(clippy::all)] - -use s3s::dto::{LifecycleRuleFilter, Transition}; +use crate::bucket::tagging::decode_tags_to_map; +use s3s::dto::{LifecycleRuleAndOperator, LifecycleRuleFilter, Tag, Transition}; 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"; @@ -33,21 +27,62 @@ pub trait Filter { impl Filter for LifecycleRuleFilter { fn test_tags(&self, user_tags: &str) -> bool { - true + if !requires_tag_matching(self) { + return true; + } + + let user_tags = 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 { - true + 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>; } impl TransitionOps for Transition { fn validate(&self) -> Result<(), std::io::Error> { - if !self.date.is_none() && self.days.expect("err!") > 0 { + if self.date.is_some() && self.days.is_some_and(|d| d > 0) { return Err(std::io::Error::other(ERR_TRANSITION_INVALID)); } @@ -62,8 +97,65 @@ impl TransitionOps for Transition { mod test { use super::*; - #[tokio::test] - async fn test_rule() { - //assert!(skip_access_checks(p.to_str().unwrap())); + #[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, "")); } }