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