1use 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(¤t) {
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(¤t, 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
164pub(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
274pub 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 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 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
598pub 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_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 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}