1use std::time::Duration;
2
3use chrono::{NaiveDate, Utc};
4
5use crate::state::SharedS3State;
6use crate::xml_util::extract_tag;
7
8pub 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 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 let mut keys_to_delete: Vec<String> = Vec::new();
86 let mut keys_to_transition: Vec<(String, String)> = Vec::new();
88
89 for (key, obj) in bucket.objects.iter() {
90 if let Some(ref prefix) = rule.prefix {
92 if !prefix.is_empty() && !key.starts_with(prefix) {
93 continue;
94 }
95 }
96
97 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 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 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 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; }
145 }
146 }
147
148 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 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
176struct 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
197fn 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 let prefix = if let Some(filter_body) = extract_block(rule_body, "Filter") {
211 let filter_prefix = extract_tag(filter_body, "Prefix");
213 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 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 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 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
279fn 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 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 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
307fn parse_date(s: &str) -> Option<NaiveDate> {
309 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
311 return Some(d);
312 }
313 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
315 return Some(dt.date_naive());
316 }
317 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}