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