1use crate::util::{err, new_id, now_ms, Result};
3use serde::{Deserialize, Serialize};
4use std::fs::{self, File, OpenOptions};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9pub const RUN_MARKER: &str = ".rightkit-run.json";
10pub const DEFAULT_KEEP_COUNT: usize = 5;
11pub const DEFAULT_YOUNG_MS: u128 = 24 * 60 * 60 * 1000;
12pub const DEFAULT_MAX_BYTES: u64 = 20_000_000_000;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct RunMarker {
17 pub schema_version: u8,
18 pub app: String,
19 pub run_id: String,
20 pub created_at: u128,
21 pub owner_pid: u32,
22 pub lease_until: u128,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub completed_at: Option<u128>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub app_root: Option<PathBuf>,
27}
28
29#[derive(Debug, Clone)]
30pub struct ManagedRun {
31 pub root: PathBuf,
32 pub marker: RunMarker,
33}
34
35#[derive(Debug, Default, Clone)]
36pub struct RetentionReport {
37 pub removed: Vec<PathBuf>,
38 pub retained: Vec<(PathBuf, String)>,
39 pub total_bytes: u64,
40 pub cap_bytes: u64,
41 pub over_cap_bytes: u64,
42 pub blocked_bytes: u64,
43 pub unknown_bytes: u64,
44}
45
46pub fn managed_root(explicit: Option<&Path>) -> PathBuf {
47 if let Some(path) = explicit.filter(|p| !p.as_os_str().is_empty()) {
48 return path.to_path_buf();
49 }
50 if let Ok(path) = std::env::var("RIGHTKIT_MANAGED_ROOT") {
51 if !path.trim().is_empty() {
52 return PathBuf::from(path);
53 }
54 }
55 let (workstation, volume) = if cfg!(windows) {
58 (
59 PathBuf::from(r"D:\.rightkit-managed"),
60 PathBuf::from(r"D:\"),
61 )
62 } else {
63 (
64 PathBuf::from("/Volumes/D/.rightkit-managed"),
65 PathBuf::from("/Volumes/D"),
66 )
67 };
68 if volume.is_dir() {
69 return workstation;
70 }
71 let temp = std::env::var_os("RUNNER_TEMP")
72 .map(PathBuf::from)
73 .filter(|p| p.is_dir())
74 .unwrap_or_else(std::env::temp_dir);
75 temp.join("rightkit-managed")
76}
77
78pub fn run_root(root: &Path, app: &str, run_id: &str) -> Result<PathBuf> {
79 let app = safe_key(app, "app")?;
80 let run_id = safe_key(run_id, "run id")?;
81 let runs = root.join("runs").join(&app);
82 let result = runs.join(&run_id);
83 if !is_inside(&runs, &result) {
84 return err("QA run path escapes managed root");
85 }
86 Ok(result)
87}
88
89pub(crate) fn prepare_app_root(root: &Path, app: &str) -> Result<PathBuf> {
90 let root = managed_root(Some(root));
91 let app = safe_key(app, "app")?;
92 ensure_dir(&root)?;
93 ensure_dir(&root.join("runs"))?;
94 let app_root = root.join("runs").join(app);
95 ensure_dir(&app_root)?;
96 Ok(app_root)
97}
98
99pub fn create(
100 root: Option<&Path>,
101 app: &str,
102 run_id: Option<&str>,
103 lease: Duration,
104) -> Result<ManagedRun> {
105 let root = managed_root(root);
106 let app = safe_key(app, "app")?;
107 let id = match run_id {
108 Some(id) => safe_key(id, "run id")?,
109 None => new_id(),
110 };
111 let app_root = root.join("runs").join(&app);
112 with_lock(&app_root, || {
113 ensure_dir(&root)?;
114 ensure_dir(&root.join("runs"))?;
115 ensure_dir(&app_root)?;
116 let mut report = RetentionReport::default();
117 prune_unlocked(
118 &app_root,
119 &app,
120 DEFAULT_MAX_BYTES,
121 DEFAULT_KEEP_COUNT,
122 DEFAULT_YOUNG_MS,
123 &[],
124 &mut report,
125 )?;
126 if report.over_cap_bytes > 0 {
127 return err(format!(
128 "QA retention cap is blocked by {} bytes of protected or unknown data",
129 report.over_cap_bytes
130 ));
131 }
132 let run = run_root(&root, &app, &id)?;
133 if run.exists() {
134 return err(format!("QA run already exists: {}", run.display()));
135 }
136 fs::create_dir_all(run.join("data"))?;
137 fs::create_dir_all(run.join("evidence"))?;
138 let now = now_ms();
139 let marker = RunMarker {
140 schema_version: 1,
141 app: app.clone(),
142 run_id: id.clone(),
143 created_at: now,
144 owner_pid: std::process::id(),
145 lease_until: now.saturating_add(lease.as_millis().max(1)),
146 completed_at: None,
147 app_root: None,
148 };
149 write_marker(&run, &marker)?;
150 Ok(ManagedRun { root: run, marker })
151 })
152}
153
154pub fn finish(run: &ManagedRun) -> Result<RunMarker> {
155 let mut marker = read_marker(&run.root.join(RUN_MARKER))
156 .ok_or_else(|| crate::util::Error("QA run marker is missing or malformed".into()))?;
157 if let Some(home) = owned_app_root(&run.root, &marker)? {
158 for name in ["evidence", "captures"] {
159 let target = if name == "evidence" {
160 run.root.join("evidence/app")
161 } else {
162 run.root.join(name)
163 };
164 copy_tree(&home.join(name), &target)?;
165 }
166 for entry in fs::read_dir(home)? {
167 let entry = entry?;
168 if entry.file_name().to_string_lossy().ends_with(".log") {
169 copy_tree(
170 &entry.path(),
171 &run.root.join("evidence").join(entry.file_name()),
172 )?;
173 }
174 }
175 }
176 marker.owner_pid = 0;
177 marker.lease_until = 0;
178 marker.completed_at = Some(now_ms());
179 write_marker(&run.root, &marker)?;
180 Ok(marker)
181}
182
183pub(crate) fn app_temp_dir() -> PathBuf {
186 #[cfg(target_os = "macos")]
187 {
188 PathBuf::from("/private/tmp")
189 }
190 #[cfg(not(target_os = "macos"))]
191 std::env::temp_dir()
192}
193
194pub(crate) fn create_app_root(run: &mut ManagedRun) -> Result<PathBuf> {
195 let parent = app_temp_dir().join("rightkit-qa");
196 ensure_dir(&parent)?;
197 let parent = fs::canonicalize(parent)?;
198 let label: String = run.marker.app.chars().take(16).collect();
199 let home = parent.join(format!("{}-{}", label, &new_id().replace('-', "")[..16]));
200 fs::create_dir(&home)?;
201 run.marker.app_root = Some(home.clone());
202 fs::write(
204 home.join(".rightkit-app.json"),
205 serde_json::to_vec(&serde_json::json!({
206 "runRoot": fs::canonicalize(&run.root)?, "app": run.marker.app,
207 "runId": run.marker.run_id,
208 }))?,
209 )?;
210 write_marker(&run.root, &run.marker)?;
211 Ok(home)
212}
213
214fn owned_app_root(root: &Path, marker: &RunMarker) -> Result<Option<PathBuf>> {
215 let Some(home) = &marker.app_root else {
216 return Ok(None);
217 };
218 if !home.exists() {
219 return Ok(None);
220 }
221 let parent = home
222 .parent()
223 .ok_or_else(|| crate::util::Error("invalid QA app temp root".into()))?;
224 let temp = parent
225 .parent()
226 .ok_or_else(|| crate::util::Error("invalid QA app temp root".into()))?;
227 let allowed_temp = temp == fs::canonicalize(app_temp_dir())?
228 || (cfg!(target_os = "macos")
229 && (temp == Path::new("/private/tmp")
230 || (temp.starts_with("/private/var/folders")
231 && temp.file_name().is_some_and(|n| n == "T"))));
232 if parent.file_name().is_none_or(|n| n != "rightkit-qa")
233 || !allowed_temp
234 || fs::canonicalize(home)? != *home
235 || !safe_dir(home)
236 {
237 return err("invalid QA app temp root");
238 }
239 let binding: serde_json::Value =
240 serde_json::from_slice(&fs::read(home.join(".rightkit-app.json"))?)?;
241 if binding
242 != serde_json::json!({"runRoot": fs::canonicalize(root)?, "app": marker.app,
243 "runId": marker.run_id})
244 {
245 return err("QA app temp ownership mismatch");
246 }
247 Ok(Some(home.clone()))
248}
249
250pub(crate) fn remove_app_root(root: &Path, marker: &RunMarker) -> Result<()> {
251 if let Some(home) = owned_app_root(root, marker)? {
252 fs::remove_dir_all(home)?;
253 }
254 Ok(())
255}
256
257pub(crate) fn copy_tree(source: &Path, target: &Path) -> Result<()> {
258 let meta = fs::symlink_metadata(source)?;
259 if meta.file_type().is_symlink() {
260 return err("symlink in QA app evidence");
261 }
262 if target
263 .symlink_metadata()
264 .is_ok_and(|m| m.file_type().is_symlink())
265 {
266 return err("symlink in managed QA evidence");
267 }
268 if meta.is_dir() {
269 ensure_dir(target)?;
270 for entry in fs::read_dir(source)? {
271 let entry = entry?;
272 copy_tree(&entry.path(), &target.join(entry.file_name()))?;
273 }
274 } else if meta.is_file() {
275 ensure_dir(
276 target
277 .parent()
278 .ok_or_else(|| crate::util::Error("missing evidence parent".into()))?,
279 )?;
280 fs::copy(source, target)?;
281 } else {
282 return err("unsupported QA app evidence file");
283 }
284 Ok(())
285}
286
287pub fn prune(root: &Path, app: &str, max_bytes: Option<u64>) -> Result<RetentionReport> {
288 prune_with_keep(root, app, max_bytes, &[])
289}
290
291pub fn prune_with_keep(
292 root: &Path,
293 app: &str,
294 max_bytes: Option<u64>,
295 keep_run_ids: &[String],
296) -> Result<RetentionReport> {
297 prune_with_policy(
298 root,
299 app,
300 max_bytes,
301 keep_run_ids,
302 Duration::from_millis(DEFAULT_YOUNG_MS as u64),
303 )
304}
305
306pub fn prune_with_policy(
307 root: &Path,
308 app: &str,
309 max_bytes: Option<u64>,
310 keep_run_ids: &[String],
311 young_for: Duration,
312) -> Result<RetentionReport> {
313 prune_with_policy_count(
314 root,
315 app,
316 max_bytes,
317 keep_run_ids,
318 young_for,
319 DEFAULT_KEEP_COUNT,
320 )
321}
322
323pub fn prune_with_age_override(
324 root: &Path,
325 app: &str,
326 keep_run_ids: &[String],
327 young_for: Duration,
328) -> Result<RetentionReport> {
329 prune_with_policy_count(root, app, Some(0), keep_run_ids, young_for, 0)
330}
331
332fn prune_with_policy_count(
333 root: &Path,
334 app: &str,
335 max_bytes: Option<u64>,
336 keep_run_ids: &[String],
337 young_for: Duration,
338 keep_count: usize,
339) -> Result<RetentionReport> {
340 let app = safe_key(app, "app")?;
341 let app_root = managed_root(Some(root)).join("runs").join(&app);
342 let initial = RetentionReport {
343 cap_bytes: max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
344 ..Default::default()
345 };
346 if !safe_dir(&app_root) {
347 return Ok(initial);
348 }
349 with_lock(&app_root, || {
350 let mut report = RetentionReport::default();
351 prune_unlocked(
352 &app_root,
353 &app,
354 max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
355 keep_count,
356 young_for.as_millis(),
357 keep_run_ids,
358 &mut report,
359 )?;
360 Ok(report)
361 })
362}
363
364fn prune_unlocked(
365 app_root: &Path,
366 app: &str,
367 cap: u64,
368 keep_count: usize,
369 young_ms: u128,
370 keep_run_ids: &[String],
371 report: &mut RetentionReport,
372) -> Result<()> {
373 let now = now_ms();
374 report.cap_bytes = cap;
375 let mut runs = Vec::new();
376 let entries = match fs::read_dir(app_root) {
377 Ok(x) => x,
378 Err(_) => return Ok(()),
379 };
380 for entry in entries.flatten() {
381 let ft = match entry.file_type() {
382 Ok(x) => x,
383 Err(_) => continue,
384 };
385 if ft.is_symlink() {
386 report
387 .retained
388 .push((entry.path(), "symlink skipped".into()));
389 continue;
390 }
391 if !ft.is_dir() || entry.file_name().to_string_lossy().starts_with('.') {
392 continue;
393 }
394 let dir = entry.path();
395 if !is_inside(app_root, &dir) {
396 continue;
397 }
398 let Some(marker) = read_marker(&dir.join(RUN_MARKER)) else {
399 if let Some(bytes) = directory_bytes(&dir) {
400 report.unknown_bytes = report.unknown_bytes.saturating_add(bytes);
401 }
402 report
403 .retained
404 .push((dir, "unknown or malformed run metadata".into()));
405 continue;
406 };
407 if marker.app != app || marker.run_id != entry.file_name().to_string_lossy() {
408 if let Some(bytes) = directory_bytes(&dir) {
409 report.unknown_bytes = report.unknown_bytes.saturating_add(bytes);
410 }
411 report
412 .retained
413 .push((dir, "run marker identity mismatch".into()));
414 continue;
415 }
416 let Some(bytes) = directory_bytes(&dir) else {
417 report
418 .retained
419 .push((dir, "symlink or unreadable content skipped".into()));
420 continue;
421 };
422 let active =
423 (marker.owner_pid > 0 && pid_live(marker.owner_pid)) || marker.lease_until > now;
424 runs.push((dir, marker, bytes, active));
425 }
426 runs.sort_by(|a, b| {
427 b.1.created_at
428 .cmp(&a.1.created_at)
429 .then_with(|| a.1.run_id.cmp(&b.1.run_id))
430 });
431 let count_protected: std::collections::HashSet<String> = runs
432 .iter()
433 .take(keep_count)
434 .map(|x| x.1.run_id.clone())
435 .collect();
436 let protected: std::collections::HashSet<String> = runs
437 .iter()
438 .filter(|x| now.saturating_sub(x.1.created_at) < young_ms || x.3)
439 .map(|x| x.1.run_id.clone())
440 .collect();
441 let caller_protected: std::collections::HashSet<&str> =
442 keep_run_ids.iter().map(String::as_str).collect();
443 let protected: std::collections::HashSet<String> = protected
444 .into_iter()
445 .chain(caller_protected.iter().map(|x| (*x).to_string()))
446 .collect();
447 let mut total: u64 = runs.iter().map(|x| x.2).sum();
448 let mut old: Vec<_> = runs
449 .iter()
450 .filter(|x| {
451 !protected.contains(&x.1.run_id)
452 && (total > cap || !count_protected.contains(&x.1.run_id))
453 })
454 .collect();
455 old.sort_by(|a, b| {
456 a.1.created_at
457 .cmp(&b.1.created_at)
458 .then_with(|| a.1.run_id.cmp(&b.1.run_id))
459 });
460 let mut removed = std::collections::HashSet::new();
461 for item in old {
462 if total <= cap && count_protected.contains(&item.1.run_id) {
463 continue;
464 }
465 if !safe_dir(&item.0) {
466 continue;
467 }
468 match remove_app_root(&item.0, &item.1)
469 .and_then(|()| fs::remove_dir_all(&item.0).map_err(Into::into))
470 {
471 Ok(()) => {
472 report.removed.push(item.0.clone());
473 removed.insert(item.1.run_id.clone());
474 total = total.saturating_sub(item.2);
475 }
476 Err(e) => report.retained.push((item.0.clone(), e.to_string())),
477 }
478 }
479 report.blocked_bytes = runs
480 .iter()
481 .filter(|x| {
482 !removed.contains(&x.1.run_id)
483 && (protected.contains(&x.1.run_id)
484 || (total <= cap && count_protected.contains(&x.1.run_id)))
485 })
486 .map(|x| x.2)
487 .sum();
488 report.total_bytes = total.saturating_add(report.unknown_bytes);
489 report.over_cap_bytes = report.total_bytes.saturating_sub(report.cap_bytes);
490 for item in runs.iter().filter(|x| {
491 !removed.contains(&x.1.run_id)
492 && (protected.contains(&x.1.run_id)
493 || (total <= cap && count_protected.contains(&x.1.run_id)))
494 }) {
495 report.retained.push((
496 item.0.clone(),
497 if item.3 {
498 "active run".into()
499 } else if now.saturating_sub(item.1.created_at) < young_ms {
500 "younger than retention window".into()
501 } else {
502 "newest protected run".into()
503 },
504 ));
505 }
506 Ok(())
507}
508
509fn with_lock<T>(app_root: &Path, body: impl FnOnce() -> Result<T>) -> Result<T> {
510 ensure_dir(app_root)?;
511 let lock = app_root.join(".retention-lock");
512 let owner = lock.join("owner.json");
513 let deadline = std::time::Instant::now() + Duration::from_secs(5);
514 loop {
515 match fs::create_dir(&lock) {
516 Ok(()) => {
517 let mut file = File::create(&owner)?;
518 write!(
519 file,
520 "{{\"pid\":{},\"createdAt\":{}}}",
521 std::process::id(),
522 now_ms()
523 )?;
524 break;
525 }
526 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
527 let pid = fs::read_to_string(&owner)
528 .ok()
529 .and_then(|x| serde_json::from_str::<serde_json::Value>(&x).ok())
530 .and_then(|x| x.get("pid").and_then(|v| v.as_u64()))
531 .and_then(|x| u32::try_from(x).ok());
532 if let Some(pid) = pid.filter(|p| *p > 0) {
533 if !pid_live(pid) {
534 let _ = fs::remove_dir_all(&lock);
535 continue;
536 }
537 }
538 if std::time::Instant::now() >= deadline {
539 return err(format!("QA retention lock is held: {}", lock.display()));
540 }
541 std::thread::sleep(Duration::from_millis(20));
542 }
543 Err(e) => return Err(e.into()),
544 }
545 }
546 let result = body();
547 let _ = fs::remove_dir_all(lock);
548 result
549}
550
551fn write_marker(root: &Path, marker: &RunMarker) -> Result<()> {
552 let target = root.join(RUN_MARKER);
553 let temp = root.join(format!(".{RUN_MARKER}.{}.tmp", std::process::id()));
554 let mut file = OpenOptions::new()
555 .write(true)
556 .create_new(true)
557 .open(&temp)?;
558 file.write_all(serde_json::to_string(marker)?.as_bytes())?;
559 file.write_all(b"\n")?;
560 file.sync_all()?;
561 fs::rename(temp, target)?;
562 Ok(())
563}
564
565pub(crate) fn read_marker(path: &Path) -> Option<RunMarker> {
566 let marker: RunMarker = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
567 if marker.schema_version != 1
568 || safe_key(&marker.app, "app").is_err()
569 || safe_key(&marker.run_id, "run id").is_err()
570 || marker.created_at == 0
571 {
572 return None;
573 }
574 Some(marker)
575}
576
577pub(crate) fn marker_active(marker: &RunMarker, now: u128) -> bool {
578 (marker.owner_pid > 0 && pid_live(marker.owner_pid)) || marker.lease_until > now
579}
580
581fn directory_bytes(root: &Path) -> Option<u64> {
582 let mut total = 0u64;
583 fn walk(dir: &Path, total: &mut u64) -> bool {
584 let entries = match fs::read_dir(dir) {
585 Ok(x) => x,
586 Err(_) => return false,
587 };
588 for e in entries.flatten() {
589 let ft = match e.file_type() {
590 Ok(x) => x,
591 Err(_) => return false,
592 };
593 if ft.is_symlink() {
594 return false;
595 }
596 if ft.is_dir() {
597 if !walk(&e.path(), total) {
598 return false;
599 }
600 } else if ft.is_file() {
601 let Some(size) = e.metadata().ok().map(|m| m.len()) else {
602 return false;
603 };
604 let Some(next) = total.checked_add(size) else {
605 return false;
606 };
607 *total = next;
608 } else {
609 return false;
610 }
611 }
612 true
613 }
614 walk(root, &mut total).then_some(total)
615}
616
617fn ensure_dir(dir: &Path) -> Result<()> {
618 let mut current = PathBuf::new();
619 for component in dir.components() {
620 current.push(component.as_os_str());
621 if current.exists() && fs::symlink_metadata(¤t)?.file_type().is_symlink() {
622 return err(format!(
623 "refusing symlink in managed path: {}",
624 current.display()
625 ));
626 }
627 }
628 fs::create_dir_all(dir)?;
629 Ok(())
630}
631fn safe_dir(dir: &Path) -> bool {
632 fs::symlink_metadata(dir)
633 .map(|m| m.is_dir() && !m.file_type().is_symlink())
634 .unwrap_or(false)
635}
636fn safe_key(value: &str, name: &str) -> Result<String> {
637 if !value.is_empty()
638 && value
639 .chars()
640 .next()
641 .is_some_and(|c| c.is_ascii_alphanumeric())
642 && value
643 .chars()
644 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
645 {
646 Ok(value.to_string())
647 } else {
648 err(format!("unsafe QA {name}: {value}"))
649 }
650}
651fn is_inside(root: &Path, candidate: &Path) -> bool {
652 candidate
653 .strip_prefix(root)
654 .map(|r| {
655 !r.as_os_str().is_empty()
656 && !r
657 .components()
658 .any(|c| matches!(c, std::path::Component::ParentDir))
659 })
660 .unwrap_or(false)
661}
662fn pid_live(pid: u32) -> bool {
663 if pid == 0 {
664 return false;
665 }
666 #[cfg(unix)]
667 {
668 let result = unsafe { libc::kill(pid as i32, 0) };
669 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
670 }
671 #[cfg(windows)]
672 {
673 use std::os::windows::process::CommandExt;
674 let output = std::process::Command::new("tasklist")
675 .args(["/FI", &format!("PID eq {pid}")])
676 .creation_flags(0x0800_0000)
677 .output();
678 match output {
679 Ok(x) if x.status.success() => {
680 let wanted = pid.to_string();
681 !String::from_utf8_lossy(&x.stdout)
682 .lines()
683 .skip(1)
684 .all(|line| line.split_whitespace().next() != Some(wanted.as_str()))
685 }
686 _ => true,
687 }
688 }
689 #[cfg(not(any(unix, windows)))]
690 {
691 true
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use std::io::Write;
699
700 fn temp_root(label: &str) -> PathBuf {
701 let root = std::env::temp_dir().join(format!(
702 "rkqa-storage-{label}-{}-{}",
703 std::process::id(),
704 new_id()
705 ));
706 fs::create_dir_all(&root).unwrap();
707 root
708 }
709
710 fn age(run: &ManagedRun) {
711 age_at(run, 1);
712 }
713
714 fn age_at(run: &ManagedRun, created_at: u128) {
715 let mut marker = read_marker(&run.root.join(RUN_MARKER)).unwrap();
716 marker.created_at = created_at;
717 marker.owner_pid = 0;
718 marker.lease_until = 0;
719 write_marker(&run.root, &marker).unwrap();
720 }
721
722 #[test]
723 fn newest_five_are_kept_under_byte_cap() {
724 let root = temp_root("count");
725 let mut runs = Vec::new();
726 for i in 0..7 {
727 let run = create(
728 Some(&root),
729 "count",
730 Some(&format!("run-{i}")),
731 Duration::from_secs(1),
732 )
733 .unwrap();
734 runs.push(run);
735 }
736 for (i, run) in runs.iter().enumerate() {
737 finish(run).unwrap();
738 age_at(run, 100 + i as u128);
739 }
740 let report = prune(&root, "count", Some(1024 * 1024)).unwrap();
741 assert_eq!(report.removed.len(), 2);
742 assert_eq!(report.over_cap_bytes, 0);
743 assert!(root.join("runs/count/run-5").exists());
744 assert!(root.join("runs/count/run-6").exists());
745 let _ = fs::remove_dir_all(root);
746 }
747
748 #[test]
749 fn young_run_is_protected_when_cap_requires_old_eviction() {
750 let root = temp_root("young");
751 let old = create(Some(&root), "young", Some("old"), Duration::from_secs(1)).unwrap();
752 let young = create(Some(&root), "young", Some("young"), Duration::from_secs(1)).unwrap();
753 finish(&old).unwrap();
754 finish(&young).unwrap();
755 age(&old);
756 let report = prune(&root, "young", Some(0)).unwrap();
757 assert!(report.removed.iter().any(|p| p.ends_with("old")));
758 assert!(root.join("runs/young/young").exists());
759 assert!(report.over_cap_bytes > 0);
760 let _ = fs::remove_dir_all(root);
761 }
762
763 #[test]
764 fn live_pid_and_future_lease_are_protected() {
765 let root = temp_root("active");
766 let live = create(Some(&root), "active", Some("live"), Duration::from_secs(1)).unwrap();
767 let lease = create(
768 Some(&root),
769 "active",
770 Some("lease"),
771 Duration::from_secs(3600),
772 )
773 .unwrap();
774 let old = create(Some(&root), "active", Some("old"), Duration::from_secs(1)).unwrap();
775 finish(&lease).unwrap();
776 let mut lease_marker = read_marker(&lease.root.join(RUN_MARKER)).unwrap();
777 lease_marker.created_at = 1;
778 lease_marker.lease_until = now_ms() + 3600 * 1000;
779 write_marker(&lease.root, &lease_marker).unwrap();
780 let mut live_marker = read_marker(&live.root.join(RUN_MARKER)).unwrap();
781 live_marker.created_at = 1;
782 write_marker(&live.root, &live_marker).unwrap();
783 finish(&old).unwrap();
784 age(&old);
785 let report = prune(&root, "active", Some(0)).unwrap();
786 assert!(report.removed.iter().any(|p| p.ends_with("old")));
787 assert!(root.join("runs/active/live").exists());
788 assert!(root.join("runs/active/lease").exists());
789 let _ = fs::remove_dir_all(root);
790 }
791
792 #[test]
793 fn malformed_and_symlink_roots_are_retained_and_reported() {
794 let root = temp_root("unknown");
795 let app_root = prepare_app_root(&root, "unknown").unwrap();
796 let malformed = app_root.join("bad");
797 fs::create_dir_all(&malformed).unwrap();
798 fs::write(malformed.join("payload"), b"unknown").unwrap();
799 #[cfg(unix)]
800 std::os::unix::fs::symlink(&malformed, app_root.join("link")).unwrap();
801 let report = prune(&root, "unknown", Some(0)).unwrap();
802 assert!(report.removed.is_empty());
803 assert!(report.unknown_bytes >= 7);
804 assert!(report.retained.iter().any(|(p, _)| p.ends_with("bad")));
805 #[cfg(unix)]
806 assert!(report
807 .retained
808 .iter()
809 .any(|(p, reason)| p.ends_with("link") && reason.contains("symlink")));
810 let _ = fs::remove_dir_all(root);
811 }
812
813 #[test]
814 fn caller_keep_id_blocks_eviction_and_reports_overage() {
815 let root = temp_root("keep");
816 let keep = create(Some(&root), "keep", Some("keep-me"), Duration::from_secs(1)).unwrap();
817 let remove = create(
818 Some(&root),
819 "keep",
820 Some("remove-me"),
821 Duration::from_secs(1),
822 )
823 .unwrap();
824 finish(&keep).unwrap();
825 finish(&remove).unwrap();
826 age(&keep);
827 age(&remove);
828 let report = prune_with_keep(&root, "keep", Some(0), &["keep-me".into()]).unwrap();
829 assert!(report.removed.iter().any(|p| p.ends_with("remove-me")));
830 assert!(root.join("runs/keep/keep-me").exists());
831 assert!(report.over_cap_bytes > 0);
832 let _ = fs::remove_dir_all(root);
833 }
834
835 #[test]
836 fn create_refuses_when_protected_data_exceeds_cap() {
837 let root = temp_root("blocked");
838 let run = create(
839 Some(&root),
840 "blocked",
841 Some("live"),
842 Duration::from_secs(3600),
843 )
844 .unwrap();
845 let mut file = File::create(run.root.join("large")).unwrap();
846 file.set_len(DEFAULT_MAX_BYTES + 1).unwrap();
847 file.flush().unwrap();
848 let result = create(Some(&root), "blocked", Some("next"), Duration::from_secs(1));
849 assert!(result.is_err());
850 assert!(!root.join("runs/blocked/next").exists());
851 let _ = fs::remove_dir_all(root);
852 }
853}