Skip to main content

supercov_engine/
lifecycle.rs

1//! Crash-safe run publication, recovery and explicit retention.
2//!
3//! Deletion targets are derived from a trusted project root. Large trees are
4//! atomically moved into durable trash; recursive unlinking is a separate,
5//! retryable operation that the CLI can run in a detached child.
6
7use std::{
8    collections::BTreeSet,
9    fs::{self, File, OpenOptions},
10    io::{self, Read, Write},
11    path::{Component, Path, PathBuf},
12    sync::atomic::{AtomicU64, Ordering},
13    time::{Duration, SystemTime, UNIX_EPOCH},
14};
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::run_store::{RunMetadata, valid_run_id};
20
21const TRASH: &str = ".supercov/.trash";
22const INCOMPLETE_LOCK_GRACE: Duration = Duration::from_secs(30);
23static UNIQUE: AtomicU64 = AtomicU64::new(0);
24
25#[derive(Debug)]
26pub enum LifecycleError {
27    Io { path: PathBuf, source: io::Error },
28    InvalidRunId(String),
29    UnsafePath(PathBuf),
30    InvalidState(String),
31    ActiveRun { run_id: String, pid: u32 },
32    LockAcquiring,
33    LockUnavailable,
34    PublicationExists(String),
35    Metadata(serde_json::Error),
36    EvidenceLength { expected: u64, actual: u64 },
37    EvidenceChanged,
38}
39
40impl std::fmt::Display for LifecycleError {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
44            Self::InvalidRunId(id) => write!(formatter, "invalid coverage run ID: {id}"),
45            Self::UnsafePath(path) => {
46                write!(
47                    formatter,
48                    "unsafe Supercov storage path: {}",
49                    path.display()
50                )
51            }
52            Self::InvalidState(reason) => write!(formatter, "invalid run state: {reason}"),
53            Self::ActiveRun { run_id, pid } => write!(
54                formatter,
55                "coverage run {run_id} is already active in this project (pid {pid})"
56            ),
57            Self::LockAcquiring => {
58                write!(
59                    formatter,
60                    "a coverage run is currently acquiring the project lock"
61                )
62            }
63            Self::LockUnavailable => {
64                write!(formatter, "could not acquire the Supercov project lock")
65            }
66            Self::PublicationExists(id) => write!(formatter, "coverage run already exists: {id}"),
67            Self::Metadata(error) => write!(formatter, "invalid run metadata: {error}"),
68            Self::EvidenceLength { expected, actual } => write!(
69                formatter,
70                "evidence length changed before publication: expected {expected}, got {actual}"
71            ),
72            Self::EvidenceChanged => write!(formatter, "evidence changed during publication"),
73        }
74    }
75}
76
77impl std::error::Error for LifecycleError {}
78
79fn io_error(path: &Path, source: io::Error) -> LifecycleError {
80    LifecycleError::Io {
81        path: path.to_owned(),
82        source,
83    }
84}
85
86fn checked_id(id: &str) -> Result<(), LifecycleError> {
87    valid_run_id(id)
88        .then_some(())
89        .ok_or_else(|| LifecycleError::InvalidRunId(id.into()))
90}
91
92fn absolute_root(root: &Path) -> Result<PathBuf, LifecycleError> {
93    if root.is_absolute() {
94        Ok(root.to_owned())
95    } else {
96        std::env::current_dir()
97            .map(|cwd| cwd.join(root))
98            .map_err(|source| io_error(root, source))
99    }
100}
101
102fn lexical_descendant(root: &Path, path: &Path) -> bool {
103    let Ok(local) = path.strip_prefix(root) else {
104        return false;
105    };
106    !local.as_os_str().is_empty()
107        && local
108            .components()
109            .all(|component| matches!(component, Component::Normal(_)))
110}
111
112fn reject_linked_ancestors(
113    root: &Path,
114    path: &Path,
115    include_leaf: bool,
116) -> Result<(), LifecycleError> {
117    let local = path
118        .strip_prefix(root)
119        .map_err(|_| LifecycleError::UnsafePath(path.into()))?;
120    let components = local.components().collect::<Vec<_>>();
121    let through = if include_leaf {
122        components.len()
123    } else {
124        components.len().saturating_sub(1)
125    };
126    let mut current = root.to_owned();
127    for component in components.into_iter().take(through) {
128        let Component::Normal(component) = component else {
129            return Err(LifecycleError::UnsafePath(path.into()));
130        };
131        current.push(component);
132        match fs::symlink_metadata(&current) {
133            Ok(metadata) if metadata.file_type().is_symlink() => {
134                return Err(LifecycleError::UnsafePath(current));
135            }
136            Ok(metadata) if !metadata.file_type().is_dir() => {
137                return Err(LifecycleError::UnsafePath(current));
138            }
139            Ok(_) => {}
140            Err(error) if error.kind() == io::ErrorKind::NotFound => break,
141            Err(source) => return Err(io_error(&current, source)),
142        }
143    }
144    Ok(())
145}
146
147fn owned_workspace_container(root: &Path) -> bool {
148    let container = crate::workspace::workspace_container(root);
149    crate::workspace::owned_workspace_path(&container)
150}
151
152fn unique_name() -> String {
153    let nanos = SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .unwrap_or_default()
156        .as_nanos();
157    format!(
158        "{}-{nanos}-{}",
159        std::process::id(),
160        UNIQUE.fetch_add(1, Ordering::Relaxed)
161    )
162}
163
164/// Make a rename or creation inside `path` durable. On Unix a directory opens
165/// like a file and fsync flushes its entries. On Windows there is nothing to
166/// do and no way to do it: CreateFileW refuses to open a directory without
167/// FILE_FLAG_BACKUP_SEMANTICS and answers ERROR_ACCESS_DENIED, which is how
168/// the first Windows run died -- in the JavaScript frontend's own copy of this
169/// idiom, on the generated node_modules directory it had just created -- and
170/// NTFS journals directory metadata without an explicit sync. Every place
171/// that syncs a directory goes through here so the rule is stated once.
172pub(crate) fn sync_directory_handle(path: &Path) -> io::Result<()> {
173    #[cfg(unix)]
174    {
175        File::open(path).and_then(|directory| directory.sync_all())
176    }
177    #[cfg(not(unix))]
178    {
179        let _ = path;
180        Ok(())
181    }
182}
183
184pub(crate) fn sync_directory(path: &Path) -> Result<(), LifecycleError> {
185    sync_directory_handle(path).map_err(|source| io_error(path, source))
186}
187
188pub(crate) fn atomic_rename(source: &Path, destination: &Path) -> Result<(), LifecycleError> {
189    let source_parent = source
190        .parent()
191        .ok_or_else(|| LifecycleError::UnsafePath(source.into()))?;
192    let destination_parent = destination
193        .parent()
194        .ok_or_else(|| LifecycleError::UnsafePath(destination.into()))?;
195    fs::create_dir_all(destination_parent).map_err(|error| io_error(destination_parent, error))?;
196    fs::rename(source, destination).map_err(|error| io_error(destination, error))?;
197    sync_directory(destination_parent)?;
198    if source_parent != destination_parent {
199        sync_directory(source_parent)?;
200    }
201    Ok(())
202}
203
204pub(crate) fn atomic_write(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
205    let parent = path
206        .parent()
207        .ok_or_else(|| LifecycleError::UnsafePath(path.into()))?;
208    reject_linked_ancestors(root, parent, true)?;
209    fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
210    for _ in 0..16 {
211        let temporary = parent.join(format!(
212            ".{}.{}.tmp",
213            path.file_name()
214                .and_then(|value| value.to_str())
215                .unwrap_or("state"),
216            unique_name()
217        ));
218        let mut file = match OpenOptions::new()
219            .write(true)
220            .create_new(true)
221            .open(&temporary)
222        {
223            Ok(file) => file,
224            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
225            Err(source) => return Err(io_error(&temporary, source)),
226        };
227        if let Err(source) = file.write_all(bytes).and_then(|_| file.sync_all()) {
228            let _ = fs::remove_file(&temporary);
229            return Err(io_error(&temporary, source));
230        }
231        drop(file);
232        if let Err(source) = fs::rename(&temporary, path) {
233            let _ = fs::remove_file(&temporary);
234            return Err(io_error(path, source));
235        }
236        sync_directory(parent)?;
237        return Ok(());
238    }
239    Err(LifecycleError::LockUnavailable)
240}
241
242pub fn remove_stored_tree_deferred(
243    project_root: &Path,
244    target: &Path,
245) -> Result<Option<PathBuf>, LifecycleError> {
246    let root = absolute_root(project_root)?;
247    let target = if target.is_absolute() {
248        target.to_owned()
249    } else {
250        root.join(target)
251    };
252    match fs::symlink_metadata(&target) {
253        Ok(_) => {}
254        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
255        Err(source) => return Err(io_error(&target, source)),
256    }
257    let store = root.join(".supercov");
258    let trash = root.join(TRASH);
259    let in_store = lexical_descendant(&store, &target) && !lexical_descendant(&trash, &target);
260    let container = crate::workspace::workspace_container(&root);
261    let in_workspace = owned_workspace_container(&root)
262        && (target == container || lexical_descendant(&container, &target));
263    if !in_store && !in_workspace {
264        return Err(LifecycleError::UnsafePath(target));
265    }
266    reject_linked_ancestors(if in_store { &store } else { &container }, &target, false)?;
267    reject_linked_ancestors(&root, &trash, true)?;
268    fs::create_dir_all(&trash).map_err(|source| io_error(&trash, source))?;
269    let destination = trash.join(unique_name());
270    atomic_rename(&target, &destination)?;
271    Ok(Some(destination))
272}
273
274/// Retryable trash unlinking. Public commands execute this in a child.
275pub fn sweep_trash(project_root: &Path) -> Result<usize, LifecycleError> {
276    let root = absolute_root(project_root)?;
277    let trash = root.join(TRASH);
278    reject_linked_ancestors(&root, &trash, true)?;
279    let entries = match fs::read_dir(&trash) {
280        Ok(entries) => entries,
281        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
282        Err(source) => return Err(io_error(&trash, source)),
283    };
284    let Some(_lock) = TrashLock::acquire(&trash)? else {
285        return Ok(0);
286    };
287    let mut removed = 0;
288    for entry in entries {
289        let entry = entry.map_err(|source| io_error(&trash, source))?;
290        let path = entry.path();
291        if entry.file_name() == ".deleter.lock" {
292            continue;
293        }
294        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
295        if metadata.file_type().is_dir() {
296            fs::remove_dir_all(&path).map_err(|source| io_error(&path, source))?;
297        } else {
298            fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
299        }
300        removed += 1;
301    }
302    Ok(removed)
303}
304
305struct TrashLock {
306    path: PathBuf,
307}
308
309impl TrashLock {
310    fn acquire(trash: &Path) -> Result<Option<Self>, LifecycleError> {
311        let path = trash.join(".deleter.lock");
312        for _ in 0..2 {
313            match OpenOptions::new().write(true).create_new(true).open(&path) {
314                Ok(mut file) => {
315                    write!(file, "{}", std::process::id())
316                        .and_then(|_| file.sync_all())
317                        .map_err(|source| io_error(&path, source))?;
318                    return Ok(Some(Self { path }));
319                }
320                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
321                    let owner = fs::read_to_string(&path)
322                        .ok()
323                        .and_then(|value| value.parse::<u32>().ok());
324                    if owner.is_some_and(process_exists) {
325                        return Ok(None);
326                    }
327                    if owner.is_none() {
328                        let age = fs::metadata(&path)
329                            .and_then(|metadata| metadata.modified())
330                            .ok()
331                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
332                            .unwrap_or_default();
333                        if age < INCOMPLETE_LOCK_GRACE {
334                            return Ok(None);
335                        }
336                    }
337                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
338                }
339                Err(source) => return Err(io_error(&path, source)),
340            }
341        }
342        Ok(None)
343    }
344}
345
346impl Drop for TrashLock {
347    fn drop(&mut self) {
348        let _ = fs::remove_file(&self.path);
349    }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "lowercase")]
354pub enum RunStateStatus {
355    Preparing,
356    Building,
357    Testing,
358    Publishing,
359    Complete,
360    Failed,
361    Interrupted,
362    Abandoned,
363}
364
365impl RunStateStatus {
366    pub fn terminal(self) -> bool {
367        matches!(
368            self,
369            Self::Complete | Self::Failed | Self::Interrupted | Self::Abandoned
370        )
371    }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct RunState {
377    pub id: String,
378    pub pid: u32,
379    pub root: String,
380    pub workspace: String,
381    pub started_at: String,
382    pub updated_at: String,
383    pub status: RunStateStatus,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub signal: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub error: Option<String>,
388}
389
390fn state_path(root: &Path, id: &str) -> PathBuf {
391    root.join(".supercov/work").join(id).join("state.json")
392}
393
394pub fn write_run_state(root: &Path, state: &RunState) -> Result<(), LifecycleError> {
395    checked_id(&state.id)?;
396    let mut bytes = serde_json::to_vec_pretty(state).map_err(LifecycleError::Metadata)?;
397    bytes.push(b'\n');
398    atomic_write(root, &state_path(root, &state.id), &bytes)
399}
400
401fn read_state(root: &Path, id: &str) -> Result<Option<RunState>, LifecycleError> {
402    checked_id(id)?;
403    let path = state_path(root, id);
404    let bytes = match fs::read(&path) {
405        Ok(bytes) => bytes,
406        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
407        Err(source) => return Err(io_error(&path, source)),
408    };
409    serde_json::from_slice(&bytes)
410        .map(Some)
411        .map_err(|error| LifecycleError::InvalidState(error.to_string()))
412}
413
414pub fn update_run_state(
415    root: &Path,
416    id: &str,
417    status: RunStateStatus,
418    updated_at: &str,
419    error: Option<String>,
420) -> Result<RunState, LifecycleError> {
421    let mut state = read_state(root, id)?
422        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
423    state.status = status;
424    state.updated_at = updated_at.into();
425    state.error = error;
426    write_run_state(root, &state)?;
427    Ok(state)
428}
429
430pub fn interrupt_run_state(
431    root: &Path,
432    id: &str,
433    updated_at: &str,
434    signal: &str,
435) -> Result<RunState, LifecycleError> {
436    let mut state = read_state(root, id)?
437        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
438    state.status = RunStateStatus::Interrupted;
439    state.updated_at = updated_at.into();
440    state.signal = Some(signal.into());
441    state.error = Some(format!("Interrupted by {signal}"));
442    write_run_state(root, &state)?;
443    Ok(state)
444}
445
446#[cfg(unix)]
447fn process_exists(pid: u32) -> bool {
448    if pid == 0 || pid > libc::pid_t::MAX as u32 {
449        return false;
450    }
451    // SAFETY: signal zero only checks existence/permission.
452    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
453    result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
454}
455
456#[cfg(not(unix))]
457fn process_exists(pid: u32) -> bool {
458    // Replaced by the Windows Job-object strategy before Windows GA.
459    pid == std::process::id()
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase", deny_unknown_fields)]
464struct LockOwner {
465    run_id: String,
466    pid: u32,
467    started_at: String,
468}
469
470pub struct ProjectLock {
471    root: PathBuf,
472    path: PathBuf,
473    owner: LockOwner,
474    released: bool,
475}
476
477impl ProjectLock {
478    pub fn acquire(root: &Path, run_id: &str, started_at: &str) -> Result<Self, LifecycleError> {
479        checked_id(run_id)?;
480        let path = root.join(".supercov/locks/active.json");
481        let parent = path.parent().expect("lock parent");
482        reject_linked_ancestors(root, parent, true)?;
483        fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
484        let owner = LockOwner {
485            run_id: run_id.into(),
486            pid: std::process::id(),
487            started_at: started_at.into(),
488        };
489        let mut payload = serde_json::to_vec_pretty(&owner).map_err(LifecycleError::Metadata)?;
490        payload.push(b'\n');
491        for _ in 0..2 {
492            match OpenOptions::new().write(true).create_new(true).open(&path) {
493                Ok(mut file) => {
494                    file.write_all(&payload)
495                        .and_then(|_| file.sync_all())
496                        .map_err(|source| io_error(&path, source))?;
497                    sync_directory(parent)?;
498                    return Ok(Self {
499                        root: root.to_owned(),
500                        path,
501                        owner,
502                        released: false,
503                    });
504                }
505                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
506                    let existing = fs::read(&path)
507                        .ok()
508                        .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok());
509                    if let Some(existing) = existing {
510                        if process_exists(existing.pid) {
511                            return Err(LifecycleError::ActiveRun {
512                                run_id: existing.run_id,
513                                pid: existing.pid,
514                            });
515                        }
516                    } else {
517                        let age = fs::metadata(&path)
518                            .and_then(|metadata| metadata.modified())
519                            .ok()
520                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
521                            .unwrap_or_default();
522                        if age < INCOMPLETE_LOCK_GRACE {
523                            return Err(LifecycleError::LockAcquiring);
524                        }
525                    }
526                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
527                }
528                Err(source) => return Err(io_error(&path, source)),
529            }
530        }
531        Err(LifecycleError::LockUnavailable)
532    }
533
534    pub fn release(&mut self) -> Result<(), LifecycleError> {
535        if self.released {
536            return Ok(());
537        }
538        self.released = true;
539        let owned = fs::read(&self.path)
540            .ok()
541            .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok())
542            .is_some_and(|owner| owner == self.owner);
543        if owned {
544            fs::remove_file(&self.path).map_err(|source| io_error(&self.path, source))?;
545        }
546        Ok(())
547    }
548
549    pub(crate) fn protects(&self, root: &Path) -> bool {
550        !self.released && self.root == root
551    }
552}
553
554impl Drop for ProjectLock {
555    fn drop(&mut self) {
556        let _ = self.release();
557    }
558}
559
560fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, LifecycleError> {
561    let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?;
562    if !metadata.file_type().is_file() {
563        return Err(LifecycleError::UnsafePath(source.into()));
564    }
565    let mut input = File::open(source).map_err(|error| io_error(source, error))?;
566    let mut output = OpenOptions::new()
567        .write(true)
568        .create_new(true)
569        .open(destination)
570        .map_err(|error| io_error(destination, error))?;
571    let copied = io::copy(&mut input, &mut output).map_err(|error| io_error(destination, error))?;
572    output
573        .sync_all()
574        .map_err(|error| io_error(destination, error))?;
575    Ok(copied)
576}
577
578fn file_sha256(path: &Path) -> Result<[u8; 32], LifecycleError> {
579    let metadata = fs::symlink_metadata(path).map_err(|source| io_error(path, source))?;
580    if !metadata.file_type().is_file() {
581        return Err(LifecycleError::UnsafePath(path.into()));
582    }
583    let mut file = File::open(path).map_err(|source| io_error(path, source))?;
584    let mut hash = Sha256::new();
585    let mut buffer = [0_u8; 128 * 1024];
586    loop {
587        let read = file
588            .read(&mut buffer)
589            .map_err(|source| io_error(path, source))?;
590        if read == 0 {
591            break;
592        }
593        hash.update(&buffer[..read]);
594    }
595    Ok(hash.finalize().into())
596}
597
598/// Publish both immutable run files with one final directory rename.
599pub fn publish_run(
600    root: &Path,
601    metadata: &RunMetadata,
602    evidence_source: &Path,
603) -> Result<PathBuf, LifecycleError> {
604    publish_run_with_fault(root, metadata, evidence_source, None)
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub(crate) enum RunPublicationFault {
609    FinalRename,
610}
611
612pub(crate) fn publish_run_with_fault(
613    root: &Path,
614    metadata: &RunMetadata,
615    evidence_source: &Path,
616    fault: Option<RunPublicationFault>,
617) -> Result<PathBuf, LifecycleError> {
618    checked_id(&metadata.id)?;
619    let destination = root.join(".supercov/runs").join(&metadata.id);
620    reject_linked_ancestors(root, &destination, false)?;
621    if fs::symlink_metadata(&destination).is_ok() {
622        return Err(LifecycleError::PublicationExists(metadata.id.clone()));
623    }
624    let staging = root
625        .join(".supercov/work")
626        .join(&metadata.id)
627        .join("run-publication");
628    reject_linked_ancestors(root, &staging, true)?;
629    if fs::symlink_metadata(&staging).is_ok() {
630        remove_stored_tree_deferred(root, &staging)?;
631    }
632    let evidence_sha256 = file_sha256(evidence_source)?;
633    fs::create_dir_all(&staging).map_err(|source| io_error(&staging, source))?;
634    let copied = copy_regular_file(evidence_source, &staging.join("evidence.raw.gz"))?;
635    if copied != metadata.raw_evidence.compressed_bytes {
636        remove_stored_tree_deferred(root, &staging)?;
637        return Err(LifecycleError::EvidenceLength {
638            expected: metadata.raw_evidence.compressed_bytes,
639            actual: copied,
640        });
641    }
642    if file_sha256(evidence_source)? != evidence_sha256
643        || file_sha256(&staging.join("evidence.raw.gz"))? != evidence_sha256
644    {
645        remove_stored_tree_deferred(root, &staging)?;
646        return Err(LifecycleError::EvidenceChanged);
647    }
648    let mut json = serde_json::to_vec_pretty(metadata).map_err(LifecycleError::Metadata)?;
649    json.push(b'\n');
650    atomic_write(root, &staging.join("run.json"), &json)?;
651    sync_directory(&staging)?;
652    let runs = destination.parent().expect("runs parent");
653    fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
654    if fault == Some(RunPublicationFault::FinalRename) {
655        let error = io_error(
656            &destination,
657            io::Error::new(
658                io::ErrorKind::PermissionDenied,
659                "injected final run publication rename failure",
660            ),
661        );
662        let _ = remove_stored_tree_deferred(root, &staging);
663        return Err(error);
664    }
665    fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
666    sync_directory(runs)?;
667    Ok(destination)
668}
669
670fn published_run(root: &Path, id: &str) -> bool {
671    let directory = root.join(".supercov/runs").join(id);
672    let metadata = fs::read(directory.join("run.json"))
673        .ok()
674        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
675    metadata
676        .as_ref()
677        .and_then(|value| value.get("id"))
678        .and_then(|value| value.as_str())
679        == Some(id)
680        && fs::symlink_metadata(directory.join("evidence.raw.gz"))
681            .is_ok_and(|metadata| metadata.file_type().is_file())
682}
683
684pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
685    checked_id(id)?;
686    if !published_run(root, id) {
687        return Ok(false);
688    }
689    remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
690    remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
691    Ok(true)
692}
693
694fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
695    let root = path
696        .ancestors()
697        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
698        .and_then(Path::parent)
699        .unwrap_or(path);
700    reject_linked_ancestors(root, path, true)?;
701    let entries = match fs::read_dir(path) {
702        Ok(entries) => entries,
703        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
704        Err(source) => return Err(io_error(path, source)),
705    };
706    let mut names = Vec::new();
707    for entry in entries {
708        let entry = entry.map_err(|source| io_error(path, source))?;
709        let file_type = entry
710            .file_type()
711            .map_err(|source| io_error(&entry.path(), source))?;
712        if file_type.is_symlink() {
713            return Err(LifecycleError::UnsafePath(entry.path()));
714        }
715        if !file_type.is_dir() {
716            continue;
717        }
718        let name = entry
719            .file_name()
720            .into_string()
721            .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
722        checked_id(&name)?;
723        names.push(name);
724    }
725    names.sort();
726    Ok(names)
727}
728
729pub fn recover_abandoned_runs(
730    root: &Path,
731    updated_at: &str,
732) -> Result<Vec<String>, LifecycleError> {
733    let mut recovered = Vec::new();
734    for id in child_directories(&root.join(".supercov/work"))? {
735        let Some(state) = read_state(root, &id)? else {
736            continue;
737        };
738        if state.status.terminal() {
739            finalize_published_run(root, &id)?;
740            continue;
741        }
742        if process_exists(state.pid) {
743            continue;
744        }
745        let workspace_name = root.file_name().unwrap_or_default();
746        remove_stored_tree_deferred(
747            root,
748            &root.join(".supercov/work").join(&id).join(workspace_name),
749        )?;
750        remove_stored_tree_deferred(
751            root,
752            &root
753                .join(".supercov/work")
754                .join(&id)
755                .join("run-publication"),
756        )?;
757        if !finalize_published_run(root, &id)? {
758            remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
759            update_run_state(
760                root,
761                &id,
762                RunStateStatus::Abandoned,
763                updated_at,
764                Some(format!(
765                    "Recovered after process {} exited without cleanup",
766                    state.pid
767                )),
768            )?;
769            remove_stored_tree_deferred(root, &root.join(".supercov/work").join(&id))?;
770        }
771        recovered.push(id);
772    }
773    recovered.sort();
774    Ok(recovered)
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778pub struct CleanupOptions {
779    pub keep: usize,
780    pub dry_run: bool,
781}
782
783#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
784#[serde(rename_all = "camelCase")]
785pub struct CleanupResult {
786    pub removed_runs: Vec<String>,
787    pub removed_workspaces: Vec<String>,
788    pub removed_evidence: Vec<String>,
789    pub removed_build_cache: bool,
790}
791
792pub fn cleanup_storage_locked(
793    root: &Path,
794    options: CleanupOptions,
795    remove_build_cache: bool,
796) -> Result<CleanupResult, LifecycleError> {
797    let runs_root = root.join(".supercov/runs");
798    let work_root = root.join(".supercov/work");
799    let evidence_root = root.join(".supercov/evidence");
800    let published = child_directories(&runs_root)?;
801    let work = child_directories(&work_root)?;
802    let evidence = child_directories(&evidence_root)?;
803    let mut ids = published
804        .iter()
805        .chain(&work)
806        .chain(&evidence)
807        .cloned()
808        .collect::<BTreeSet<_>>()
809        .into_iter()
810        .collect::<Vec<_>>();
811    ids.sort_by(|left, right| right.cmp(left));
812    let mut active = BTreeSet::new();
813    for id in &ids {
814        if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
815            active.insert(id.clone());
816        }
817    }
818    let retained = published
819        .iter()
820        .rev()
821        .filter(|id| !active.contains(*id))
822        .take(options.keep)
823        .cloned()
824        .collect::<BTreeSet<_>>();
825    let mut result = CleanupResult {
826        removed_runs: Vec::new(),
827        removed_workspaces: Vec::new(),
828        removed_evidence: Vec::new(),
829        removed_build_cache: false,
830    };
831    for id in ids {
832        if active.contains(&id) {
833            continue;
834        }
835        let has_run = published.contains(&id);
836        let remove_history = has_run && !retained.contains(&id);
837        if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
838        {
839            result.removed_workspaces.push(id.clone());
840            if !options.dry_run {
841                remove_stored_tree_deferred(root, &work_root.join(&id))?;
842            }
843        }
844        if evidence.contains(&id) && (!has_run || remove_history) {
845            result.removed_evidence.push(id.clone());
846            if !options.dry_run {
847                remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
848            }
849        }
850        if remove_history {
851            result.removed_runs.push(id.clone());
852            if !options.dry_run {
853                remove_stored_tree_deferred(root, &runs_root.join(&id))?;
854            }
855        }
856    }
857    let container = crate::workspace::workspace_container(root);
858    let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
859    let mut caches = Vec::new();
860    let mut removed_cargo_cache = false;
861    if remove_build_cache && active.is_empty() {
862        if owned_workspace_container(root) {
863            caches.push(container);
864        }
865        caches.extend(
866            legacy
867                .into_iter()
868                .filter(|path| fs::symlink_metadata(path).is_ok()),
869        );
870        removed_cargo_cache = crate::workspace::clean_cargo_workspace(root, options.dry_run)
871            .map_err(|error| {
872                LifecycleError::InvalidState(format!(
873                    "could not clean the owned Cargo workspace: {error}"
874                ))
875            })?;
876    }
877    result.removed_build_cache = removed_cargo_cache || !caches.is_empty();
878    if !options.dry_run {
879        for cache in caches {
880            remove_stored_tree_deferred(root, &cache)?;
881        }
882    }
883    Ok(result)
884}
885
886fn cleanup_storage(
887    root: &Path,
888    options: CleanupOptions,
889    remove_build_cache: bool,
890    updated_at: &str,
891) -> Result<CleanupResult, LifecycleError> {
892    let operation = if remove_build_cache {
893        "clean"
894    } else {
895        "retention"
896    };
897    let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
898    let mut lock = ProjectLock::acquire(root, &lock_id, updated_at)?;
899    recover_abandoned_runs(root, updated_at)?;
900    let result = cleanup_storage_locked(root, options, remove_build_cache);
901    lock.release()?;
902    result
903}
904
905pub fn clean_storage(
906    root: &Path,
907    options: CleanupOptions,
908    updated_at: &str,
909) -> Result<CleanupResult, LifecycleError> {
910    cleanup_storage(root, options, true, updated_at)
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
917
918    fn project() -> PathBuf {
919        let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
920        fs::create_dir_all(root.join("src")).unwrap();
921        fs::write(root.join("src/index.js"), "user source").unwrap();
922        root
923    }
924
925    fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
926        RunState {
927            id: id.into(),
928            pid,
929            root: root.display().to_string(),
930            workspace: root.join("dist").display().to_string(),
931            started_at: "start".into(),
932            updated_at: "update".into(),
933            status,
934            signal: None,
935            error: None,
936        }
937    }
938
939    fn metadata(id: &str, bytes: u64) -> RunMetadata {
940        RunMetadata {
941            id: id.into(),
942            started_at: "2026-01-01T00:00:00Z".into(),
943            duration_ms: 1.0,
944            command: vec!["test".into()],
945            test_exit_code: Some(0),
946            integrity: RunIntegrity {
947                schema_version: 2,
948                instrumenter_version: "rust".into(),
949                git: None,
950                fingerprint: RunFingerprint {
951                    algorithm: "sha256".into(),
952                    source: "0".repeat(64),
953                    tests: "0".repeat(64),
954                    dependencies: "0".repeat(64),
955                    configuration: "0".repeat(64),
956                    instrumenter: "0".repeat(64),
957                    execution: "0".repeat(64),
958                    combined: "0".repeat(64),
959                    source_files: 1,
960                    test_files: 1,
961                },
962                stale: None,
963                stale_reasons: None,
964            },
965            raw_evidence: RawEvidenceMetadata {
966                schema_version: 2,
967                format: "supercov-evidence-archive".into(),
968                file: "evidence.raw.gz".into(),
969                files: 1,
970                uncompressed_bytes: bytes,
971                compressed_bytes: bytes,
972            },
973            isolated_build: None,
974            instrumented_build_cache: None,
975            timings: None,
976            merged: None,
977            parents: None,
978        }
979    }
980
981    #[test]
982    fn defers_only_owned_storage_and_sweeps_without_touching_source() {
983        let root = project();
984        let owned = root.join(".supercov/evidence/run");
985        fs::create_dir_all(&owned).unwrap();
986        fs::write(owned.join("hit"), "hit").unwrap();
987        let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
988        assert!(!owned.exists());
989        assert!(trash.exists());
990        assert!(matches!(
991            remove_stored_tree_deferred(&root, &root.join("src")),
992            Err(LifecycleError::UnsafePath(_))
993        ));
994        assert_eq!(sweep_trash(&root).unwrap(), 1);
995        assert!(root.join("src/index.js").exists());
996        fs::remove_dir_all(root).unwrap();
997    }
998
999    #[cfg(unix)]
1000    #[test]
1001    fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
1002        use std::os::unix::fs::symlink;
1003
1004        let root = project();
1005        let outside = project();
1006        fs::create_dir_all(root.join(".supercov")).unwrap();
1007        fs::create_dir_all(outside.join("run")).unwrap();
1008        fs::write(outside.join("run/user.txt"), "user").unwrap();
1009        symlink(&outside, root.join(".supercov/evidence")).unwrap();
1010        assert!(matches!(
1011            remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
1012            Err(LifecycleError::UnsafePath(_))
1013        ));
1014        assert_eq!(
1015            fs::read_to_string(outside.join("run/user.txt")).unwrap(),
1016            "user"
1017        );
1018        fs::remove_dir_all(root).unwrap();
1019        fs::remove_dir_all(outside).unwrap();
1020    }
1021
1022    #[test]
1023    fn publishes_both_required_files_with_one_visible_rename() {
1024        let root = project();
1025        let id = "2026-01-01T00-00-00-000Z";
1026        let evidence = root.join("evidence.gz");
1027        fs::write(&evidence, b"evidence").unwrap();
1028        let published = publish_run(&root, &metadata(id, 8), &evidence).unwrap();
1029        assert!(published.join("run.json").is_file());
1030        assert_eq!(
1031            fs::read(published.join("evidence.raw.gz")).unwrap(),
1032            b"evidence"
1033        );
1034        assert!(matches!(
1035            publish_run(&root, &metadata(id, 8), &evidence),
1036            Err(LifecycleError::PublicationExists(_))
1037        ));
1038        fs::remove_dir_all(root).unwrap();
1039    }
1040
1041    #[test]
1042    fn final_rename_failure_exposes_no_run_and_removes_staging() {
1043        let root = project();
1044        let id = "2026-01-01T00-00-00-000Z";
1045        let evidence = root.join("evidence.gz");
1046        fs::write(&evidence, b"evidence").unwrap();
1047        let error = publish_run_with_fault(
1048            &root,
1049            &metadata(id, 8),
1050            &evidence,
1051            Some(RunPublicationFault::FinalRename),
1052        )
1053        .unwrap_err();
1054        assert!(
1055            error
1056                .to_string()
1057                .contains("injected final run publication rename failure")
1058        );
1059        assert!(!root.join(".supercov/runs").join(id).exists());
1060        assert!(
1061            !root
1062                .join(".supercov/work")
1063                .join(id)
1064                .join("run-publication")
1065                .exists()
1066        );
1067        sweep_trash(&root).unwrap();
1068        fs::remove_dir_all(root).unwrap();
1069    }
1070
1071    #[test]
1072    fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1073        let root = project();
1074        let dead = "2026-01-01T00-00-00-000Z";
1075        let published = "2026-01-02T00-00-00-000Z";
1076        for id in [dead, published] {
1077            fs::create_dir_all(
1078                root.join(".supercov/work")
1079                    .join(id)
1080                    .join(root.file_name().unwrap()),
1081            )
1082            .unwrap();
1083            fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1084            write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1085        }
1086        let evidence = root.join("published.gz");
1087        fs::write(&evidence, b"evidence").unwrap();
1088        publish_run(&root, &metadata(published, 8), &evidence).unwrap();
1089        assert_eq!(
1090            recover_abandoned_runs(&root, "recovered").unwrap(),
1091            [dead, published]
1092        );
1093        assert!(!root.join(".supercov/work").join(dead).exists());
1094        assert!(!root.join(".supercov/work").join(published).exists());
1095        assert!(root.join(".supercov/runs").join(published).exists());
1096        assert!(root.join("src/index.js").exists());
1097        sweep_trash(&root).unwrap();
1098        fs::remove_dir_all(root).unwrap();
1099    }
1100
1101    #[test]
1102    fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1103        let root = project();
1104        let ids = [
1105            "2026-01-01T00-00-00-000Z",
1106            "2026-01-02T00-00-00-000Z",
1107            "2026-01-03T00-00-00-000Z",
1108        ];
1109        for id in ids {
1110            fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1111            write_run_state(
1112                &root,
1113                &state(&root, id, RunStateStatus::Complete, std::process::id()),
1114            )
1115            .unwrap();
1116        }
1117        let active = "2025-12-31T00-00-00-000Z";
1118        write_run_state(
1119            &root,
1120            &state(&root, active, RunStateStatus::Testing, std::process::id()),
1121        )
1122        .unwrap();
1123        let preview = cleanup_storage_locked(
1124            &root,
1125            CleanupOptions {
1126                keep: 1,
1127                dry_run: true,
1128            },
1129            false,
1130        )
1131        .unwrap();
1132        assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1133        assert!(
1134            ids.iter()
1135                .all(|id| root.join(".supercov/runs").join(id).exists())
1136        );
1137        let result = cleanup_storage_locked(
1138            &root,
1139            CleanupOptions {
1140                keep: 1,
1141                dry_run: false,
1142            },
1143            false,
1144        )
1145        .unwrap();
1146        assert_eq!(result, preview);
1147        assert!(root.join(".supercov/work").join(active).exists());
1148        assert!(root.join(".supercov/runs").join(ids[2]).exists());
1149        sweep_trash(&root).unwrap();
1150        fs::remove_dir_all(root).unwrap();
1151    }
1152
1153    #[test]
1154    fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
1155        let root = project();
1156        let container = root.join(".supercov/workspaces");
1157        fs::create_dir_all(container.join("workspace/project")).unwrap();
1158        fs::write(
1159            container.join(".supercov-workspace-store"),
1160            b"Supercov instrumented workspace. Safe to delete.\n",
1161        )
1162        .unwrap();
1163        fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
1164        let mut preparation = ProjectLock::acquire(&root, "prepare", "start").unwrap();
1165        crate::workspace::prepare_cargo_cached_workspace(&root, &preparation).unwrap();
1166        let cargo_container = crate::workspace::cargo_workspace_container(&root).unwrap();
1167        preparation.release().unwrap();
1168
1169        let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
1170        assert!(matches!(
1171            clean_storage(
1172                &root,
1173                CleanupOptions {
1174                    keep: 0,
1175                    dry_run: false
1176                },
1177                "now"
1178            ),
1179            Err(LifecycleError::ActiveRun { .. })
1180        ));
1181        assert!(container.exists());
1182        assert!(cargo_container.exists());
1183        active.release().unwrap();
1184
1185        let cleaned = clean_storage(
1186            &root,
1187            CleanupOptions {
1188                keep: 0,
1189                dry_run: false,
1190            },
1191            "now",
1192        )
1193        .unwrap();
1194        assert!(cleaned.removed_build_cache);
1195        assert!(!container.exists());
1196        assert!(!cargo_container.exists());
1197        assert!(!root.join(".supercov/cache").exists());
1198        sweep_trash(&root).unwrap();
1199        fs::remove_dir_all(root).unwrap();
1200    }
1201}