1use std::collections::HashSet;
7use std::fs;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::bundle::{list_sessions, validate_session_id};
13use crate::storage::now_ms;
14
15#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase", default)]
18pub struct RetentionPolicy {
19 pub max_age_days: Option<u32>,
22 pub max_sessions: Option<u32>,
24 pub orphan_max_age_days: Option<u32>,
28}
29
30impl RetentionPolicy {
31 pub fn is_empty(&self) -> bool {
32 self.max_age_days.is_none()
33 && self.max_sessions.is_none()
34 && self.orphan_max_age_days.is_none()
35 }
36}
37
38fn effective(v: Option<u32>) -> Option<u32> {
40 v.filter(|&x| x > 0)
41}
42
43fn remove_session_dir(root: &Path, id: &str) -> bool {
46 if !validate_session_id(id) {
47 return false;
48 }
49 fs::remove_dir_all(root.join(id)).is_ok()
50}
51
52pub fn enforce_retention_protect(
64 root: &Path,
65 policy: &RetentionPolicy,
66 protect: &[&str],
67) -> Result<usize, String> {
68 let max_age = effective(policy.max_age_days);
69 let orphan_age = effective(policy.orphan_max_age_days);
70 let max_sessions = effective(policy.max_sessions);
71 if max_age.is_none() && orphan_age.is_none() && max_sessions.is_none() {
72 return Ok(0);
73 }
74 let protected: HashSet<&str> = protect.iter().copied().collect();
75 let mut sessions = list_sessions(root);
76 let now = now_ms();
77 let mut removed = 0usize;
78
79 if let Some(days) = max_age {
81 let cutoff = now - (days as i64) * 86_400_000;
82 sessions.retain(|s| {
83 let Some(id) = s["id"].as_str() else {
84 return true;
85 };
86 if protected.contains(id) {
87 return true;
88 }
89 let overage = matches!(s["endedAt"].as_i64(), Some(t) if t < cutoff);
90 if overage && remove_session_dir(root, id) {
91 removed += 1;
92 return false;
93 }
94 true
95 });
96 }
97
98 if let Some(days) = orphan_age {
100 let cutoff = now - (days as i64) * 86_400_000;
101 sessions.retain(|s| {
102 let Some(id) = s["id"].as_str() else {
103 return true;
104 };
105 if protected.contains(id) {
106 return true;
107 }
108 if s["endedAt"].as_i64().is_some() {
109 return true; }
111 let Some(started) = s["startedAt"].as_i64() else {
112 return true;
113 };
114 if started < cutoff && remove_session_dir(root, id) {
115 removed += 1;
116 return false;
117 }
118 true
119 });
120 }
121
122 if let Some(max) = max_sessions {
124 let mut eligible: Vec<&serde_json::Value> = sessions
125 .iter()
126 .filter(|s| s["id"].as_str().map_or(true, |id| !protected.contains(id)))
127 .collect();
128 eligible.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
129 for s in eligible.iter().skip(max as usize) {
130 if let Some(id) = s["id"].as_str() {
131 if remove_session_dir(root, id) {
132 removed += 1;
133 }
134 }
135 }
136 }
137
138 Ok(removed)
139}
140
141pub fn enforce_retention(root: &Path, policy: &RetentionPolicy) -> Result<usize, String> {
145 enforce_retention_protect(root, policy, &[])
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use serde_json::json;
152 use tempfile::tempdir;
153
154 fn make_session(root: &Path, id: &str, started: i64, ended: Option<i64>) {
155 let dir = root.join(id);
156 fs::create_dir_all(dir.join("segments")).unwrap();
157 let mut s = json!({ "id": id, "startedAt": started });
158 if let Some(e) = ended {
159 s["endedAt"] = json!(e);
160 }
161 fs::write(dir.join("session.json"), s.to_string()).unwrap();
162 }
163
164 #[test]
166 fn enforces_max_age() {
167 let root = tempdir().unwrap();
168 let now = now_ms();
169 make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
170 make_session(root.path(), "900000000003", now - 1000, Some(now - 1000));
171 make_session(root.path(), "900000000004", now - 2000, None); let removed = enforce_retention(
174 root.path(),
175 &RetentionPolicy {
176 max_age_days: Some(30),
177 max_sessions: None,
178 orphan_max_age_days: None,
179 },
180 )
181 .unwrap();
182 assert_eq!(removed, 1);
183 assert!(!root.path().join("900000000002").exists());
184 assert!(root.path().join("900000000003").exists());
185 assert!(root.path().join("900000000004").exists());
186 }
187
188 #[test]
190 fn enforces_max_sessions() {
191 let root = tempdir().unwrap();
192 make_session(root.path(), "1001", 1000, Some(1100));
193 make_session(root.path(), "1002", 2000, Some(2100));
194 make_session(root.path(), "1003", 3000, Some(3100));
195
196 let removed = enforce_retention(
197 root.path(),
198 &RetentionPolicy {
199 max_age_days: None,
200 max_sessions: Some(2),
201 orphan_max_age_days: None,
202 },
203 )
204 .unwrap();
205 assert_eq!(removed, 1);
206 assert!(!root.path().join("1001").exists());
208 assert!(root.path().join("1002").exists());
209 assert!(root.path().join("1003").exists());
210 }
211
212 #[test]
214 fn empty_policy_noop() {
215 let root = tempdir().unwrap();
216 make_session(root.path(), "1001", 1000, Some(1100));
217 let removed = enforce_retention(root.path(), &RetentionPolicy::default()).unwrap();
218 assert_eq!(removed, 0);
219 assert!(root.path().join("1001").exists());
220 }
221
222 #[test]
224 fn protect_skips_in_max_age() {
225 let root = tempdir().unwrap();
226 let now = now_ms();
227 make_session(root.path(), "900000000001", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
228 make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
229
230 let policy = RetentionPolicy {
231 max_age_days: Some(30),
232 max_sessions: None,
233 orphan_max_age_days: None,
234 };
235 let removed = enforce_retention_protect(root.path(), &policy, &["900000000001"]).unwrap();
236 assert_eq!(removed, 1);
237 assert!(root.path().join("900000000001").exists());
238 assert!(!root.path().join("900000000002").exists());
239 }
240
241 #[test]
243 fn protect_skips_in_max_sessions() {
244 let root = tempdir().unwrap();
245 make_session(root.path(), "1001", 1000, Some(1100));
246 make_session(root.path(), "1002", 2000, Some(2100));
247 make_session(root.path(), "1003", 3000, Some(3100));
248 make_session(root.path(), "1004", 4000, Some(4100));
249
250 let policy = RetentionPolicy {
251 max_age_days: None,
252 max_sessions: Some(2),
253 orphan_max_age_days: None,
254 };
255 let removed = enforce_retention_protect(root.path(), &policy, &["1001"]).unwrap();
256 assert_eq!(removed, 1);
258 assert!(root.path().join("1001").exists());
259 assert!(!root.path().join("1002").exists());
260 assert!(root.path().join("1003").exists());
261 assert!(root.path().join("1004").exists());
262 }
263
264 #[test]
266 fn orphan_swept_by_started_at() {
267 let root = tempdir().unwrap();
268 let now = now_ms();
269 make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
270 make_session(root.path(), "800000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
271
272 let policy = RetentionPolicy {
273 max_age_days: None,
274 max_sessions: None,
275 orphan_max_age_days: Some(30),
276 };
277 let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
278 assert_eq!(removed, 1);
279 assert!(!root.path().join("800000000001").exists());
280 assert!(root.path().join("800000000002").exists()); }
282
283 #[test]
285 fn orphan_skips_protected() {
286 let root = tempdir().unwrap();
287 let now = now_ms();
288 make_session(root.path(), "800000000003", now - 86_400_000 * 40, None);
289
290 let policy = RetentionPolicy {
291 max_age_days: None,
292 max_sessions: None,
293 orphan_max_age_days: Some(30),
294 };
295 let removed = enforce_retention_protect(root.path(), &policy, &["800000000003"]).unwrap();
296 assert_eq!(removed, 0);
297 assert!(root.path().join("800000000003").exists());
298 }
299
300 #[test]
302 fn orphan_none_keeps_legacy_semantics() {
303 let root = tempdir().unwrap();
304 let now = now_ms();
305 make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
306
307 let policy = RetentionPolicy {
308 max_age_days: Some(30),
309 max_sessions: None,
310 orphan_max_age_days: None,
311 };
312 let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
313 assert_eq!(removed, 0);
314 assert!(root.path().join("800000000001").exists());
315 }
316
317 #[test]
319 fn zero_values_treated_as_noop() {
320 let root = tempdir().unwrap();
321 let now = now_ms();
322 make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
323 make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
324
325 let policy = RetentionPolicy {
326 max_age_days: Some(0),
327 max_sessions: Some(0),
328 orphan_max_age_days: Some(0),
329 };
330 let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
331 assert_eq!(removed, 0);
332 assert!(root.path().join("900000000002").exists());
333 assert!(root.path().join("800000000001").exists());
334 }
335
336 #[test]
338 fn invalid_session_id_skipped() {
339 let root = tempdir().unwrap();
340 let now = now_ms();
341 let dir = root.path().join("dir-a");
342 fs::create_dir_all(dir.join("segments")).unwrap();
343 fs::write(
344 dir.join("session.json"),
345 json!({
346 "id": "../evil",
347 "startedAt": now - 86_400_000 * 40,
348 "endedAt": now - 86_400_000 * 40
349 })
350 .to_string(),
351 )
352 .unwrap();
353
354 let policy = RetentionPolicy {
355 max_age_days: Some(30),
356 max_sessions: None,
357 orphan_max_age_days: None,
358 };
359 let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
360 assert_eq!(removed, 0);
361 assert!(root.path().join("dir-a").exists());
362 }
363
364 #[test]
366 fn delegate_empty_protect_equivalent() {
367 let root = tempdir().unwrap();
368 let now = now_ms();
369 make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
370
371 let policy = RetentionPolicy {
372 max_age_days: Some(30),
373 max_sessions: None,
374 orphan_max_age_days: None,
375 };
376 let removed = enforce_retention(root.path(), &policy).unwrap();
377 assert_eq!(removed, 1);
378 assert!(!root.path().join("900000000002").exists());
379 }
380}