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 immutable run evidence and its initial assertion map/state together.
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    if let Err(reason) = crate::assertion_store::prepare_publication(root, &staging, metadata) {
652        let _ = remove_stored_tree_deferred(root, &staging);
653        return Err(LifecycleError::InvalidState(format!(
654            "assertion map publication: {reason}"
655        )));
656    }
657    sync_directory(&staging)?;
658    let runs = destination.parent().expect("runs parent");
659    fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
660    if fault == Some(RunPublicationFault::FinalRename) {
661        let error = io_error(
662            &destination,
663            io::Error::new(
664                io::ErrorKind::PermissionDenied,
665                "injected final run publication rename failure",
666            ),
667        );
668        let _ = remove_stored_tree_deferred(root, &staging);
669        return Err(error);
670    }
671    fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
672    sync_directory(runs)?;
673    Ok(destination)
674}
675
676fn published_run(root: &Path, id: &str) -> bool {
677    let directory = root.join(".supercov/runs").join(id);
678    let metadata = fs::read(directory.join("run.json"))
679        .ok()
680        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
681    metadata
682        .as_ref()
683        .and_then(|value| value.get("id"))
684        .and_then(|value| value.as_str())
685        == Some(id)
686        && fs::symlink_metadata(directory.join("evidence.raw.gz"))
687            .is_ok_and(|metadata| metadata.file_type().is_file())
688}
689
690pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
691    checked_id(id)?;
692    if !published_run(root, id) {
693        return Ok(false);
694    }
695    remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
696    remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
697    Ok(true)
698}
699
700fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
701    let root = path
702        .ancestors()
703        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
704        .and_then(Path::parent)
705        .unwrap_or(path);
706    reject_linked_ancestors(root, path, true)?;
707    let entries = match fs::read_dir(path) {
708        Ok(entries) => entries,
709        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
710        Err(source) => return Err(io_error(path, source)),
711    };
712    let mut names = Vec::new();
713    for entry in entries {
714        let entry = entry.map_err(|source| io_error(path, source))?;
715        let file_type = entry
716            .file_type()
717            .map_err(|source| io_error(&entry.path(), source))?;
718        if file_type.is_symlink() {
719            return Err(LifecycleError::UnsafePath(entry.path()));
720        }
721        if !file_type.is_dir() {
722            continue;
723        }
724        let name = entry
725            .file_name()
726            .into_string()
727            .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
728        checked_id(&name)?;
729        names.push(name);
730    }
731    names.sort();
732    Ok(names)
733}
734
735pub fn recover_abandoned_runs(
736    root: &Path,
737    updated_at: &str,
738) -> Result<Vec<String>, LifecycleError> {
739    let mut recovered = Vec::new();
740    for id in child_directories(&root.join(".supercov/work"))? {
741        let Some(state) = read_state(root, &id)? else {
742            continue;
743        };
744        if state.status.terminal() {
745            finalize_published_run(root, &id)?;
746            continue;
747        }
748        if process_exists(state.pid) {
749            continue;
750        }
751        let workspace_name = root.file_name().unwrap_or_default();
752        remove_stored_tree_deferred(
753            root,
754            &root.join(".supercov/work").join(&id).join(workspace_name),
755        )?;
756        remove_stored_tree_deferred(
757            root,
758            &root
759                .join(".supercov/work")
760                .join(&id)
761                .join("run-publication"),
762        )?;
763        if !finalize_published_run(root, &id)? {
764            remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
765            update_run_state(
766                root,
767                &id,
768                RunStateStatus::Abandoned,
769                updated_at,
770                Some(format!(
771                    "Recovered after process {} exited without cleanup",
772                    state.pid
773                )),
774            )?;
775            remove_stored_tree_deferred(root, &root.join(".supercov/work").join(&id))?;
776        }
777        recovered.push(id);
778    }
779    recovered.sort();
780    Ok(recovered)
781}
782
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub struct CleanupOptions {
785    pub keep: usize,
786    pub dry_run: bool,
787}
788
789#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
790#[serde(rename_all = "camelCase")]
791pub struct CleanupResult {
792    pub removed_runs: Vec<String>,
793    pub removed_workspaces: Vec<String>,
794    pub removed_evidence: Vec<String>,
795    pub removed_build_cache: bool,
796}
797
798pub fn cleanup_storage_locked(
799    root: &Path,
800    options: CleanupOptions,
801    remove_build_cache: bool,
802) -> Result<CleanupResult, LifecycleError> {
803    let runs_root = root.join(".supercov/runs");
804    let work_root = root.join(".supercov/work");
805    let evidence_root = root.join(".supercov/evidence");
806    let published = child_directories(&runs_root)?;
807    let work = child_directories(&work_root)?;
808    let evidence = child_directories(&evidence_root)?;
809    let mut ids = published
810        .iter()
811        .chain(&work)
812        .chain(&evidence)
813        .cloned()
814        .collect::<BTreeSet<_>>()
815        .into_iter()
816        .collect::<Vec<_>>();
817    ids.sort_by(|left, right| right.cmp(left));
818    let mut active = BTreeSet::new();
819    for id in &ids {
820        if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
821            active.insert(id.clone());
822        }
823    }
824    let retained = published
825        .iter()
826        .rev()
827        .filter(|id| !active.contains(*id))
828        .take(options.keep)
829        .cloned()
830        .collect::<BTreeSet<_>>();
831    let mut result = CleanupResult {
832        removed_runs: Vec::new(),
833        removed_workspaces: Vec::new(),
834        removed_evidence: Vec::new(),
835        removed_build_cache: false,
836    };
837    for id in ids {
838        if active.contains(&id) {
839            continue;
840        }
841        let has_run = published.contains(&id);
842        let remove_history = has_run && !retained.contains(&id);
843        if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
844        {
845            result.removed_workspaces.push(id.clone());
846            if !options.dry_run {
847                remove_stored_tree_deferred(root, &work_root.join(&id))?;
848            }
849        }
850        if evidence.contains(&id) && (!has_run || remove_history) {
851            result.removed_evidence.push(id.clone());
852            if !options.dry_run {
853                remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
854            }
855        }
856        if remove_history {
857            result.removed_runs.push(id.clone());
858            if !options.dry_run {
859                remove_stored_tree_deferred(root, &runs_root.join(&id))?;
860            }
861        }
862    }
863    let container = crate::workspace::workspace_container(root);
864    let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
865    let mut caches = Vec::new();
866    let mut removed_cargo_cache = false;
867    if remove_build_cache && active.is_empty() {
868        if owned_workspace_container(root) {
869            caches.push(container);
870        }
871        caches.extend(
872            legacy
873                .into_iter()
874                .filter(|path| fs::symlink_metadata(path).is_ok()),
875        );
876        removed_cargo_cache = crate::workspace::clean_cargo_workspace(root, options.dry_run)
877            .map_err(|error| {
878                LifecycleError::InvalidState(format!(
879                    "could not clean the owned Cargo workspace: {error}"
880                ))
881            })?;
882    }
883    result.removed_build_cache = removed_cargo_cache || !caches.is_empty();
884    if !options.dry_run {
885        for cache in caches {
886            remove_stored_tree_deferred(root, &cache)?;
887        }
888    }
889    Ok(result)
890}
891
892fn cleanup_storage(
893    root: &Path,
894    options: CleanupOptions,
895    remove_build_cache: bool,
896    updated_at: &str,
897) -> Result<CleanupResult, LifecycleError> {
898    let operation = if remove_build_cache {
899        "clean"
900    } else {
901        "retention"
902    };
903    let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
904    let mut lock = ProjectLock::acquire(root, &lock_id, updated_at)?;
905    recover_abandoned_runs(root, updated_at)?;
906    let result = cleanup_storage_locked(root, options, remove_build_cache);
907    lock.release()?;
908    result
909}
910
911pub fn clean_storage(
912    root: &Path,
913    options: CleanupOptions,
914    updated_at: &str,
915) -> Result<CleanupResult, LifecycleError> {
916    cleanup_storage(root, options, true, updated_at)
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
923
924    fn project() -> PathBuf {
925        let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
926        fs::create_dir_all(root.join("src")).unwrap();
927        fs::write(root.join("src/index.js"), "user source").unwrap();
928        root
929    }
930
931    fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
932        RunState {
933            id: id.into(),
934            pid,
935            root: root.display().to_string(),
936            workspace: root.join("dist").display().to_string(),
937            started_at: "start".into(),
938            updated_at: "update".into(),
939            status,
940            signal: None,
941            error: None,
942        }
943    }
944
945    fn metadata(id: &str, bytes: u64) -> RunMetadata {
946        RunMetadata {
947            id: id.into(),
948            started_at: "2026-01-01T00:00:00Z".into(),
949            duration_ms: 1.0,
950            command: vec!["test".into()],
951            test_exit_code: Some(0),
952            integrity: RunIntegrity {
953                schema_version: 2,
954                instrumenter_version: "rust".into(),
955                git: None,
956                fingerprint: RunFingerprint {
957                    algorithm: "sha256".into(),
958                    source: "0".repeat(64),
959                    tests: "0".repeat(64),
960                    dependencies: "0".repeat(64),
961                    configuration: "0".repeat(64),
962                    instrumenter: "0".repeat(64),
963                    execution: "0".repeat(64),
964                    combined: "0".repeat(64),
965                    source_files: 1,
966                    test_files: 1,
967                },
968                stale: None,
969                stale_reasons: None,
970            },
971            raw_evidence: RawEvidenceMetadata {
972                schema_version: 2,
973                format: "supercov-evidence-archive".into(),
974                file: "evidence.raw.gz".into(),
975                files: 1,
976                uncompressed_bytes: bytes,
977                compressed_bytes: bytes,
978            },
979            isolated_build: None,
980            instrumented_build_cache: None,
981            timings: None,
982            merged: None,
983            parents: None,
984        }
985    }
986
987    fn evidence(root: &Path) -> (PathBuf, u64) {
988        use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
989        let mut entries = ["manifest.json", "frontend.json", "coverage-model.json"]
990            .into_iter()
991            .map(|path| EvidenceArchiveEntry {
992                path: path.into(),
993                contents: b"{}".to_vec(),
994            })
995            .collect::<Vec<_>>();
996        let inputs =
997            crate::assertion_inputs::capture(root, "javascript", [PathBuf::from("src/index.js")])
998                .unwrap();
999        entries = crate::assertion_inputs::append(entries, &inputs).unwrap();
1000        let path = root.join("evidence.gz");
1001        let metadata = write_archive(entries, &path).unwrap();
1002        (path, metadata.compressed_bytes)
1003    }
1004
1005    #[test]
1006    fn defers_only_owned_storage_and_sweeps_without_touching_source() {
1007        let root = project();
1008        let owned = root.join(".supercov/evidence/run");
1009        fs::create_dir_all(&owned).unwrap();
1010        fs::write(owned.join("hit"), "hit").unwrap();
1011        let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
1012        assert!(!owned.exists());
1013        assert!(trash.exists());
1014        assert!(matches!(
1015            remove_stored_tree_deferred(&root, &root.join("src")),
1016            Err(LifecycleError::UnsafePath(_))
1017        ));
1018        assert_eq!(sweep_trash(&root).unwrap(), 1);
1019        assert!(root.join("src/index.js").exists());
1020        fs::remove_dir_all(root).unwrap();
1021    }
1022
1023    #[cfg(unix)]
1024    #[test]
1025    fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
1026        use std::os::unix::fs::symlink;
1027
1028        let root = project();
1029        let outside = project();
1030        fs::create_dir_all(root.join(".supercov")).unwrap();
1031        fs::create_dir_all(outside.join("run")).unwrap();
1032        fs::write(outside.join("run/user.txt"), "user").unwrap();
1033        symlink(&outside, root.join(".supercov/evidence")).unwrap();
1034        assert!(matches!(
1035            remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
1036            Err(LifecycleError::UnsafePath(_))
1037        ));
1038        assert_eq!(
1039            fs::read_to_string(outside.join("run/user.txt")).unwrap(),
1040            "user"
1041        );
1042        fs::remove_dir_all(root).unwrap();
1043        fs::remove_dir_all(outside).unwrap();
1044    }
1045
1046    #[test]
1047    fn publishes_both_required_files_with_one_visible_rename() {
1048        let root = project();
1049        let id = "2026-01-01T00-00-00-000Z";
1050        let (evidence, bytes) = evidence(&root);
1051        let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1052        assert!(published.join("run.json").is_file());
1053        assert_eq!(
1054            fs::read(published.join("evidence.raw.gz")).unwrap(),
1055            fs::read(&evidence).unwrap()
1056        );
1057        assert!(published.join("assertions.json").is_file());
1058        assert!(published.join("assertions.state.json").is_file());
1059        assert!(matches!(
1060            publish_run(&root, &metadata(id, bytes), &evidence),
1061            Err(LifecycleError::PublicationExists(_))
1062        ));
1063        fs::remove_dir_all(root).unwrap();
1064    }
1065
1066    #[test]
1067    fn final_rename_failure_exposes_no_run_and_removes_staging() {
1068        let root = project();
1069        let id = "2026-01-01T00-00-00-000Z";
1070        let (evidence, bytes) = evidence(&root);
1071        let error = publish_run_with_fault(
1072            &root,
1073            &metadata(id, bytes),
1074            &evidence,
1075            Some(RunPublicationFault::FinalRename),
1076        )
1077        .unwrap_err();
1078        assert!(
1079            error
1080                .to_string()
1081                .contains("injected final run publication rename failure")
1082        );
1083        assert!(!root.join(".supercov/runs").join(id).exists());
1084        assert!(
1085            !root
1086                .join(".supercov/work")
1087                .join(id)
1088                .join("run-publication")
1089                .exists()
1090        );
1091        sweep_trash(&root).unwrap();
1092        fs::remove_dir_all(root).unwrap();
1093    }
1094
1095    #[test]
1096    fn malformed_assertion_inputs_expose_no_partial_run() {
1097        use crate::evidence_archive::{read_archive, write_archive};
1098        let root = project();
1099        let id = "2026-01-01T00-00-00-000Z";
1100        let (evidence, _) = evidence(&root);
1101        let mut entries = read_archive(&evidence).unwrap();
1102        entries
1103            .iter_mut()
1104            .find(|e| e.path == crate::assertion_inputs::ARCHIVE_PATH)
1105            .unwrap()
1106            .contents = b"{broken".to_vec();
1107        let raw = write_archive(entries, &evidence).unwrap();
1108        let error = publish_run(&root, &metadata(id, raw.compressed_bytes), &evidence).unwrap_err();
1109        assert!(error.to_string().contains("assertion map publication"));
1110        assert!(!root.join(".supercov/runs").join(id).exists());
1111        assert!(
1112            !root
1113                .join(".supercov/work")
1114                .join(id)
1115                .join("run-publication")
1116                .exists()
1117        );
1118        fs::remove_dir_all(root).unwrap();
1119    }
1120
1121    #[test]
1122    fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1123        let root = project();
1124        let dead = "2026-01-01T00-00-00-000Z";
1125        let published = "2026-01-02T00-00-00-000Z";
1126        for id in [dead, published] {
1127            fs::create_dir_all(
1128                root.join(".supercov/work")
1129                    .join(id)
1130                    .join(root.file_name().unwrap()),
1131            )
1132            .unwrap();
1133            fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1134            write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1135        }
1136        let (evidence, bytes) = evidence(&root);
1137        publish_run(&root, &metadata(published, bytes), &evidence).unwrap();
1138        assert_eq!(
1139            recover_abandoned_runs(&root, "recovered").unwrap(),
1140            [dead, published]
1141        );
1142        assert!(!root.join(".supercov/work").join(dead).exists());
1143        assert!(!root.join(".supercov/work").join(published).exists());
1144        assert!(root.join(".supercov/runs").join(published).exists());
1145        assert!(root.join("src/index.js").exists());
1146        sweep_trash(&root).unwrap();
1147        fs::remove_dir_all(root).unwrap();
1148    }
1149
1150    #[test]
1151    fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1152        let root = project();
1153        let ids = [
1154            "2026-01-01T00-00-00-000Z",
1155            "2026-01-02T00-00-00-000Z",
1156            "2026-01-03T00-00-00-000Z",
1157        ];
1158        for id in ids {
1159            fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1160            write_run_state(
1161                &root,
1162                &state(&root, id, RunStateStatus::Complete, std::process::id()),
1163            )
1164            .unwrap();
1165        }
1166        let active = "2025-12-31T00-00-00-000Z";
1167        write_run_state(
1168            &root,
1169            &state(&root, active, RunStateStatus::Testing, std::process::id()),
1170        )
1171        .unwrap();
1172        let preview = cleanup_storage_locked(
1173            &root,
1174            CleanupOptions {
1175                keep: 1,
1176                dry_run: true,
1177            },
1178            false,
1179        )
1180        .unwrap();
1181        assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1182        assert!(
1183            ids.iter()
1184                .all(|id| root.join(".supercov/runs").join(id).exists())
1185        );
1186        let result = cleanup_storage_locked(
1187            &root,
1188            CleanupOptions {
1189                keep: 1,
1190                dry_run: false,
1191            },
1192            false,
1193        )
1194        .unwrap();
1195        assert_eq!(result, preview);
1196        assert!(root.join(".supercov/work").join(active).exists());
1197        assert!(root.join(".supercov/runs").join(ids[2]).exists());
1198        sweep_trash(&root).unwrap();
1199        fs::remove_dir_all(root).unwrap();
1200    }
1201
1202    #[test]
1203    fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
1204        let root = project();
1205        let container = root.join(".supercov/workspaces");
1206        fs::create_dir_all(container.join("workspace/project")).unwrap();
1207        fs::write(
1208            container.join(".supercov-workspace-store"),
1209            b"Supercov instrumented workspace. Safe to delete.\n",
1210        )
1211        .unwrap();
1212        fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
1213        let mut preparation = ProjectLock::acquire(&root, "prepare", "start").unwrap();
1214        crate::workspace::prepare_cargo_cached_workspace(&root, &preparation).unwrap();
1215        let cargo_container = crate::workspace::cargo_workspace_container(&root).unwrap();
1216        preparation.release().unwrap();
1217
1218        let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
1219        assert!(matches!(
1220            clean_storage(
1221                &root,
1222                CleanupOptions {
1223                    keep: 0,
1224                    dry_run: false
1225                },
1226                "now"
1227            ),
1228            Err(LifecycleError::ActiveRun { .. })
1229        ));
1230        assert!(container.exists());
1231        assert!(cargo_container.exists());
1232        active.release().unwrap();
1233
1234        let cleaned = clean_storage(
1235            &root,
1236            CleanupOptions {
1237                keep: 0,
1238                dry_run: false,
1239            },
1240            "now",
1241        )
1242        .unwrap();
1243        assert!(cleaned.removed_build_cache);
1244        assert!(!container.exists());
1245        assert!(!cargo_container.exists());
1246        assert!(!root.join(".supercov/cache").exists());
1247        sweep_trash(&root).unwrap();
1248        fs::remove_dir_all(root).unwrap();
1249    }
1250}