Skip to main content

fakecloud_s3/
lifecycle.rs

1use std::time::Duration;
2
3use chrono::{NaiveDate, Utc};
4
5use crate::state::SharedS3State;
6use crate::xml_util::extract_tag;
7
8/// Background task that processes S3 lifecycle rules.
9///
10/// Every 60 seconds, iterates all buckets with lifecycle configurations,
11/// parses the lifecycle XML, and:
12/// - Deletes objects matching expiration rules (by Days or Date)
13/// - Updates storage class for objects matching transition rules
14pub struct LifecycleProcessor {
15    state: SharedS3State,
16}
17
18impl LifecycleProcessor {
19    pub fn new(state: SharedS3State) -> Self {
20        Self { state }
21    }
22
23    pub async fn run(self) {
24        let mut interval = tokio::time::interval(Duration::from_secs(60));
25
26        loop {
27            interval.tick().await;
28            self.tick();
29        }
30    }
31
32    pub fn tick(&self) {
33        let now = Utc::now();
34        let today = now.date_naive();
35
36        // Collect bucket names, their lifecycle configs, and owning account id
37        let bucket_configs: Vec<(String, String, String)> = {
38            let __mas = self.state.read();
39            __mas
40                .iter()
41                .flat_map(|(acct_id, state)| {
42                    state.buckets.values().filter_map(move |b| {
43                        b.lifecycle_config
44                            .as_ref()
45                            .map(|cfg| (b.name.clone(), cfg.clone(), acct_id.to_string()))
46                    })
47                })
48                .collect()
49        };
50
51        for (bucket_name, config_xml, account_id) in bucket_configs {
52            let rules = match parse_lifecycle_rules(&config_xml) {
53                Some(r) => r,
54                None => continue,
55            };
56
57            for rule in &rules {
58                if rule.status != "Enabled" {
59                    continue;
60                }
61
62                self.process_rule(&account_id, &bucket_name, rule, today);
63            }
64        }
65    }
66
67    fn process_rule(
68        &self,
69        account_id: &str,
70        bucket_name: &str,
71        rule: &LifecycleRule,
72        today: NaiveDate,
73    ) {
74        let mut __mas = self.state.write();
75        let state = match __mas.get_mut(account_id) {
76            Some(s) => s,
77            None => return,
78        };
79        let bucket = match state.buckets.get_mut(bucket_name) {
80            Some(b) => b,
81            None => return,
82        };
83
84        // Collect keys to expire
85        let mut keys_to_delete: Vec<String> = Vec::new();
86        // Collect keys to transition (key, new_storage_class)
87        let mut keys_to_transition: Vec<(String, String)> = Vec::new();
88
89        for (key, obj) in bucket.objects.iter() {
90            // Check prefix filter
91            if let Some(ref prefix) = rule.prefix {
92                if !prefix.is_empty() && !key.starts_with(prefix) {
93                    continue;
94                }
95            }
96
97            // Check tag filter
98            if let Some(ref tag_filter) = rule.tag_filter {
99                let matches = obj
100                    .tags
101                    .get(&tag_filter.key)
102                    .map(|v| v == &tag_filter.value)
103                    .unwrap_or(false);
104                if !matches {
105                    continue;
106                }
107            }
108
109            // Check expiration by Days
110            if let Some(days) = rule.expiration_days {
111                let age = today
112                    .signed_duration_since(obj.last_modified.date_naive())
113                    .num_days();
114                if age >= days as i64 {
115                    keys_to_delete.push(key.clone());
116                    continue;
117                }
118            }
119
120            // Check expiration by Date
121            if let Some(ref date) = rule.expiration_date {
122                if &today >= date {
123                    keys_to_delete.push(key.clone());
124                    continue;
125                }
126            }
127
128            // Check transition by Days
129            for transition in &rule.transitions {
130                let should_transition = if let Some(days) = transition.days {
131                    let age = today
132                        .signed_duration_since(obj.last_modified.date_naive())
133                        .num_days();
134                    age >= days as i64
135                } else if let Some(ref date) = transition.date {
136                    &today >= date
137                } else {
138                    false
139                };
140
141                if should_transition && obj.storage_class != transition.storage_class {
142                    keys_to_transition.push((key.clone(), transition.storage_class.clone()));
143                    break; // Only apply first matching transition
144                }
145            }
146        }
147
148        // Apply deletions
149        if !keys_to_delete.is_empty() {
150            tracing::info!(
151                bucket = %bucket_name,
152                count = keys_to_delete.len(),
153                "S3 lifecycle: expiring objects"
154            );
155            for key in &keys_to_delete {
156                bucket.objects.remove(key);
157            }
158        }
159
160        // Apply transitions
161        if !keys_to_transition.is_empty() {
162            tracing::info!(
163                bucket = %bucket_name,
164                count = keys_to_transition.len(),
165                "S3 lifecycle: transitioning object storage classes"
166            );
167            for (key, new_class) in &keys_to_transition {
168                if let Some(obj) = bucket.objects.get_mut(key) {
169                    obj.storage_class = new_class.clone();
170                }
171            }
172        }
173    }
174}
175
176/// A parsed lifecycle rule.
177struct LifecycleRule {
178    status: String,
179    prefix: Option<String>,
180    tag_filter: Option<TagFilter>,
181    expiration_days: Option<u32>,
182    expiration_date: Option<NaiveDate>,
183    transitions: Vec<Transition>,
184}
185
186struct TagFilter {
187    key: String,
188    value: String,
189}
190
191struct Transition {
192    days: Option<u32>,
193    date: Option<NaiveDate>,
194    storage_class: String,
195}
196
197/// Parse lifecycle configuration XML into rules.
198fn parse_lifecycle_rules(xml: &str) -> Option<Vec<LifecycleRule>> {
199    let mut rules = Vec::new();
200    let mut remaining = xml;
201
202    while let Some(rule_start) = remaining.find("<Rule>") {
203        let after = &remaining[rule_start + 6..];
204        let rule_end = after.find("</Rule>")?;
205        let rule_body = &after[..rule_end];
206
207        let status = extract_tag(rule_body, "Status").unwrap_or_default();
208
209        // Parse prefix — can be at rule level or inside <Filter>
210        let prefix = if let Some(filter_body) = extract_block(rule_body, "Filter") {
211            // Check for <Prefix> inside Filter
212            let filter_prefix = extract_tag(filter_body, "Prefix");
213            // Also check for <And><Prefix> pattern
214            if filter_prefix.is_some() {
215                filter_prefix
216            } else if let Some(and_body) = extract_block(filter_body, "And") {
217                extract_tag(and_body, "Prefix")
218            } else {
219                None
220            }
221        } else {
222            extract_tag(rule_body, "Prefix")
223        };
224
225        // Parse tag filter from <Filter><Tag> or <Filter><And><Tag>
226        let tag_filter = if let Some(filter_body) = extract_block(rule_body, "Filter") {
227            parse_tag_filter(filter_body)
228        } else {
229            None
230        };
231
232        // Parse expiration
233        let (expiration_days, expiration_date) =
234            if let Some(exp_body) = extract_block(rule_body, "Expiration") {
235                let days = extract_tag(exp_body, "Days").and_then(|s| s.parse::<u32>().ok());
236                let date = extract_tag(exp_body, "Date").and_then(|s| parse_date(&s));
237                (days, date)
238            } else {
239                (None, None)
240            };
241
242        // Parse transitions
243        let mut transitions = Vec::new();
244        let mut trans_remaining = rule_body;
245        while let Some(t_start) = trans_remaining.find("<Transition>") {
246            let t_after = &trans_remaining[t_start + 12..];
247            if let Some(t_end) = t_after.find("</Transition>") {
248                let t_body = &t_after[..t_end];
249                let days = extract_tag(t_body, "Days").and_then(|s| s.parse::<u32>().ok());
250                let date = extract_tag(t_body, "Date").and_then(|s| parse_date(&s));
251                let storage_class =
252                    extract_tag(t_body, "StorageClass").unwrap_or_else(|| "GLACIER".to_string());
253                transitions.push(Transition {
254                    days,
255                    date,
256                    storage_class,
257                });
258                trans_remaining = &t_after[t_end + 13..];
259            } else {
260                break;
261            }
262        }
263
264        rules.push(LifecycleRule {
265            status,
266            prefix,
267            tag_filter,
268            expiration_days,
269            expiration_date,
270            transitions,
271        });
272
273        remaining = &after[rule_end + 7..];
274    }
275
276    Some(rules)
277}
278
279/// Extract the body of a block element, e.g. `<Filter>...</Filter>` -> "...".
280fn extract_block<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
281    let open = format!("<{tag}>");
282    let close = format!("</{tag}>");
283    let start = body.find(&open)?;
284    let content_start = start + open.len();
285    let end = body[content_start..].find(&close)?;
286    Some(&body[content_start..content_start + end])
287}
288
289fn parse_tag_filter(filter_body: &str) -> Option<TagFilter> {
290    // Try direct <Tag> inside <Filter>
291    if let Some(tag_body) = extract_block(filter_body, "Tag") {
292        let key = extract_tag(tag_body, "Key")?;
293        let value = extract_tag(tag_body, "Value").unwrap_or_default();
294        return Some(TagFilter { key, value });
295    }
296    // Try <And><Tag> inside <Filter>
297    if let Some(and_body) = extract_block(filter_body, "And") {
298        if let Some(tag_body) = extract_block(and_body, "Tag") {
299            let key = extract_tag(tag_body, "Key")?;
300            let value = extract_tag(tag_body, "Value").unwrap_or_default();
301            return Some(TagFilter { key, value });
302        }
303    }
304    None
305}
306
307/// Parse a date string like "2024-01-01" or "2024-01-01T00:00:00.000Z".
308fn parse_date(s: &str) -> Option<NaiveDate> {
309    // Try YYYY-MM-DD first
310    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
311        return Some(d);
312    }
313    // Try ISO 8601 with time
314    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
315        return Some(dt.date_naive());
316    }
317    // Try with T and Z suffix
318    if let Some(date_part) = s.split('T').next() {
319        if let Ok(d) = NaiveDate::parse_from_str(date_part, "%Y-%m-%d") {
320            return Some(d);
321        }
322    }
323    None
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn parse_expiration_days_rule() {
332        let xml = r#"<LifecycleConfiguration>
333            <Rule>
334                <Filter><Prefix>logs/</Prefix></Filter>
335                <Status>Enabled</Status>
336                <Expiration><Days>30</Days></Expiration>
337            </Rule>
338        </LifecycleConfiguration>"#;
339
340        let rules = parse_lifecycle_rules(xml).unwrap();
341        assert_eq!(rules.len(), 1);
342        assert_eq!(rules[0].status, "Enabled");
343        assert_eq!(rules[0].prefix.as_deref(), Some("logs/"));
344        assert_eq!(rules[0].expiration_days, Some(30));
345    }
346
347    #[test]
348    fn parse_expiration_date_rule() {
349        let xml = r#"<LifecycleConfiguration>
350            <Rule>
351                <Filter><Prefix></Prefix></Filter>
352                <Status>Enabled</Status>
353                <Expiration><Date>2024-06-01</Date></Expiration>
354            </Rule>
355        </LifecycleConfiguration>"#;
356
357        let rules = parse_lifecycle_rules(xml).unwrap();
358        assert_eq!(rules.len(), 1);
359        assert_eq!(
360            rules[0].expiration_date,
361            Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap())
362        );
363    }
364
365    #[test]
366    fn parse_transition_rule() {
367        let xml = r#"<LifecycleConfiguration>
368            <Rule>
369                <Filter><Prefix>archive/</Prefix></Filter>
370                <Status>Enabled</Status>
371                <Transition>
372                    <Days>90</Days>
373                    <StorageClass>GLACIER</StorageClass>
374                </Transition>
375                <Transition>
376                    <Days>365</Days>
377                    <StorageClass>DEEP_ARCHIVE</StorageClass>
378                </Transition>
379            </Rule>
380        </LifecycleConfiguration>"#;
381
382        let rules = parse_lifecycle_rules(xml).unwrap();
383        assert_eq!(rules.len(), 1);
384        assert_eq!(rules[0].transitions.len(), 2);
385        assert_eq!(rules[0].transitions[0].days, Some(90));
386        assert_eq!(rules[0].transitions[0].storage_class, "GLACIER");
387        assert_eq!(rules[0].transitions[1].days, Some(365));
388        assert_eq!(rules[0].transitions[1].storage_class, "DEEP_ARCHIVE");
389    }
390
391    #[test]
392    fn parse_disabled_rule() {
393        let xml = r#"<LifecycleConfiguration>
394            <Rule>
395                <Filter><Prefix></Prefix></Filter>
396                <Status>Disabled</Status>
397                <Expiration><Days>1</Days></Expiration>
398            </Rule>
399        </LifecycleConfiguration>"#;
400
401        let rules = parse_lifecycle_rules(xml).unwrap();
402        assert_eq!(rules.len(), 1);
403        assert_eq!(rules[0].status, "Disabled");
404    }
405
406    #[test]
407    fn parse_tag_filter_rule() {
408        let xml = r#"<LifecycleConfiguration>
409            <Rule>
410                <Filter>
411                    <Tag><Key>env</Key><Value>test</Value></Tag>
412                </Filter>
413                <Status>Enabled</Status>
414                <Expiration><Days>7</Days></Expiration>
415            </Rule>
416        </LifecycleConfiguration>"#;
417
418        let rules = parse_lifecycle_rules(xml).unwrap();
419        assert_eq!(rules.len(), 1);
420        let tag = rules[0].tag_filter.as_ref().unwrap();
421        assert_eq!(tag.key, "env");
422        assert_eq!(tag.value, "test");
423    }
424
425    #[test]
426    fn parse_multiple_rules() {
427        let xml = r#"<LifecycleConfiguration>
428            <Rule>
429                <Filter><Prefix>a/</Prefix></Filter>
430                <Status>Enabled</Status>
431                <Expiration><Days>10</Days></Expiration>
432            </Rule>
433            <Rule>
434                <Filter><Prefix>b/</Prefix></Filter>
435                <Status>Enabled</Status>
436                <Expiration><Days>20</Days></Expiration>
437            </Rule>
438        </LifecycleConfiguration>"#;
439
440        let rules = parse_lifecycle_rules(xml).unwrap();
441        assert_eq!(rules.len(), 2);
442        assert_eq!(rules[0].prefix.as_deref(), Some("a/"));
443        assert_eq!(rules[0].expiration_days, Some(10));
444        assert_eq!(rules[1].prefix.as_deref(), Some("b/"));
445        assert_eq!(rules[1].expiration_days, Some(20));
446    }
447
448    #[test]
449    fn parse_empty_lifecycle_xml_returns_empty() {
450        let xml = "<LifecycleConfiguration></LifecycleConfiguration>";
451        let rules = parse_lifecycle_rules(xml);
452        assert!(rules.is_some());
453        assert!(rules.unwrap().is_empty());
454    }
455
456    #[test]
457    fn parse_rule_with_noncurrent_version_expiration() {
458        let xml = r#"<LifecycleConfiguration>
459            <Rule>
460                <ID>nc-rule</ID>
461                <Status>Enabled</Status>
462                <Prefix>x/</Prefix>
463                <NoncurrentVersionExpiration><NoncurrentDays>30</NoncurrentDays></NoncurrentVersionExpiration>
464            </Rule>
465        </LifecycleConfiguration>"#;
466        let rules = parse_lifecycle_rules(xml).unwrap();
467        assert_eq!(rules.len(), 1);
468    }
469
470    #[test]
471    fn parse_rule_with_abort_incomplete_multipart() {
472        let xml = r#"<LifecycleConfiguration>
473            <Rule>
474                <ID>mp-rule</ID>
475                <Status>Enabled</Status>
476                <Prefix></Prefix>
477                <AbortIncompleteMultipartUpload><DaysAfterInitiation>7</DaysAfterInitiation></AbortIncompleteMultipartUpload>
478            </Rule>
479        </LifecycleConfiguration>"#;
480        let rules = parse_lifecycle_rules(xml).unwrap();
481        assert_eq!(rules.len(), 1);
482    }
483
484    #[test]
485    fn parse_date_valid() {
486        let d = parse_date("2025-01-15T00:00:00Z");
487        assert!(d.is_some());
488    }
489
490    #[test]
491    fn parse_date_invalid_returns_none() {
492        assert!(parse_date("bogus").is_none());
493    }
494
495    #[test]
496    fn extract_block_finds_tag() {
497        let body = "<a><b>content</b></a>";
498        let block = extract_block(body, "b");
499        assert_eq!(block, Some("content"));
500    }
501
502    #[test]
503    fn extract_block_missing_tag_returns_none() {
504        let body = "<a></a>";
505        assert!(extract_block(body, "missing").is_none());
506    }
507}