// 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, "")); } }