Skip to main content

rightkit_qa/
storage.rs

1//! Managed QA run storage: shared marker, retention, and cross-language lock protocol.
2use 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    // Workstation default only when its drive exists; hosted CI and other machines
56    // fall back to the runner's temp dir, then the system temp dir.
57    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
183/// Use short internal temp paths on macOS: launchd needs internal storage &
184/// service fallback sockets must fit sockaddr_un even after app temp overrides.
185pub(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    // Bind both sides; a forged path in a managed marker cannot delete arbitrary temp data.
203    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(&current)?.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}