Skip to main content

supercov_engine/
workspace.rs

1//! Isolated project snapshots and crash-recoverable stable build cache.
2
3use std::{
4    collections::BTreeSet,
5    fs::{self, OpenOptions},
6    io::{self, Write},
7    path::{Component, Path, PathBuf},
8    sync::atomic::{AtomicU64, Ordering},
9    time::{SystemTime, UNIX_EPOCH},
10};
11
12use serde::{Deserialize, Serialize};
13
14use crate::lifecycle::{LifecycleError, ProjectLock, atomic_rename, remove_stored_tree_deferred};
15
16const WORKSPACE_MARKER: &str = ".supercov-workspace-store";
17const ROOT_EXCLUSIONS: &[&str] = &[
18    ".cache",
19    ".git",
20    ".supercov",
21    ".mcdc-pool",
22    "node_modules",
23    "build",
24    "dist",
25    ".next",
26    ".nuxt",
27    ".output",
28    "coverage",
29    "playwright-report",
30    "test-results",
31];
32const NESTED_EXCLUSIONS: &[&str] = &[".supercov", ".mcdc-pool"];
33static UNIQUE: AtomicU64 = AtomicU64::new(0);
34
35#[derive(Debug)]
36pub enum WorkspaceError {
37    Io { path: PathBuf, source: io::Error },
38    UnsafePath(PathBuf),
39    EscapingLink { path: PathBuf, target: PathBuf },
40    UnsupportedEntry(PathBuf),
41    MissingLock,
42    Lifecycle(LifecycleError),
43    InvalidCacheMetadata(serde_json::Error),
44    UnsupportedPlatform(&'static str),
45}
46
47impl std::fmt::Display for WorkspaceError {
48    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
51            Self::UnsafePath(path) => {
52                write!(formatter, "unsafe workspace path: {}", path.display())
53            }
54            Self::EscapingLink { path, target } => write!(
55                formatter,
56                "refusing to preserve symlink outside the isolated project: {} -> {}",
57                path.display(),
58                target.display()
59            ),
60            Self::UnsupportedEntry(path) => {
61                write!(
62                    formatter,
63                    "unsupported filesystem entry in isolated project: {}",
64                    path.display()
65                )
66            }
67            Self::MissingLock => write!(
68                formatter,
69                "isolated workspace preparation requires the active project lock"
70            ),
71            Self::Lifecycle(error) => write!(formatter, "{error}"),
72            Self::InvalidCacheMetadata(error) => {
73                write!(formatter, "invalid build-cache metadata: {error}")
74            }
75            Self::UnsupportedPlatform(reason) => {
76                write!(formatter, "unsupported workspace platform: {reason}")
77            }
78        }
79    }
80}
81
82impl std::error::Error for WorkspaceError {}
83
84impl From<LifecycleError> for WorkspaceError {
85    fn from(value: LifecycleError) -> Self {
86        Self::Lifecycle(value)
87    }
88}
89
90fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
91    WorkspaceError::Io {
92        path: path.to_owned(),
93        source,
94    }
95}
96
97fn unique() -> String {
98    let nanos = SystemTime::now()
99        .duration_since(UNIX_EPOCH)
100        .unwrap_or_default()
101        .as_nanos();
102    format!(
103        "{}-{nanos}-{}",
104        std::process::id(),
105        UNIQUE.fetch_add(1, Ordering::Relaxed)
106    )
107}
108
109fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
110    root.file_name()
111        .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
112}
113
114pub fn workspace_container(root: &Path) -> PathBuf {
115    root.join("supercov")
116}
117
118pub fn cached_workspace_path(root: &Path) -> Result<PathBuf, WorkspaceError> {
119    Ok(workspace_container(root)
120        .join("workspace")
121        .join(project_name(root)?))
122}
123
124pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
125    if run_id.is_empty()
126        || run_id == "."
127        || run_id == ".."
128        || run_id
129            .chars()
130            .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
131    {
132        return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
133    }
134    Ok(root
135        .join(".supercov/work")
136        .join(run_id)
137        .join(project_name(root)?))
138}
139
140fn marker_owned(path: &Path) -> bool {
141    fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
142        && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
143            .is_ok_and(|metadata| metadata.file_type().is_file())
144}
145
146fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
147    let container = workspace_container(root);
148    match fs::symlink_metadata(&container) {
149        Ok(metadata) if !metadata.file_type().is_dir() => {
150            return Err(WorkspaceError::UnsafePath(container));
151        }
152        Ok(_) => {}
153        Err(error) if error.kind() == io::ErrorKind::NotFound => {
154            fs::create_dir(&container).map_err(|source| io_error(&container, source))?;
155        }
156        Err(source) => return Err(io_error(&container, source)),
157    }
158    for (name, contents) in [
159        (".gitignore", "*\n"),
160        (
161            WORKSPACE_MARKER,
162            "Supercov instrumented workspace. Safe to delete.\n",
163        ),
164    ] {
165        let path = container.join(name);
166        match OpenOptions::new().write(true).create_new(true).open(&path) {
167            Ok(mut file) => file
168                .write_all(contents.as_bytes())
169                .and_then(|_| file.sync_all())
170                .map_err(|source| io_error(&path, source))?,
171            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
172                if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file())
173                {
174                    return Err(WorkspaceError::UnsafePath(path));
175                }
176            }
177            Err(source) => return Err(io_error(&path, source)),
178        }
179    }
180    Ok(container)
181}
182
183fn lexical_normalize(path: &Path) -> Option<PathBuf> {
184    let mut normalized = PathBuf::new();
185    for component in path.components() {
186        match component {
187            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
188            Component::RootDir => normalized.push(component.as_os_str()),
189            Component::CurDir => {}
190            Component::ParentDir => {
191                if !normalized.pop() {
192                    return None;
193                }
194            }
195            Component::Normal(value) => normalized.push(value),
196        }
197    }
198    Some(normalized)
199}
200
201fn inside(root: &Path, path: &Path) -> bool {
202    path == root
203        || path.strip_prefix(root).is_ok_and(|local| {
204            !local.as_os_str().is_empty()
205                && local
206                    .components()
207                    .all(|component| matches!(component, Component::Normal(_)))
208        })
209}
210
211trait WorkspaceOperations {
212    fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
213    fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
214}
215
216struct SystemWorkspaceOperations;
217
218impl WorkspaceOperations for SystemWorkspaceOperations {
219    fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
220        reflink_copy::reflink_or_copy(source, destination)
221            .map(|_| ())
222            .map_err(|source_error| io_error(destination, source_error))
223    }
224
225    fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
226        atomic_rename(source, destination).map_err(WorkspaceError::from)
227    }
228}
229
230#[cfg(unix)]
231fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
232    std::os::unix::fs::symlink(target, destination)
233}
234
235#[cfg(windows)]
236fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
237    if directory {
238        // Junctions work on ordinary NTFS installations without requiring
239        // Developer Mode or SeCreateSymbolicLinkPrivilege. This is the common
240        // path for dependency mounts and internal directory links. A project
241        // may still live on a non-NTFS/network filesystem where junctions are
242        // unavailable but unprivileged symlinks are enabled, so preserve that
243        // valid fallback instead of assuming one Windows filesystem.
244        match junction::create(target, destination) {
245            Ok(()) => Ok(()),
246            Err(junction_error) => {
247                let _ = fs::remove_dir(destination);
248                std::os::windows::fs::symlink_dir(target, destination).map_err(
249                    |symlink_error| {
250                        io::Error::new(
251                            symlink_error.kind(),
252                            format!(
253                                "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
254                            ),
255                        )
256                    },
257                )
258            }
259        }
260    } else {
261        // A project that already contains a file symlink necessarily runs in
262        // an environment capable of creating one. Do not silently copy it:
263        // that can change Node realpath/module-identity semantics.
264        std::os::windows::fs::symlink_file(target, destination)
265    }
266}
267
268#[derive(Clone, Copy)]
269struct CopyRoots<'a> {
270    source: &'a Path,
271    destination: &'a Path,
272    final_destination: &'a Path,
273    canonical_source: &'a Path,
274}
275
276fn copy_tree<Operations: WorkspaceOperations>(
277    source: &Path,
278    destination: &Path,
279    roots: CopyRoots<'_>,
280    root_level: bool,
281    operations: &mut Operations,
282) -> Result<(), WorkspaceError> {
283    fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
284    let mut entries = fs::read_dir(source)
285        .map_err(|error| io_error(source, error))?
286        .collect::<Result<Vec<_>, _>>()
287        .map_err(|error| io_error(source, error))?;
288    entries.sort_by_key(fs::DirEntry::file_name);
289    for entry in entries {
290        let name = entry.file_name();
291        let name_text = name
292            .to_str()
293            .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
294        if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
295            || NESTED_EXCLUSIONS.contains(&name_text)
296        {
297            continue;
298        }
299        let from = entry.path();
300        let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
301        if metadata.file_type().is_dir() && marker_owned(&from) {
302            continue;
303        }
304        let to = destination.join(&name);
305        if metadata.file_type().is_dir() {
306            copy_tree(&from, &to, roots, false, operations)?;
307        } else if metadata.file_type().is_symlink() {
308            let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
309            let unresolved_target = if link.is_absolute() {
310                link.clone()
311            } else {
312                from.parent().expect("entry parent").join(&link)
313            };
314            let lexical_target = lexical_normalize(&unresolved_target).ok_or_else(|| {
315                WorkspaceError::EscapingLink {
316                    path: from.clone(),
317                    target: link.clone(),
318                }
319            })?;
320            let canonical_target =
321                fs::canonicalize(&from).map_err(|error| io_error(&from, error))?;
322            if !inside(roots.source, &lexical_target)
323                || !inside(roots.canonical_source, &canonical_target)
324            {
325                return Err(WorkspaceError::EscapingLink {
326                    path: from,
327                    target: link,
328                });
329            }
330            let local_target = lexical_target
331                .strip_prefix(roots.source)
332                .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
333            let relocated = roots.destination.join(local_target);
334            let target_metadata = fs::metadata(&canonical_target)
335                .map_err(|error| io_error(&canonical_target, error))?;
336            let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
337                roots.final_destination.join(local_target)
338            } else if link.is_absolute() {
339                pathdiff(&to, &relocated)?
340            } else {
341                link
342            };
343            create_link(&isolated_link, &to, target_metadata.is_dir())
344                .map_err(|error| io_error(&to, error))?;
345        } else if metadata.file_type().is_file() {
346            operations.copy_file(&from, &to)?;
347        } else {
348            return Err(WorkspaceError::UnsupportedEntry(from));
349        }
350    }
351    Ok(())
352}
353
354fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
355    let from = from
356        .parent()
357        .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
358    let from_components = from.components().collect::<Vec<_>>();
359    let to_components = to.components().collect::<Vec<_>>();
360    let common = from_components
361        .iter()
362        .zip(&to_components)
363        .take_while(|(left, right)| left == right)
364        .count();
365    if common == 0 {
366        return Err(WorkspaceError::UnsafePath(to.into()));
367    }
368    let mut relative = PathBuf::new();
369    for _ in common..from_components.len() {
370        relative.push("..");
371    }
372    for component in &to_components[common..] {
373        relative.push(component.as_os_str());
374    }
375    Ok(relative)
376}
377
378fn link_node_modules<Operations: WorkspaceOperations>(
379    root: &Path,
380    workspace: &Path,
381    operations: &mut Operations,
382) -> Result<(), WorkspaceError> {
383    #[cfg(unix)]
384    let _ = &operations;
385    let source = root.join("node_modules");
386    let source_metadata = match fs::symlink_metadata(&source) {
387        Ok(metadata) => metadata,
388        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
389        Err(error) => return Err(io_error(&source, error)),
390    };
391    if !source_metadata.file_type().is_dir() {
392        return Err(WorkspaceError::UnsafePath(source));
393    }
394    let destination = workspace.join("node_modules");
395    fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
396    let mut entries = fs::read_dir(&source)
397        .map_err(|error| io_error(&source, error))?
398        .collect::<Result<Vec<_>, _>>()
399        .map_err(|error| io_error(&source, error))?;
400    entries.sort_by_key(fs::DirEntry::file_name);
401    for entry in entries {
402        let target = entry.path();
403        let to = destination.join(entry.file_name());
404        #[cfg(unix)]
405        create_link(&target, &to, false).map_err(|error| io_error(&to, error))?;
406        #[cfg(windows)]
407        {
408            let file_type = entry
409                .file_type()
410                .map_err(|error| io_error(&target, error))?;
411            let resolved = if file_type.is_symlink() {
412                fs::canonicalize(&target).map_err(|error| io_error(&target, error))?
413            } else {
414                target.clone()
415            };
416            let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
417            if metadata.is_dir() {
418                create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
419            } else if metadata.is_file() {
420                // Top-level node_modules metadata files are cheap to copy and a
421                // link would unnecessarily require Windows symlink privileges.
422                operations.copy_file(&resolved, &to)?;
423            } else {
424                return Err(WorkspaceError::UnsupportedEntry(target));
425            }
426        }
427    }
428    Ok(())
429}
430
431fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
432    if lock.protects(root) {
433        Ok(())
434    } else {
435        Err(WorkspaceError::MissingLock)
436    }
437}
438
439pub fn prepare_isolated_workspace(
440    root: &Path,
441    run_id: &str,
442    lock: &ProjectLock,
443) -> Result<PathBuf, WorkspaceError> {
444    require_lock(root, lock)?;
445    let mut operations = SystemWorkspaceOperations;
446    let workspace = isolated_workspace_path(root, run_id)?;
447    remove_stored_tree_deferred(root, &workspace)?;
448    let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?;
449    copy_tree(
450        root,
451        &workspace,
452        CopyRoots {
453            source: root,
454            destination: &workspace,
455            final_destination: &workspace,
456            canonical_source: &canonical_root,
457        },
458        true,
459        &mut operations,
460    )?;
461    link_node_modules(root, &workspace, &mut operations)?;
462    Ok(workspace)
463}
464
465fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
466    Ok(format!(
467        ".{}.{}-",
468        project_name(root)?.to_string_lossy(),
469        kind
470    ))
471}
472
473fn transaction_path(root: &Path, kind: &str) -> Result<PathBuf, WorkspaceError> {
474    let workspace = cached_workspace_path(root)?;
475    Ok(workspace.parent().expect("workspace parent").join(format!(
476        "{}{}",
477        transaction_prefix(root, kind)?,
478        unique()
479    )))
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
483#[serde(rename_all = "camelCase")]
484pub struct CacheRecoveryResult {
485    pub restored_previous: bool,
486    pub removed_staging: usize,
487    pub removed_previous: usize,
488}
489
490pub fn recover_cached_workspace(
491    root: &Path,
492    lock: &ProjectLock,
493) -> Result<CacheRecoveryResult, WorkspaceError> {
494    require_lock(root, lock)?;
495    let workspace = cached_workspace_path(root)?;
496    let parent = workspace.parent().expect("workspace parent");
497    let entries = match fs::read_dir(parent) {
498        Ok(entries) => entries,
499        Err(error) if error.kind() == io::ErrorKind::NotFound => {
500            return Ok(CacheRecoveryResult {
501                restored_previous: false,
502                removed_staging: 0,
503                removed_previous: 0,
504            });
505        }
506        Err(error) => return Err(io_error(parent, error)),
507    };
508    let staging_prefix = transaction_prefix(root, "staging")?;
509    let previous_prefix = transaction_prefix(root, "previous")?;
510    let mut staging = Vec::new();
511    let mut previous = Vec::new();
512    let mut invalid_previous = Vec::new();
513    for entry in entries {
514        let entry = entry.map_err(|error| io_error(parent, error))?;
515        let name = entry.file_name().to_string_lossy().into_owned();
516        if name.starts_with(&staging_prefix) {
517            staging.push(entry.path());
518        } else if name.starts_with(&previous_prefix) {
519            let metadata = entry
520                .file_type()
521                .map_err(|error| io_error(&entry.path(), error))?;
522            if metadata.is_dir() {
523                let modified = entry
524                    .metadata()
525                    .and_then(|metadata| metadata.modified())
526                    .unwrap_or(UNIX_EPOCH);
527                previous.push((modified, entry.path()));
528            } else {
529                invalid_previous.push(entry.path());
530            }
531        }
532    }
533    previous.sort_by(|left, right| right.0.cmp(&left.0));
534    let mut restored = false;
535    if fs::symlink_metadata(&workspace).is_err()
536        && let Some((_, newest)) = previous.first()
537    {
538        atomic_rename(newest, &workspace)?;
539        previous.remove(0);
540        restored = true;
541    }
542    let removed_staging = staging.len();
543    let removed_previous = previous.len() + invalid_previous.len();
544    for path in staging
545        .into_iter()
546        .chain(previous.into_iter().map(|(_, path)| path))
547        .chain(invalid_previous)
548    {
549        remove_stored_tree_deferred(root, &path)?;
550    }
551    Ok(CacheRecoveryResult {
552        restored_previous: restored,
553        removed_staging,
554        removed_previous,
555    })
556}
557
558fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
559    if requested.is_absolute()
560        || requested
561            .components()
562            .any(|component| !matches!(component, Component::Normal(_)))
563    {
564        return Err(WorkspaceError::UnsafePath(requested.into()));
565    }
566    let path = workspace.join(requested);
567    if path == workspace || fs::symlink_metadata(&path).is_err() {
568        return Err(WorkspaceError::UnsafePath(requested.into()));
569    }
570    Ok(path)
571}
572
573pub fn prepare_cached_workspace(
574    root: &Path,
575    lock: &ProjectLock,
576    reuse_paths: &[PathBuf],
577) -> Result<PathBuf, WorkspaceError> {
578    let mut operations = SystemWorkspaceOperations;
579    prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
580}
581
582fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
583    root: &Path,
584    lock: &ProjectLock,
585    reuse_paths: &[PathBuf],
586    operations: &mut Operations,
587) -> Result<PathBuf, WorkspaceError> {
588    require_lock(root, lock)?;
589    ensure_container(root)?;
590    recover_cached_workspace(root, lock)?;
591    let workspace = cached_workspace_path(root)?;
592    let staging = transaction_path(root, "staging")?;
593    let previous = transaction_path(root, "previous")?;
594    let result = (|| {
595        let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?;
596        copy_tree(
597            root,
598            &staging,
599            CopyRoots {
600                source: root,
601                destination: &staging,
602                final_destination: &workspace,
603                canonical_source: &canonical_root,
604            },
605            true,
606            operations,
607        )?;
608        link_node_modules(root, &staging, operations)?;
609        for requested in reuse_paths {
610            let from = checked_reuse_path(&workspace, requested)?;
611            let to = staging.join(requested);
612            let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
613            if metadata.file_type().is_dir() {
614                let canonical_workspace =
615                    fs::canonicalize(&workspace).map_err(|error| io_error(&workspace, error))?;
616                copy_tree(
617                    &from,
618                    &to,
619                    CopyRoots {
620                        source: &workspace,
621                        destination: &staging,
622                        final_destination: &workspace,
623                        canonical_source: &canonical_workspace,
624                    },
625                    false,
626                    operations,
627                )?;
628            } else if metadata.file_type().is_file() {
629                fs::create_dir_all(to.parent().expect("reuse parent"))
630                    .map_err(|error| io_error(&to, error))?;
631                operations.copy_file(&from, &to)?;
632            } else {
633                return Err(WorkspaceError::UnsupportedEntry(from));
634            }
635        }
636        let mut moved_previous = false;
637        if fs::symlink_metadata(&workspace).is_ok() {
638            operations.rename(&workspace, &previous)?;
639            moved_previous = true;
640        }
641        if let Err(error) = operations.rename(&staging, &workspace) {
642            if moved_previous && fs::symlink_metadata(&workspace).is_err() {
643                let _ = operations.rename(&previous, &workspace);
644            }
645            return Err(error);
646        }
647        if fs::symlink_metadata(&previous).is_ok() {
648            remove_stored_tree_deferred(root, &previous)?;
649        }
650        Ok(workspace.clone())
651    })();
652    if fs::symlink_metadata(&staging).is_ok() {
653        remove_stored_tree_deferred(root, &staging)?;
654    }
655    result
656}
657
658#[derive(Deserialize)]
659#[serde(rename_all = "camelCase")]
660struct BuildCacheMetadata {
661    #[serde(default)]
662    artifact_paths: Vec<String>,
663}
664
665pub fn prune_cached_workspace_sources(
666    root: &Path,
667    lock: &ProjectLock,
668) -> Result<Vec<String>, WorkspaceError> {
669    require_lock(root, lock)?;
670    let workspace = cached_workspace_path(root)?;
671    if fs::symlink_metadata(&workspace).is_err() {
672        return Ok(Vec::new());
673    }
674    let mut keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
675    let metadata_path = workspace.join(".supercov/build-cache.json");
676    if let Ok(bytes) = fs::read(&metadata_path) {
677        let metadata: BuildCacheMetadata =
678            serde_json::from_slice(&bytes).map_err(WorkspaceError::InvalidCacheMetadata)?;
679        for artifact in metadata.artifact_paths {
680            if let Some(top) = Path::new(&artifact)
681                .components()
682                .next()
683                .and_then(|component| match component {
684                    Component::Normal(value) => value.to_str(),
685                    _ => None,
686                })
687            {
688                keep.insert(top.into());
689            }
690        }
691    }
692    let mut removed = Vec::new();
693    for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
694        let entry = entry.map_err(|error| io_error(&workspace, error))?;
695        let name = entry
696            .file_name()
697            .into_string()
698            .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
699        if keep.contains(&name) {
700            continue;
701        }
702        remove_stored_tree_deferred(root, &entry.path())?;
703        removed.push(name);
704    }
705    removed.sort();
706    Ok(removed)
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    struct OrdinaryCopyOperations;
714
715    impl WorkspaceOperations for OrdinaryCopyOperations {
716        fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
717            fs::copy(source, destination)
718                .map(|_| ())
719                .map_err(|error| io_error(destination, error))
720        }
721
722        fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
723            atomic_rename(source, destination).map_err(WorkspaceError::from)
724        }
725    }
726
727    struct FaultOperations {
728        copy_count: usize,
729        fail_copy_at: Option<usize>,
730        rename_count: usize,
731        fail_rename_at: Option<usize>,
732    }
733
734    impl WorkspaceOperations for FaultOperations {
735        fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
736            self.copy_count += 1;
737            if self.fail_copy_at == Some(self.copy_count) {
738                return Err(io_error(
739                    destination,
740                    io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
741                ));
742            }
743            SystemWorkspaceOperations.copy_file(source, destination)
744        }
745
746        fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
747            self.rename_count += 1;
748            if self.fail_rename_at == Some(self.rename_count) {
749                return Err(io_error(
750                    destination,
751                    io::Error::other("injected publication rename failure"),
752                ));
753            }
754            SystemWorkspaceOperations.rename(source, destination)
755        }
756    }
757
758    fn transaction_debris(root: &Path) -> Vec<String> {
759        let workspace = cached_workspace_path(root).unwrap();
760        let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
761        fs::read_dir(workspace.parent().unwrap())
762            .unwrap()
763            .filter_map(Result::ok)
764            .map(|entry| entry.file_name().to_string_lossy().into_owned())
765            .filter(|name| name.starts_with(&prefix))
766            .collect()
767    }
768
769    fn project() -> PathBuf {
770        let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
771        fs::create_dir_all(root.join("src")).unwrap();
772        fs::write(root.join("src/index.js"), "one").unwrap();
773        fs::write(root.join("package.json"), "{}").unwrap();
774        root
775    }
776
777    #[test]
778    fn isolated_copy_never_changes_source_and_requires_the_lock() {
779        let root = project();
780        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
781        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
782        fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
783        assert_eq!(
784            fs::read_to_string(root.join("src/index.js")).unwrap(),
785            "one"
786        );
787        lock.release().unwrap();
788        assert!(matches!(
789            prepare_isolated_workspace(&root, "other", &lock),
790            Err(WorkspaceError::MissingLock)
791        ));
792        fs::remove_dir_all(root).unwrap();
793    }
794
795    #[test]
796    fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
797        let root = project();
798        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
799        let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
800        fs::create_dir_all(first.join("build")).unwrap();
801        fs::write(first.join("build/output.js"), "instrumented").unwrap();
802        fs::write(first.join("stale.txt"), "stale").unwrap();
803        fs::write(root.join("src/index.js"), "two").unwrap();
804        let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
805        assert_eq!(first, second);
806        assert_eq!(
807            fs::read_to_string(second.join("src/index.js")).unwrap(),
808            "two"
809        );
810        assert_eq!(
811            fs::read_to_string(second.join("build/output.js")).unwrap(),
812            "instrumented"
813        );
814        assert!(!second.join("stale.txt").exists());
815        lock.release().unwrap();
816        fs::remove_dir_all(root).unwrap();
817    }
818
819    #[test]
820    fn ordinary_copy_fallback_preserves_workspace_semantics() {
821        let root = project();
822        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
823        let mut operations = OrdinaryCopyOperations;
824        let workspace =
825            prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
826        assert_eq!(
827            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
828            "one"
829        );
830        assert_eq!(
831            fs::read_to_string(root.join("src/index.js")).unwrap(),
832            "one"
833        );
834        assert!(transaction_debris(&root).is_empty());
835        lock.release().unwrap();
836        fs::remove_dir_all(root).unwrap();
837    }
838
839    #[test]
840    fn enospc_during_copy_preserves_the_complete_generation() {
841        let root = project();
842        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
843        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
844        fs::write(workspace.join("generation"), "complete").unwrap();
845        fs::write(root.join("src/index.js"), "new source").unwrap();
846        let mut operations = FaultOperations {
847            copy_count: 0,
848            fail_copy_at: Some(1),
849            rename_count: 0,
850            fail_rename_at: None,
851        };
852        let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
853            .unwrap_err();
854        assert!(matches!(
855            error,
856            WorkspaceError::Io { ref source, .. }
857                if source.kind() == io::ErrorKind::StorageFull
858        ));
859        assert_eq!(
860            fs::read_to_string(workspace.join("generation")).unwrap(),
861            "complete"
862        );
863        assert_eq!(
864            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
865            "one"
866        );
867        assert!(transaction_debris(&root).is_empty());
868        lock.release().unwrap();
869        fs::remove_dir_all(root).unwrap();
870    }
871
872    #[test]
873    fn failed_publication_rename_restores_the_complete_generation() {
874        let root = project();
875        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
876        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
877        fs::write(workspace.join("generation"), "complete").unwrap();
878        fs::write(root.join("src/index.js"), "new source").unwrap();
879        let mut operations = FaultOperations {
880            copy_count: 0,
881            fail_copy_at: None,
882            rename_count: 0,
883            fail_rename_at: Some(2),
884        };
885        let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
886            .unwrap_err();
887        assert!(matches!(
888            error,
889            WorkspaceError::Io { ref source, .. }
890                if source.kind() == io::ErrorKind::Other
891        ));
892        assert_eq!(
893            operations.rename_count, 3,
894            "prior generation was not restored"
895        );
896        assert_eq!(
897            fs::read_to_string(workspace.join("generation")).unwrap(),
898            "complete"
899        );
900        assert_eq!(
901            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
902            "one"
903        );
904        assert!(transaction_debris(&root).is_empty());
905        lock.release().unwrap();
906        fs::remove_dir_all(root).unwrap();
907    }
908
909    #[cfg(unix)]
910    #[test]
911    fn relocates_internal_links_and_rejects_external_links() {
912        use std::os::unix::fs::symlink;
913
914        let root = project();
915        fs::create_dir_all(root.join("shared")).unwrap();
916        fs::write(root.join("shared/value"), "inside").unwrap();
917        symlink("../shared/value", root.join("src/value-link")).unwrap();
918        let external = project();
919        fs::write(external.join("outside"), "outside").unwrap();
920        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
921        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
922        assert_eq!(
923            fs::read_to_string(workspace.join("src/value-link")).unwrap(),
924            "inside"
925        );
926        symlink(external.join("outside"), root.join("src/external-link")).unwrap();
927        assert!(matches!(
928            prepare_cached_workspace(&root, &lock, &[]),
929            Err(WorkspaceError::EscapingLink { .. })
930        ));
931        assert_eq!(
932            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
933            "one"
934        );
935        lock.release().unwrap();
936        fs::remove_dir_all(root).unwrap();
937        fs::remove_dir_all(external).unwrap();
938    }
939
940    #[cfg(windows)]
941    #[test]
942    fn relocates_internal_junctions_without_symlink_privileges() {
943        let root = project();
944        fs::create_dir_all(root.join("shared")).unwrap();
945        fs::write(root.join("shared/value"), "inside").unwrap();
946        junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
947        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
948        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
949        let isolated_link = workspace.join("linked-shared");
950        assert!(junction::exists(&isolated_link).unwrap());
951        assert_eq!(
952            fs::canonicalize(junction::get_target(&isolated_link).unwrap()).unwrap(),
953            fs::canonicalize(workspace.join("shared")).unwrap()
954        );
955        assert_eq!(
956            fs::read_to_string(isolated_link.join("value")).unwrap(),
957            "inside"
958        );
959        lock.release().unwrap();
960        fs::remove_dir_all(root).unwrap();
961    }
962
963    #[cfg(windows)]
964    #[test]
965    fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
966        let root = project();
967        fs::create_dir_all(root.join("node_modules/example")).unwrap();
968        fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
969        fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
970        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
971        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
972        let package = workspace.join("node_modules/example");
973        assert!(junction::exists(&package).unwrap());
974        assert_eq!(
975            fs::canonicalize(junction::get_target(&package).unwrap()).unwrap(),
976            fs::canonicalize(root.join("node_modules/example")).unwrap()
977        );
978        let metadata = workspace.join("node_modules/.package-lock.json");
979        assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
980        assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
981        lock.release().unwrap();
982        fs::remove_dir_all(root).unwrap();
983    }
984
985    #[test]
986    fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
987        let root = project();
988        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
989        ensure_container(&root).unwrap();
990        let workspace = cached_workspace_path(&root).unwrap();
991        let parent = workspace.parent().unwrap();
992        fs::create_dir_all(parent).unwrap();
993        let previous = parent.join(format!(
994            "{}old",
995            transaction_prefix(&root, "previous").unwrap()
996        ));
997        let staging = parent.join(format!(
998            "{}new",
999            transaction_prefix(&root, "staging").unwrap()
1000        ));
1001        fs::create_dir_all(&previous).unwrap();
1002        fs::write(previous.join("complete"), "yes").unwrap();
1003        fs::create_dir_all(&staging).unwrap();
1004        let result = recover_cached_workspace(&root, &lock).unwrap();
1005        assert!(result.restored_previous);
1006        assert_eq!(result.removed_staging, 1);
1007        assert_eq!(
1008            fs::read_to_string(workspace.join("complete")).unwrap(),
1009            "yes"
1010        );
1011        lock.release().unwrap();
1012        fs::remove_dir_all(root).unwrap();
1013    }
1014}