1use std::collections::HashMap;
7use std::fmt;
8
9use serde::de::Error as _;
10use serde::{Deserialize, Deserializer, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LifecycleConfiguration {
15 #[serde(alias = "Rules")]
17 pub rules: Vec<LifecycleRule>,
18}
19
20#[derive(Debug, Clone, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct LifecycleRule {
24 pub id: String,
26
27 pub status: LifecycleRuleStatus,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub prefix: Option<String>,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub tags: Option<HashMap<String, String>>,
37
38 #[serde(
40 skip_serializing_if = "Option::is_none",
41 alias = "ObjectSizeGreaterThan",
42 alias = "object_size_greater_than"
43 )]
44 pub object_size_greater_than: Option<i64>,
45
46 #[serde(
48 skip_serializing_if = "Option::is_none",
49 alias = "ObjectSizeLessThan",
50 alias = "object_size_less_than"
51 )]
52 pub object_size_less_than: Option<i64>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub expiration: Option<LifecycleExpiration>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub del_marker_expiration: Option<LifecycleDelMarkerExpiration>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub transition: Option<LifecycleTransition>,
65
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub transitions: Vec<LifecycleTransition>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub noncurrent_version_transition: Option<NoncurrentVersionTransition>,
80
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub abort_incomplete_multipart_upload_days: Option<i32>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub expired_object_delete_marker: Option<bool>,
92}
93
94#[derive(Debug, Deserialize)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96struct LifecycleRuleInput {
97 #[serde(alias = "ID")]
98 id: String,
99 #[serde(alias = "Status")]
100 status: LifecycleRuleStatus,
101 #[serde(default, alias = "Prefix")]
102 prefix: Option<String>,
103 #[serde(default, alias = "Tags")]
104 tags: Option<HashMap<String, String>>,
105 #[serde(
106 default,
107 alias = "ObjectSizeGreaterThan",
108 alias = "object_size_greater_than"
109 )]
110 object_size_greater_than: Option<i64>,
111 #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
112 object_size_less_than: Option<i64>,
113 #[serde(default, alias = "Filter")]
115 filter: Option<LifecycleFilterInput>,
116 #[serde(default, alias = "Expiration")]
117 expiration: Option<LifecycleExpirationInput>,
118 #[serde(default, alias = "Transition")]
119 transition: Option<LifecycleTransition>,
120 #[serde(default, alias = "Transitions")]
121 transitions: Vec<LifecycleTransition>,
122 #[serde(default, alias = "NoncurrentVersionExpiration")]
123 noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
124 #[serde(default, alias = "NoncurrentVersionTransition")]
125 noncurrent_version_transition: Option<NoncurrentVersionTransition>,
126 #[serde(default, alias = "NoncurrentVersionTransitions")]
127 noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
128 #[serde(
129 default,
130 alias = "AbortIncompleteMultipartUploadDays",
131 alias = "abort_incomplete_multipart_upload_days"
132 )]
133 abort_incomplete_multipart_upload_days: Option<i32>,
134 #[serde(
136 default,
137 alias = "AbortIncompleteMultipartUpload",
138 alias = "abort_incomplete_multipart_upload"
139 )]
140 abort_incomplete_multipart_upload: Option<AbortIncompleteMultipartUploadInput>,
141
142 #[serde(
143 default,
144 alias = "ExpiredObjectDeleteMarker",
145 alias = "expired_object_delete_marker"
146 )]
147 expired_object_delete_marker: Option<bool>,
148
149 #[serde(
150 default,
151 alias = "DelMarkerExpiration",
152 alias = "del_marker_expiration"
153 )]
154 del_marker_expiration: Option<LifecycleDelMarkerInput>,
155}
156
157#[derive(Debug, Deserialize)]
160#[serde(rename_all = "camelCase", deny_unknown_fields)]
161struct LifecycleFilterInput {
162 #[serde(default, alias = "Prefix")]
163 prefix: Option<String>,
164 #[serde(default, alias = "Tag")]
165 tag: Option<LifecycleTagInput>,
166 #[serde(default, alias = "And")]
167 and: Option<LifecycleFilterAndInput>,
168 #[serde(
169 default,
170 alias = "ObjectSizeGreaterThan",
171 alias = "object_size_greater_than"
172 )]
173 object_size_greater_than: Option<i64>,
174 #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
175 object_size_less_than: Option<i64>,
176}
177
178#[derive(Debug, Deserialize)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181struct LifecycleFilterAndInput {
182 #[serde(default, alias = "Prefix")]
183 prefix: Option<String>,
184 #[serde(default, alias = "Tag")]
185 tag: Option<LifecycleTagInput>,
186 #[serde(default, alias = "Tags")]
187 tags: Option<Vec<LifecycleTagInput>>,
188 #[serde(
189 default,
190 alias = "ObjectSizeGreaterThan",
191 alias = "object_size_greater_than"
192 )]
193 object_size_greater_than: Option<i64>,
194 #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
195 object_size_less_than: Option<i64>,
196}
197
198#[derive(Debug, Deserialize)]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201struct LifecycleTagInput {
202 #[serde(alias = "Key")]
203 key: String,
204 #[serde(alias = "Value")]
205 value: String,
206}
207
208#[derive(Debug, Deserialize)]
210#[serde(rename_all = "camelCase", deny_unknown_fields)]
211struct AbortIncompleteMultipartUploadInput {
212 #[serde(
213 default,
214 alias = "DaysAfterInitiation",
215 alias = "days_after_initiation"
216 )]
217 days_after_initiation: Option<i32>,
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(rename_all = "camelCase", deny_unknown_fields)]
222struct LifecycleExpirationInput {
223 #[serde(default, alias = "Days")]
224 days: Option<i32>,
225 #[serde(default, alias = "Date")]
226 date: Option<String>,
227 #[serde(
228 default,
229 alias = "ExpiredObjectAllVersions",
230 alias = "expired_object_all_versions"
231 )]
232 expired_object_all_versions: Option<bool>,
233 #[serde(
234 default,
235 alias = "DelMarkerExpiration",
236 alias = "del_marker_expiration"
237 )]
238 del_marker_expiration: Option<LifecycleDelMarkerInput>,
239 #[serde(
240 default,
241 alias = "ExpiredObjectDeleteMarker",
242 alias = "expired_object_delete_marker"
243 )]
244 expired_object_delete_marker: Option<bool>,
245}
246
247#[derive(Debug, Deserialize)]
248#[serde(untagged)]
249enum LifecycleDelMarkerInput {
250 Flag(bool),
251 Configuration(LifecycleDelMarkerExpiration),
252}
253
254#[derive(Debug)]
255struct NormalizedDelMarkerInput {
256 enabled: bool,
257 configuration: Option<LifecycleDelMarkerExpiration>,
258}
259
260impl<'de> Deserialize<'de> for LifecycleRule {
261 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
262 where
263 D: Deserializer<'de>,
264 {
265 let LifecycleRuleInput {
266 id,
267 status,
268 prefix,
269 tags,
270 object_size_greater_than,
271 object_size_less_than,
272 filter,
273 expiration: expiration_input,
274 transition,
275 transitions: mut transitions_input,
276 noncurrent_version_expiration,
277 noncurrent_version_transition,
278 noncurrent_version_transitions: mut noncurrent_version_transitions_input,
279 abort_incomplete_multipart_upload_days,
280 abort_incomplete_multipart_upload,
281 expired_object_delete_marker,
282 del_marker_expiration: top_level_del_marker_input,
283 } = LifecycleRuleInput::deserialize(deserializer)?;
284
285 let filter_fields = resolve_filter_fields(filter).map_err(D::Error::custom)?;
286 let prefix = merge_exclusive(prefix, filter_fields.prefix, "prefix", "Filter.Prefix")
287 .map_err(D::Error::custom)?;
288 let tags = merge_exclusive(tags, filter_fields.tags, "tags", "Filter tags")
289 .map_err(D::Error::custom)?;
290 let object_size_greater_than = merge_exclusive(
291 object_size_greater_than,
292 filter_fields.object_size_greater_than,
293 "objectSizeGreaterThan",
294 "Filter.ObjectSizeGreaterThan",
295 )
296 .map_err(D::Error::custom)?;
297 let object_size_less_than = merge_exclusive(
298 object_size_less_than,
299 filter_fields.object_size_less_than,
300 "objectSizeLessThan",
301 "Filter.ObjectSizeLessThan",
302 )
303 .map_err(D::Error::custom)?;
304
305 let nested_expired_object_delete_marker = expiration_input
306 .as_ref()
307 .and_then(|expiration| expiration.expired_object_delete_marker);
308 let expired_object_delete_marker = merge_equal_optional(
309 expired_object_delete_marker,
310 nested_expired_object_delete_marker,
311 "expiredObjectDeleteMarker",
312 )
313 .map_err(D::Error::custom)?;
314
315 let abort_incomplete_multipart_upload_days = merge_equal_optional(
316 abort_incomplete_multipart_upload_days,
317 abort_incomplete_multipart_upload.and_then(|action| action.days_after_initiation),
318 "abortIncompleteMultipartUploadDays",
319 )
320 .map_err(D::Error::custom)?;
321
322 let (expiration, nested_del_marker) = match expiration_input {
323 Some(expiration) => {
324 let nested_del_marker = expiration.del_marker_expiration;
325 (
326 Some(LifecycleExpiration {
327 days: expiration.days,
328 date: expiration.date,
329 expired_object_all_versions: expiration.expired_object_all_versions,
330 }),
331 nested_del_marker,
332 )
333 }
334 None => (None, None),
335 };
336
337 let fallback_days = expiration.as_ref().and_then(|expiration| expiration.days);
338 let top_level_del_marker =
339 normalize_del_marker_input(top_level_del_marker_input, fallback_days)
340 .map_err(D::Error::custom)?;
341 let nested_del_marker = normalize_del_marker_input(nested_del_marker, fallback_days)
342 .map_err(D::Error::custom)?;
343 let del_marker_expiration =
344 merge_del_marker_inputs(top_level_del_marker, nested_del_marker)
345 .map_err(D::Error::custom)?;
346
347 if let Some(transition) = transition {
348 transitions_input.insert(0, transition);
349 }
350 let transition = transitions_input.first().cloned();
351 let additional_transitions = transitions_input.into_iter().skip(1).collect();
352
353 if let Some(transition) = noncurrent_version_transition {
354 noncurrent_version_transitions_input.insert(0, transition);
355 }
356 let noncurrent_version_transition = noncurrent_version_transitions_input.first().cloned();
357 let additional_noncurrent_version_transitions = noncurrent_version_transitions_input
358 .into_iter()
359 .skip(1)
360 .collect();
361
362 Ok(Self {
363 id,
364 status,
365 prefix,
366 tags,
367 object_size_greater_than,
368 object_size_less_than,
369 expiration,
370 del_marker_expiration,
371 transition,
372 transitions: additional_transitions,
373 noncurrent_version_expiration,
374 noncurrent_version_transition,
375 noncurrent_version_transitions: additional_noncurrent_version_transitions,
376 abort_incomplete_multipart_upload_days,
377 expired_object_delete_marker,
378 })
379 }
380}
381
382#[derive(Default)]
384struct FilterFields {
385 prefix: Option<String>,
386 tags: Option<HashMap<String, String>>,
387 object_size_greater_than: Option<i64>,
388 object_size_less_than: Option<i64>,
389}
390
391fn resolve_filter_fields(
397 filter: Option<LifecycleFilterInput>,
398) -> std::result::Result<FilterFields, String> {
399 let Some(filter) = filter else {
400 return Ok(FilterFields::default());
401 };
402 let predicate_count = usize::from(filter.prefix.is_some())
403 + usize::from(filter.tag.is_some())
404 + usize::from(filter.and.is_some())
405 + usize::from(filter.object_size_greater_than.is_some())
406 + usize::from(filter.object_size_less_than.is_some());
407 if predicate_count > 1 {
408 return Err(
409 "S3 Filter allows exactly one of Prefix, Tag, And, ObjectSizeGreaterThan, or ObjectSizeLessThan"
410 .to_string(),
411 );
412 }
413 if let Some(and) = filter.and {
414 let mut tags = HashMap::new();
415 if let Some(tag) = and.tag {
416 tags.insert(tag.key, tag.value);
417 }
418 if let Some(tag_list) = and.tags {
419 for tag in tag_list {
420 if tags.insert(tag.key.clone(), tag.value).is_some() {
421 let key = tag.key;
422 return Err(format!("duplicate tag key in Filter.And: {key}"));
423 }
424 }
425 }
426 return Ok(FilterFields {
427 prefix: and.prefix,
428 tags: if tags.is_empty() { None } else { Some(tags) },
429 object_size_greater_than: and.object_size_greater_than,
430 object_size_less_than: and.object_size_less_than,
431 });
432 }
433 if let Some(prefix) = filter.prefix {
434 return Ok(FilterFields {
435 prefix: Some(prefix),
436 ..FilterFields::default()
437 });
438 }
439 if let Some(tag) = filter.tag {
440 return Ok(FilterFields {
441 tags: Some(HashMap::from([(tag.key, tag.value)])),
442 ..FilterFields::default()
443 });
444 }
445 if filter.object_size_greater_than.is_some() || filter.object_size_less_than.is_some() {
446 return Ok(FilterFields {
447 object_size_greater_than: filter.object_size_greater_than,
448 object_size_less_than: filter.object_size_less_than,
449 ..FilterFields::default()
450 });
451 }
452 Ok(FilterFields::default())
453}
454
455fn merge_exclusive<T>(
457 flat: Option<T>,
458 nested: Option<T>,
459 flat_name: &str,
460 nested_name: &str,
461) -> std::result::Result<Option<T>, String> {
462 match (flat, nested) {
463 (Some(_), Some(_)) => Err(format!(
464 "lifecycle rule sets both {flat_name} and {nested_name}; specify one"
465 )),
466 (value, None) | (None, value) => Ok(value),
467 }
468}
469
470fn merge_equal_optional<T: PartialEq>(
472 primary: Option<T>,
473 secondary: Option<T>,
474 field_name: &str,
475) -> std::result::Result<Option<T>, String> {
476 match (primary, secondary) {
477 (Some(primary_value), Some(secondary_value)) if primary_value != secondary_value => {
478 Err(format!("conflicting {field_name} values in lifecycle rule"))
479 }
480 (value, None) | (None, value) => Ok(value),
481 (Some(value), Some(_)) => Ok(Some(value)),
482 }
483}
484
485fn normalize_del_marker_input(
486 input: Option<LifecycleDelMarkerInput>,
487 fallback_days: Option<i32>,
488) -> std::result::Result<Option<NormalizedDelMarkerInput>, String> {
489 match input {
490 None => Ok(None),
491 Some(LifecycleDelMarkerInput::Flag(false)) => Ok(Some(NormalizedDelMarkerInput {
492 enabled: false,
493 configuration: None,
494 })),
495 Some(LifecycleDelMarkerInput::Flag(true)) => fallback_days
496 .map(|days| {
497 Some(NormalizedDelMarkerInput {
498 enabled: true,
499 configuration: Some(LifecycleDelMarkerExpiration { days: Some(days) }),
500 })
501 })
502 .ok_or_else(|| {
503 "DelMarkerExpiration=true requires expiration.days for compatibility input"
504 .to_string()
505 }),
506 Some(LifecycleDelMarkerInput::Configuration(configuration)) => {
507 Ok(Some(NormalizedDelMarkerInput {
508 enabled: true,
509 configuration: Some(configuration),
510 }))
511 }
512 }
513}
514
515fn merge_del_marker_inputs(
516 top_level: Option<NormalizedDelMarkerInput>,
517 nested: Option<NormalizedDelMarkerInput>,
518) -> std::result::Result<Option<LifecycleDelMarkerExpiration>, String> {
519 match (top_level, nested) {
520 (None, None) => Ok(None),
521 (Some(value), None) | (None, Some(value)) => Ok(value.enabled.then(|| {
522 value
523 .configuration
524 .unwrap_or(LifecycleDelMarkerExpiration { days: None })
525 })),
526 (Some(primary), Some(secondary)) => {
527 if primary.enabled != secondary.enabled {
528 return Err(
529 "conflicting DelMarkerExpiration values in lifecycle rule: explicit false cannot be combined with an enabled value".to_string(),
530 );
531 }
532 if !primary.enabled {
533 return Ok(None);
534 }
535
536 let primary_configuration = primary
537 .configuration
538 .unwrap_or(LifecycleDelMarkerExpiration { days: None });
539 let secondary_configuration = secondary
540 .configuration
541 .unwrap_or(LifecycleDelMarkerExpiration { days: None });
542 if primary_configuration.days.is_some()
543 && secondary_configuration.days.is_some()
544 && primary_configuration.days != secondary_configuration.days
545 {
546 return Err("conflicting DelMarkerExpiration values in lifecycle rule".to_string());
547 }
548
549 Ok(Some(LifecycleDelMarkerExpiration {
550 days: primary_configuration.days.or(secondary_configuration.days),
551 }))
552 }
553 }
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558pub enum LifecycleRuleStatus {
559 Enabled,
560 Disabled,
561}
562
563impl fmt::Display for LifecycleRuleStatus {
564 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565 match self {
566 LifecycleRuleStatus::Enabled => write!(f, "Enabled"),
567 LifecycleRuleStatus::Disabled => write!(f, "Disabled"),
568 }
569 }
570}
571
572impl std::str::FromStr for LifecycleRuleStatus {
573 type Err = String;
574
575 fn from_str(s: &str) -> Result<Self, Self::Err> {
576 match s.to_lowercase().as_str() {
577 "enabled" => Ok(LifecycleRuleStatus::Enabled),
578 "disabled" => Ok(LifecycleRuleStatus::Disabled),
579 _ => Err(format!("Invalid lifecycle rule status: {s}")),
580 }
581 }
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(rename_all = "camelCase")]
587pub struct LifecycleExpiration {
588 #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
590 pub days: Option<i32>,
591
592 #[serde(skip_serializing_if = "Option::is_none", alias = "Date")]
594 pub date: Option<String>,
595
596 #[serde(
598 skip_serializing_if = "Option::is_none",
599 alias = "ExpiredObjectAllVersions",
600 alias = "expired_object_all_versions"
601 )]
602 pub expired_object_all_versions: Option<bool>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
607#[serde(rename_all = "camelCase")]
608pub struct LifecycleDelMarkerExpiration {
609 #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
611 pub days: Option<i32>,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize)]
616#[serde(rename_all = "camelCase")]
617pub struct LifecycleTransition {
618 #[serde(skip_serializing_if = "Option::is_none")]
620 pub days: Option<i32>,
621
622 #[serde(skip_serializing_if = "Option::is_none")]
624 pub date: Option<String>,
625
626 pub storage_class: String,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(rename_all = "camelCase")]
633pub struct NoncurrentVersionExpiration {
634 pub noncurrent_days: i32,
636
637 #[serde(skip_serializing_if = "Option::is_none")]
639 pub newer_noncurrent_versions: Option<i32>,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize)]
644#[serde(rename_all = "camelCase")]
645pub struct NoncurrentVersionTransition {
646 pub noncurrent_days: i32,
648
649 pub storage_class: String,
651
652 #[serde(skip_serializing_if = "Option::is_none")]
654 pub newer_noncurrent_versions: Option<i32>,
655}
656
657impl fmt::Display for LifecycleRule {
658 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
659 write!(f, "{} ({})", self.id, self.status)
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 fn test_lifecycle_rule_status_display() {
669 assert_eq!(LifecycleRuleStatus::Enabled.to_string(), "Enabled");
670 assert_eq!(LifecycleRuleStatus::Disabled.to_string(), "Disabled");
671 }
672
673 #[test]
674 fn test_lifecycle_rule_status_from_str() {
675 assert_eq!(
676 "enabled".parse::<LifecycleRuleStatus>().unwrap(),
677 LifecycleRuleStatus::Enabled
678 );
679 assert_eq!(
680 "Disabled".parse::<LifecycleRuleStatus>().unwrap(),
681 LifecycleRuleStatus::Disabled
682 );
683 assert!("invalid".parse::<LifecycleRuleStatus>().is_err());
684 }
685
686 #[test]
687 fn test_lifecycle_rule_serialization() {
688 let rule = LifecycleRule {
689 id: "rule-1".to_string(),
690 status: LifecycleRuleStatus::Enabled,
691 prefix: Some("logs/".to_string()),
692 tags: None,
693 object_size_greater_than: None,
694 object_size_less_than: None,
695 expiration: Some(LifecycleExpiration {
696 days: Some(30),
697 date: None,
698 expired_object_all_versions: None,
699 }),
700 del_marker_expiration: None,
701 transition: None,
702 transitions: Vec::new(),
703 noncurrent_version_expiration: None,
704 noncurrent_version_transition: None,
705 noncurrent_version_transitions: Vec::new(),
706 abort_incomplete_multipart_upload_days: Some(7),
707 expired_object_delete_marker: None,
708 };
709
710 let json = serde_json::to_string(&rule).unwrap();
711 let decoded: LifecycleRule = serde_json::from_str(&json).unwrap();
712 assert_eq!(decoded.id, "rule-1");
713 assert_eq!(decoded.status, LifecycleRuleStatus::Enabled);
714 assert_eq!(decoded.prefix.as_deref(), Some("logs/"));
715 assert_eq!(decoded.expiration.as_ref().unwrap().days, Some(30));
716 assert_eq!(decoded.abort_incomplete_multipart_upload_days, Some(7));
717 }
718
719 #[test]
720 fn test_lifecycle_transition_serialization() {
721 let transition = LifecycleTransition {
722 days: Some(90),
723 date: None,
724 storage_class: "WARM_TIER".to_string(),
725 };
726
727 let json = serde_json::to_string(&transition).unwrap();
728 assert!(json.contains("storageClass"));
729 assert!(json.contains("WARM_TIER"));
730
731 let decoded: LifecycleTransition = serde_json::from_str(&json).unwrap();
732 assert_eq!(decoded.storage_class, "WARM_TIER");
733 }
734
735 #[test]
736 fn test_lifecycle_configuration_serialization() {
737 let config = LifecycleConfiguration {
738 rules: vec![LifecycleRule {
739 id: "expire-old".to_string(),
740 status: LifecycleRuleStatus::Enabled,
741 prefix: None,
742 tags: None,
743 object_size_greater_than: None,
744 object_size_less_than: None,
745 expiration: Some(LifecycleExpiration {
746 days: Some(365),
747 date: None,
748 expired_object_all_versions: None,
749 }),
750 del_marker_expiration: None,
751 transition: None,
752 transitions: Vec::new(),
753 noncurrent_version_expiration: Some(NoncurrentVersionExpiration {
754 noncurrent_days: 30,
755 newer_noncurrent_versions: Some(3),
756 }),
757 noncurrent_version_transition: None,
758 noncurrent_version_transitions: Vec::new(),
759 abort_incomplete_multipart_upload_days: None,
760 expired_object_delete_marker: Some(true),
761 }],
762 };
763
764 let json = serde_json::to_string_pretty(&config).unwrap();
765 let decoded: LifecycleConfiguration = serde_json::from_str(&json).unwrap();
766 assert_eq!(decoded.rules.len(), 1);
767 assert_eq!(decoded.rules[0].id, "expire-old");
768 assert_eq!(
769 decoded.rules[0]
770 .noncurrent_version_expiration
771 .as_ref()
772 .unwrap()
773 .newer_noncurrent_versions,
774 Some(3)
775 );
776 }
777
778 #[test]
779 fn test_noncurrent_version_transition_serialization() {
780 let nvt = NoncurrentVersionTransition {
781 noncurrent_days: 60,
782 storage_class: "COLD_TIER".to_string(),
783 newer_noncurrent_versions: None,
784 };
785
786 let json = serde_json::to_string(&nvt).unwrap();
787 let decoded: NoncurrentVersionTransition = serde_json::from_str(&json).unwrap();
788 assert_eq!(decoded.noncurrent_days, 60);
789 assert_eq!(decoded.storage_class, "COLD_TIER");
790 }
791
792 #[test]
793 fn test_lifecycle_extensions_use_canonical_json_shape() {
794 let config = LifecycleConfiguration {
795 rules: vec![LifecycleRule {
796 id: "all-versions".to_string(),
797 status: LifecycleRuleStatus::Enabled,
798 prefix: Some("test/".to_string()),
799 tags: None,
800 object_size_greater_than: None,
801 object_size_less_than: None,
802 expiration: Some(LifecycleExpiration {
803 days: Some(1),
804 date: None,
805 expired_object_all_versions: Some(true),
806 }),
807 del_marker_expiration: Some(LifecycleDelMarkerExpiration { days: Some(1) }),
808 transition: None,
809 transitions: Vec::new(),
810 noncurrent_version_expiration: None,
811 noncurrent_version_transition: None,
812 noncurrent_version_transitions: Vec::new(),
813 abort_incomplete_multipart_upload_days: None,
814 expired_object_delete_marker: None,
815 }],
816 };
817
818 let value = serde_json::to_value(&config).expect("serialize lifecycle extensions");
819 assert_eq!(
820 value["rules"][0]["expiration"]["expiredObjectAllVersions"],
821 true
822 );
823 assert_eq!(value["rules"][0]["delMarkerExpiration"]["days"], 1);
824 assert!(
825 value["rules"][0]["expiration"]
826 .get("DelMarkerExpiration")
827 .is_none()
828 );
829 let decoded: LifecycleConfiguration =
830 serde_json::from_value(value).expect("canonical lifecycle extensions should parse");
831 assert_eq!(
832 decoded.rules[0]
833 .expiration
834 .as_ref()
835 .and_then(|expiration| expiration.expired_object_all_versions),
836 Some(true)
837 );
838 assert_eq!(
839 decoded.rules[0]
840 .del_marker_expiration
841 .as_ref()
842 .and_then(|expiration| expiration.days),
843 Some(1)
844 );
845 }
846
847 #[test]
848 fn test_lifecycle_extensions_accept_issue_6334_compatibility_shape() {
849 let input = r#"
850 {
851 "rules": [{
852 "id": "rule-delayed-deletion",
853 "status": "Enabled",
854 "prefix": "test/",
855 "expiration": {
856 "ExpiredObjectAllVersions": true,
857 "DelMarkerExpiration": true,
858 "days": 1
859 }
860 }]
861 }
862 "#;
863
864 let config: LifecycleConfiguration =
865 serde_json::from_str(input).expect("issue compatibility shape should parse");
866 let rule = &config.rules[0];
867 assert_eq!(
868 rule.expiration
869 .as_ref()
870 .and_then(|expiration| expiration.expired_object_all_versions),
871 Some(true)
872 );
873 assert_eq!(
874 rule.del_marker_expiration
875 .as_ref()
876 .and_then(|expiration| expiration.days),
877 Some(1)
878 );
879 }
880
881 #[test]
882 fn test_lifecycle_extensions_reject_ambiguous_compatibility_shape() {
883 let input = r#"
884 {
885 "rules": [{
886 "id": "conflicting",
887 "status": "Enabled",
888 "expiration": {"days": 1, "DelMarkerExpiration": true},
889 "delMarkerExpiration": {"days": 2}
890 }]
891 }
892 "#;
893
894 let result = serde_json::from_str::<LifecycleConfiguration>(input);
895 assert!(result.is_err());
896 }
897
898 #[test]
899 fn test_lifecycle_extensions_reject_explicit_false_conflicts() {
900 let inputs = [
901 r#"
902 {
903 "rules": [{
904 "id": "conflicting-false-top-level",
905 "status": "Enabled",
906 "expiration": {"days": 1, "DelMarkerExpiration": true},
907 "delMarkerExpiration": false
908 }]
909 }
910 "#,
911 r#"
912 {
913 "rules": [{
914 "id": "conflicting-false-nested",
915 "status": "Enabled",
916 "expiration": {"days": 1, "DelMarkerExpiration": false},
917 "delMarkerExpiration": {"days": 1}
918 }]
919 }
920 "#,
921 ];
922
923 for input in inputs {
924 assert!(
925 serde_json::from_str::<LifecycleConfiguration>(input).is_err(),
926 "explicit false must not be silently overridden by an enabled representation"
927 );
928 }
929
930 let explicit_false = r#"
931 {
932 "rules": [{
933 "id": "explicit-false",
934 "status": "Enabled",
935 "delMarkerExpiration": false
936 }]
937 }
938 "#;
939 let config: LifecycleConfiguration =
940 serde_json::from_str(explicit_false).expect("standalone false is compatible");
941 assert!(config.rules[0].del_marker_expiration.is_none());
942 }
943
944 #[test]
945 fn test_lifecycle_rule_accepts_additional_actions_and_size_filters() {
946 let input = r#"
947 {
948 "rules": [{
949 "id": "full-rule",
950 "status": "Enabled",
951 "objectSizeGreaterThan": 500,
952 "objectSizeLessThan": 64000,
953 "transition": {"days": 30, "storageClass": "WARM"},
954 "transitions": [{"days": 60, "storageClass": "COLD"}],
955 "noncurrentVersionTransition": {
956 "noncurrentDays": 90,
957 "storageClass": "WARM",
958 "newerNoncurrentVersions": 2
959 },
960 "noncurrentVersionTransitions": [{
961 "noncurrentDays": 180,
962 "storageClass": "COLD",
963 "newerNoncurrentVersions": 1
964 }]
965 }]
966 }
967 "#;
968
969 let config: LifecycleConfiguration =
970 serde_json::from_str(input).expect("full lifecycle rule should parse");
971 let rule = &config.rules[0];
972 assert_eq!(rule.object_size_greater_than, Some(500));
973 assert_eq!(rule.object_size_less_than, Some(64000));
974 assert_eq!(rule.transitions.len(), 1);
975 assert_eq!(rule.noncurrent_version_transitions.len(), 1);
976 assert_eq!(
977 rule.noncurrent_version_transition
978 .as_ref()
979 .and_then(|transition| transition.newer_noncurrent_versions),
980 Some(2)
981 );
982 assert_eq!(
983 rule.noncurrent_version_transitions[0].newer_noncurrent_versions,
984 Some(1)
985 );
986
987 let serialized =
988 serde_json::to_value(&config).expect("full lifecycle rule should serialize");
989 let decoded: LifecycleConfiguration =
990 serde_json::from_value(serialized).expect("full lifecycle rule should round-trip");
991 assert_eq!(decoded.rules[0].transitions.len(), 1);
992 assert_eq!(decoded.rules[0].object_size_less_than, Some(64000));
993 }
994}