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