Skip to main content

supercov_engine/
workspace.rs

1//! Isolated project snapshots and crash-recoverable stable build cache.
2
3use std::{
4    collections::{BTreeMap, 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::Serialize;
13use sha2::{Digest, Sha256};
14
15use crate::lifecycle::{LifecycleError, ProjectLock, atomic_rename, remove_stored_tree_deferred};
16
17const WORKSPACE_MARKER: &str = ".supercov-workspace-store";
18const WORKSPACE_MARKER_CONTENTS: &[u8] = b"Supercov instrumented workspace. Safe to delete.\n";
19const ROOT_EXCLUSIONS: &[&str] = &[
20    ".cache",
21    ".git",
22    ".supercov",
23    ".mcdc-pool",
24    "node_modules",
25    "build",
26    "dist",
27    ".next",
28    ".nuxt",
29    ".output",
30    "coverage",
31    "playwright-report",
32    "test-results",
33    "target",
34];
35const NESTED_EXCLUSIONS: &[&str] = &[".supercov", ".mcdc-pool"];
36
37/// Whether the copy uses a virtual environment of the project's own, linked
38/// in rather than copied: what the command does to it, it does to the
39/// project's. Read to the depth a project keeps one at -- `.venv`, or a
40/// service's `api/.venv`.
41pub fn links_a_virtual_environment(workspace: &Path) -> bool {
42    fn search(directory: &Path, depth: usize) -> bool {
43        let Ok(entries) = fs::read_dir(directory) else {
44            return false;
45        };
46        entries.filter_map(Result::ok).any(|entry| {
47            let path = entry.path();
48            let name = entry.file_name();
49            let Ok(metadata) = fs::symlink_metadata(&path) else {
50                return false;
51            };
52            if metadata.file_type().is_symlink() {
53                return path.join("pyvenv.cfg").is_file();
54            }
55            depth > 0
56                && metadata.is_dir()
57                && name != "node_modules"
58                && name != ".supercov"
59                && name != ".git"
60                && search(&path, depth - 1)
61        })
62    }
63    search(workspace, 2)
64}
65static UNIQUE: AtomicU64 = AtomicU64::new(0);
66
67#[derive(Debug)]
68pub enum WorkspaceError {
69    Io { path: PathBuf, source: io::Error },
70    UnsafePath(PathBuf),
71    UnsupportedEntry(PathBuf),
72    MissingLock,
73    Lifecycle(LifecycleError),
74    UnsupportedPlatform(&'static str),
75}
76
77impl std::fmt::Display for WorkspaceError {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
81            Self::UnsafePath(path) => {
82                write!(formatter, "unsafe workspace path: {}", path.display())
83            }
84            Self::UnsupportedEntry(path) => {
85                write!(
86                    formatter,
87                    "unsupported filesystem entry in isolated project: {}",
88                    path.display()
89                )
90            }
91            Self::MissingLock => write!(
92                formatter,
93                "isolated workspace preparation requires the active project lock"
94            ),
95            Self::Lifecycle(error) => write!(formatter, "{error}"),
96            Self::UnsupportedPlatform(reason) => {
97                write!(formatter, "unsupported workspace platform: {reason}")
98            }
99        }
100    }
101}
102
103impl std::error::Error for WorkspaceError {}
104
105impl From<LifecycleError> for WorkspaceError {
106    fn from(value: LifecycleError) -> Self {
107        Self::Lifecycle(value)
108    }
109}
110
111/// Leftover tool state -- a Chrome test profile linking into /tmp was the
112/// field case -- must not abort measurement. The mirror omits the entry:
113/// nothing outside the project is ever followed, and a test that truly needs
114/// the link fails visibly inside the workspace with this line as the cause.
115fn omit_escaping_link(path: &Path, target: &Path) {
116    eprintln!(
117        "[supercov] omitting symlink outside the isolated project: {} -> {}",
118        path.display(),
119        target.display()
120    );
121}
122
123/// `fs::canonicalize` on Windows returns a verbatim path -- `\\?\C:\...` or
124/// `\\?\UNC\server\share\...` -- and that prefix does not survive contact with
125/// anything else: Node's pathToFileURL reads `?` as a UNC host, `Path::starts_with`
126/// treats `\\?\C:\` and `C:\` as different prefixes so containment checks
127/// misjudge every link, and the prefix shows up in every diagnostic. Canonical
128/// paths are simplified back to the ordinary form at the one place they are
129/// made, so nothing downstream ever sees the verbatim spelling.
130pub(crate) fn canonicalize_simplified<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
131    fs::canonicalize(path).map(simplified)
132}
133
134#[cfg(windows)]
135pub(crate) fn simplified(path: PathBuf) -> PathBuf {
136    match path.to_str().and_then(strip_verbatim) {
137        Some(plain) => PathBuf::from(plain),
138        None => path,
139    }
140}
141
142#[cfg(not(windows))]
143pub(crate) fn simplified(path: PathBuf) -> PathBuf {
144    path
145}
146
147/// The pure string half, kept host-independent so it can be tested anywhere:
148/// `\\?\C:\a` -> `C:\a`, `\\?\UNC\srv\share\a` -> `\\srv\share\a`, and `None`
149/// for a path that carries no verbatim prefix.
150#[cfg_attr(not(windows), allow(dead_code))]
151pub(crate) fn strip_verbatim(path: &str) -> Option<String> {
152    let rest = path.strip_prefix(r"\\?\")?;
153    if let Some(unc) = rest.strip_prefix(r"UNC\") {
154        return Some(format!(r"\\{unc}"));
155    }
156    let mut chars = rest.chars();
157    let drive = chars.next()?;
158    if drive.is_ascii_alphabetic() && chars.next() == Some(':') {
159        return Some(rest.to_owned());
160    }
161    None
162}
163
164/// The `file://` URL for an absolute path, the way Node's `pathToFileURL`
165/// derives it. Node takes a bare `--import=C:\...` for a URL with scheme `c:`
166/// and refuses it -- ERR_UNSUPPORTED_ESM_URL_SCHEME -- while `/abs/path` has no
167/// colon and passes, which is why every POSIX run worked and every Windows run
168/// died in the first Node child. Windows paths reach here already simplified
169/// (no `\\?\`); `\` becomes `/`, a drive path becomes `file:///C:/...`, a UNC
170/// path `file://server/share/...`, and every byte outside the URL-safe set is
171/// percent-encoded so a space or a `#` in a temp directory cannot break it.
172pub(crate) fn file_url(path: &Path) -> String {
173    let text = path.to_string_lossy();
174    #[cfg(windows)]
175    let text = text.replace('\\', "/");
176    file_url_from_slash_path(&text)
177}
178
179/// The pure half: `slash_path` uses `/` separators on every host.
180pub(crate) fn file_url_from_slash_path(slash_path: &str) -> String {
181    let mut url = String::from("file://");
182    let mut rest = slash_path;
183    if let Some(unc) = rest.strip_prefix("//") {
184        // `//server/share/dir` -> `file://server/share/dir`: the host is the server.
185        let (host, tail) = unc.split_once('/').unwrap_or((unc, ""));
186        url.push_str(host);
187        rest = tail;
188        url.push('/');
189    } else if !rest.starts_with('/') {
190        // A drive path such as `C:/dir`.
191        url.push('/');
192    }
193    for byte in rest.bytes() {
194        let keep =
195            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':');
196        if keep {
197            url.push(byte as char);
198        } else {
199            url.push_str(&format!("%{byte:02X}"));
200        }
201    }
202    url
203}
204
205fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
206    WorkspaceError::Io {
207        path: path.to_owned(),
208        source,
209    }
210}
211
212fn unique() -> String {
213    let nanos = SystemTime::now()
214        .duration_since(UNIX_EPOCH)
215        .unwrap_or_default()
216        .as_nanos();
217    format!(
218        "{}-{nanos}-{}",
219        std::process::id(),
220        UNIQUE.fetch_add(1, Ordering::Relaxed)
221    )
222}
223
224fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
225    root.file_name()
226        .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
227}
228
229/// The git repository a project is a directory of, when it is not the
230/// repository itself: a package in a monorepo. None for a project inside one
231/// of Supercov's own copies, which is never laid out again.
232fn enclosing_repository(root: &Path) -> Option<&Path> {
233    let repository = root
234        .ancestors()
235        .skip(1)
236        .find(|directory| directory.join(".git").exists())?;
237    let relative = root.strip_prefix(repository).ok()?;
238    if relative
239        .components()
240        .any(|component| component.as_os_str() == ".supercov")
241    {
242        return None;
243    }
244    Some(repository)
245}
246
247/// Where in a copy directory the project's copy sits: under its own name, or,
248/// for a package of a repository, at its path in the repository, so that
249/// what its suite reaches as `../` is where it is in the checkout.
250fn copy_layout(root: &Path) -> Result<PathBuf, WorkspaceError> {
251    let Some(repository) = enclosing_repository(root) else {
252        return Ok(PathBuf::from(project_name(root)?));
253    };
254    let name = repository
255        .file_name()
256        .ok_or_else(|| WorkspaceError::UnsafePath(repository.into()))?;
257    let relative = root
258        .strip_prefix(repository)
259        .map_err(|_| WorkspaceError::UnsafePath(root.into()))?;
260    Ok(Path::new(name).join(relative))
261}
262
263/// Around a package's copy, the rest of its repository: each directory on
264/// the way up holds links to its entries in the checkout. A Playwright
265/// `webServer` running `go run ../cmd/testserver`, or a tsconfig extending
266/// `../../tsconfig.base.json`, found nothing beside a copy of the package
267/// alone. Only the package is copied: what lies outside it is the checkout's,
268/// as when the suite runs there, and `.git` is never shared. Best effort.
269fn link_repository_around(root: &Path, workspace: &Path) {
270    let Some(repository) = enclosing_repository(root) else {
271        return;
272    };
273    let (mut real, mut copy) = (root, workspace);
274    while real != repository {
275        let (Some(real_parent), Some(copy_parent)) = (real.parent(), copy.parent()) else {
276            return;
277        };
278        let Ok(entries) = fs::read_dir(real_parent) else {
279            return;
280        };
281        for entry in entries.filter_map(Result::ok) {
282            let name = entry.file_name();
283            // A lockfile is never reached as `../`, and a tool that takes the
284            // topmost one as the root (Next.js) found the repository's twice,
285            // beside the copy and in the checkout above `.supercov`, and
286            // warned of a second lockfile the run alone had.
287            if Some(name.as_os_str()) == real.file_name()
288                || matches!(
289                    name.to_str(),
290                    Some(
291                        ".git"
292                            | ".supercov"
293                            | "package-lock.json"
294                            | "npm-shrinkwrap.json"
295                            | "yarn.lock"
296                            | "pnpm-lock.yaml"
297                            | "bun.lock"
298                            | "bun.lockb"
299                    )
300                )
301            {
302                continue;
303            }
304            let destination = copy_parent.join(&name);
305            if fs::symlink_metadata(&destination).is_ok() {
306                continue;
307            }
308            let target = entry.path();
309            let directory = fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir());
310            // Windows: a file link needs a privilege a junction does not.
311            if create_link(&target, &destination, directory).is_err() && !directory {
312                let _ = fs::copy(&target, &destination);
313            }
314        }
315        (real, copy) = (real_parent, copy_parent);
316    }
317}
318
319pub fn workspace_container(root: &Path) -> PathBuf {
320    // Everything Supercov keeps in a project lives under .supercov: one
321    // directory to gitignore, one directory to delete. Users interact with
322    // their real files and the CLI; command outputs sync back after every
323    // run, so nothing in the container is ever theirs to fetch.
324    let preferred = root.join(".supercov/workspaces");
325    if fs::symlink_metadata(&preferred).is_err() || owned_workspace_path(&preferred) {
326        return preferred;
327    }
328    let digest = format!("{:x}", Sha256::digest(root.as_os_str().as_encoded_bytes()));
329    for sequence in 0..1_024usize {
330        let name = if sequence == 0 {
331            format!(".supercov/workspaces-{}", &digest[..16])
332        } else {
333            format!(".supercov/workspaces-{}-{sequence}", &digest[..16])
334        };
335        let candidate = root.join(name);
336        if fs::symlink_metadata(&candidate).is_err() || owned_workspace_path(&candidate) {
337            return candidate;
338        }
339    }
340    root.join(format!(".supercov/workspaces-{}-overflow", &digest[..16]))
341}
342
343/// A Cargo configuration in one of Supercov's own directories above the
344/// isolated copy of `root`. Cargo reads `.cargo/config.toml` from every
345/// ancestor of where it builds, so one there would change how the copy
346/// builds -- `[env]`, a runner, an alias -- while the project's own `cargo
347/// test` never sees it. The project's own `.cargo`, and those above the
348/// project, apply to both and are not Supercov's to judge.
349pub fn planted_cargo_configuration(root: &Path, workspace: &Path) -> Option<PathBuf> {
350    if !workspace.starts_with(root) {
351        return None;
352    }
353    // A repository's own, linked around a package's copy, applies to the
354    // checkout too.
355    let repository = enclosing_repository(root).and_then(|path| canonicalize_simplified(path).ok());
356    workspace
357        .ancestors()
358        .skip(1)
359        .take_while(|directory| *directory != root)
360        .map(|directory| directory.join(".cargo"))
361        .filter(|path| fs::symlink_metadata(path).is_ok())
362        .find(|path| {
363            repository.as_ref().is_none_or(|repository| {
364                !canonicalize_simplified(path).is_ok_and(|real| real.starts_with(repository))
365            })
366        })
367}
368
369/// The directory, in the container, that holds a copy of the project:
370/// `workspace` for the first, the one a run alone takes, and `workspace-2`
371/// for the one a run takes while the first is in use.
372fn copy_directory_name(copy: usize) -> String {
373    if copy <= 1 {
374        "workspace".into()
375    } else {
376        format!("workspace-{copy}")
377    }
378}
379
380/// Everything in the container that belongs to one copy: the copy, and the
381/// view of the author's source kept beside it.
382pub(crate) fn copy_directories(container: &Path, copy: usize) -> [PathBuf; 2] {
383    let name = copy_directory_name(copy);
384    [
385        container.join(&name),
386        container.join(name.replacen("workspace", "source", 1)),
387    ]
388}
389
390/// Where the copy a run holds is.
391pub fn cached_workspace_path(root: &Path, lock: &ProjectLock) -> Result<PathBuf, WorkspaceError> {
392    copy_path(root, lock.copy())
393}
394
395/// Where one of the project's copies is, by its number.
396pub fn copy_path(root: &Path, copy: usize) -> Result<PathBuf, WorkspaceError> {
397    Ok(workspace_container(root)
398        .join(copy_directory_name(copy))
399        .join(copy_layout(root)?))
400}
401
402pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
403    if run_id.is_empty()
404        || run_id == "."
405        || run_id == ".."
406        || run_id
407            .chars()
408            .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
409    {
410        return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
411    }
412    Ok(workspace_container(root)
413        .join("work")
414        .join(run_id)
415        .join(copy_layout(root)?))
416}
417
418pub(crate) fn owned_workspace_path(path: &Path) -> bool {
419    fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
420        && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
421            .is_ok_and(|metadata| metadata.file_type().is_file())
422        && fs::read(path.join(WORKSPACE_MARKER))
423            .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
424}
425
426fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
427    let container = workspace_container(root);
428    let mut created = false;
429    match fs::symlink_metadata(&container) {
430        Ok(metadata) if !metadata.file_type().is_dir() => {
431            return Err(WorkspaceError::UnsafePath(container));
432        }
433        Ok(_) => {}
434        Err(error) if error.kind() == io::ErrorKind::NotFound => {
435            let store = container.parent().expect("container parent");
436            fs::create_dir_all(store).map_err(|source| io_error(store, source))?;
437            // One directory covers everything Supercov writes; make Git
438            // ignore it without the user touching their own .gitignore.
439            let store_ignore = root.join(".supercov/.gitignore");
440            if fs::symlink_metadata(&store_ignore).is_err() {
441                let _ = fs::write(&store_ignore, b"*\n");
442            }
443            // Made complete and then named, in one step. A run starting at
444            // the same moment took a container that existed and did not yet
445            // hold its marker for someone else's directory, kept its copy in
446            // another, and then could not find it.
447            let made = store.join(format!(".workspaces-{}", unique()));
448            fs::create_dir(&made).map_err(|source| io_error(&made, source))?;
449            fs::write(made.join(WORKSPACE_MARKER), WORKSPACE_MARKER_CONTENTS)
450                .map_err(|source| io_error(&made, source))?;
451            match fs::rename(&made, &container) {
452                Ok(()) => created = true,
453                // Another run named its own first.
454                Err(_) => {
455                    let _ = fs::remove_dir_all(&made);
456                }
457            }
458        }
459        Err(source) => return Err(io_error(&container, source)),
460    }
461    let result = (|| {
462        if !created && !owned_workspace_path(&container) {
463            return Err(WorkspaceError::UnsafePath(container.clone()));
464        }
465        for (name, contents) in [
466            (".gitignore", b"*\n".as_slice()),
467            (WORKSPACE_MARKER, WORKSPACE_MARKER_CONTENTS),
468        ] {
469            let path = container.join(name);
470            match OpenOptions::new().write(true).create_new(true).open(&path) {
471                Ok(mut file) => file
472                    .write_all(contents)
473                    .and_then(|_| file.sync_all())
474                    .map_err(|source| io_error(&path, source))?,
475                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
476                    if !fs::symlink_metadata(&path)
477                        .is_ok_and(|metadata| metadata.file_type().is_file())
478                    {
479                        return Err(WorkspaceError::UnsafePath(path));
480                    }
481                    if name == WORKSPACE_MARKER
482                        && !fs::read(&path)
483                            .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
484                    {
485                        return Err(WorkspaceError::UnsafePath(path));
486                    }
487                }
488                Err(source) => return Err(io_error(&path, source)),
489            }
490        }
491        Ok(container.clone())
492    })();
493    if result.is_err() && created {
494        let _ = fs::remove_dir_all(&container);
495    }
496    result
497}
498
499fn lexical_normalize(path: &Path) -> Option<PathBuf> {
500    let mut normalized = PathBuf::new();
501    for component in path.components() {
502        match component {
503            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
504            Component::RootDir => normalized.push(component.as_os_str()),
505            Component::CurDir => {}
506            Component::ParentDir => {
507                if !normalized.pop() {
508                    return None;
509                }
510            }
511            Component::Normal(value) => normalized.push(value),
512        }
513    }
514    Some(normalized)
515}
516
517fn inside(root: &Path, path: &Path) -> bool {
518    path == root
519        || path.strip_prefix(root).is_ok_and(|local| {
520            !local.as_os_str().is_empty()
521                && local
522                    .components()
523                    .all(|component| matches!(component, Component::Normal(_)))
524        })
525}
526
527trait WorkspaceOperations {
528    fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
529    fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
530}
531
532struct SystemWorkspaceOperations;
533
534impl WorkspaceOperations for SystemWorkspaceOperations {
535    fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
536        // Per-file APFS clonefile calls can cost tens of milliseconds for tiny
537        // files—far more than copying their bytes. Preserve CoW for genuinely
538        // large artifacts, while ordinary source/config files take the fast
539        // portable path.
540        const REFLINK_MINIMUM_BYTES: u64 = 1024 * 1024;
541        let bytes = fs::symlink_metadata(source)
542            .map_err(|source_error| io_error(source, source_error))?
543            .len();
544        if bytes < REFLINK_MINIMUM_BYTES {
545            fs::copy(source, destination)
546                .map(|_| ())
547                .map_err(|source_error| io_error(destination, source_error))
548        } else {
549            reflink_copy::reflink_or_copy(source, destination)
550                .map(|_| ())
551                .map_err(|source_error| io_error(destination, source_error))
552        }
553    }
554
555    fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
556        atomic_rename(source, destination).map_err(WorkspaceError::from)
557    }
558}
559
560#[cfg(unix)]
561pub(crate) fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
562    std::os::unix::fs::symlink(target, destination)
563}
564
565#[cfg(windows)]
566pub(crate) fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
567    if directory {
568        // Junctions work on ordinary NTFS installations without requiring
569        // Developer Mode or SeCreateSymbolicLinkPrivilege. This is the common
570        // path for dependency mounts and internal directory links. A project
571        // may still live on a non-NTFS/network filesystem where junctions are
572        // unavailable but unprivileged symlinks are enabled, so preserve that
573        // valid fallback instead of assuming one Windows filesystem.
574        match junction::create(target, destination) {
575            Ok(()) => Ok(()),
576            Err(junction_error) => {
577                let _ = fs::remove_dir(destination);
578                std::os::windows::fs::symlink_dir(target, destination).map_err(
579                    |symlink_error| {
580                        io::Error::new(
581                            symlink_error.kind(),
582                            format!(
583                                "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
584                            ),
585                        )
586                    },
587                )
588            }
589        }
590    } else {
591        // A project that already contains a file symlink necessarily runs in
592        // an environment capable of creating one. Do not silently copy it:
593        // that can change Node realpath/module-identity semantics.
594        std::os::windows::fs::symlink_file(target, destination)
595    }
596}
597
598/// Clone a directory copy-on-write. `false` means the platform or filesystem
599/// cannot (Linux has no directory reflink; APFS refuses across volumes and for
600/// trees holding entries a clone cannot carry), and the caller falls back to
601/// entry links. Any partial destination is removed first so the fallback
602/// starts from a clean slate.
603#[cfg(target_os = "macos")]
604fn clone_directory(source: &Path, destination: &Path) -> bool {
605    use std::{ffi::CString, os::unix::ffi::OsStrExt};
606
607    let (Ok(source_c), Ok(destination_c)) = (
608        CString::new(source.as_os_str().as_bytes()),
609        CString::new(destination.as_os_str().as_bytes()),
610    ) else {
611        return false;
612    };
613    // SAFETY: both arguments are valid NUL-terminated paths that outlive the
614    // call, and clonefile touches nothing else.
615    let status = unsafe { libc::clonefile(source_c.as_ptr(), destination_c.as_ptr(), 0) };
616    if status == 0 {
617        return true;
618    }
619    let _ = fs::remove_dir_all(destination);
620    false
621}
622
623#[cfg(all(unix, not(target_os = "macos")))]
624fn clone_directory(_source: &Path, _destination: &Path) -> bool {
625    false
626}
627
628/// Materialise `source` at `destination` without copying bytes: real
629/// directories, one hard link per file, symlinks recreated verbatim. This is
630/// the mount-safe form where a directory cannot be cloned (Linux has no
631/// directory reflink): a container or VM that mounts the workspace sees real
632/// dependency files where entry links would dangle. Hard links share inodes
633/// with the originals, so an in-place write still reaches the user's tree --
634/// the caveat entry links already carry -- but replacing an entry, which is
635/// what npm does, only unlinks the workspace's name. `false` means the tree
636/// could not be linked (another volume, protected files, an entry that is
637/// neither file, directory nor symlink) and the caller falls back to entry
638/// links after the partial destination is removed.
639#[cfg(unix)]
640fn hard_link_tree(source: &Path, destination: &Path) -> bool {
641    fn link(source: &Path, destination: &Path) -> io::Result<()> {
642        fs::create_dir(destination)?;
643        for entry in fs::read_dir(source)? {
644            let entry = entry?;
645            let from = entry.path();
646            let to = destination.join(entry.file_name());
647            let file_type = entry.file_type()?;
648            if file_type.is_dir() {
649                link(&from, &to)?;
650            } else if file_type.is_symlink() {
651                std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?;
652            } else if file_type.is_file() {
653                fs::hard_link(&from, &to)?;
654            } else {
655                return Err(io::Error::new(
656                    io::ErrorKind::Unsupported,
657                    "entry is neither a file, a directory nor a symlink",
658                ));
659            }
660        }
661        Ok(())
662    }
663    if link(source, destination).is_ok() {
664        return true;
665    }
666    let _ = fs::remove_dir_all(destination);
667    false
668}
669
670#[derive(Clone, Copy)]
671struct CopyRoots<'a> {
672    source: &'a Path,
673    destination: &'a Path,
674    final_destination: &'a Path,
675    canonical_source: &'a Path,
676}
677
678fn copy_tree<Operations: WorkspaceOperations>(
679    source: &Path,
680    destination: &Path,
681    roots: CopyRoots<'_>,
682    root_level: bool,
683    operations: &mut Operations,
684) -> Result<(), WorkspaceError> {
685    fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
686    let mut entries = fs::read_dir(source)
687        .map_err(|error| io_error(source, error))?
688        .collect::<Result<Vec<_>, _>>()
689        .map_err(|error| io_error(source, error))?;
690    entries.sort_by_key(fs::DirEntry::file_name);
691    for entry in entries {
692        let name = entry.file_name();
693        let name_text = name
694            .to_str()
695            .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
696        if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
697            || NESTED_EXCLUSIONS.contains(&name_text)
698        {
699            continue;
700        }
701        let from = entry.path();
702        let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
703        if metadata.file_type().is_dir() && owned_workspace_path(&from) {
704            continue;
705        }
706        let to = destination.join(&name);
707        // A Python virtual environment is never instrumented, and copied it
708        // loses its interpreter: `bin/python` links outside the project, and
709        // such links are left out, so a server started as `.venv/bin/python`
710        // did not start at all. The copy uses the environment itself.
711        if metadata.file_type().is_dir() && from.join("pyvenv.cfg").is_file() {
712            create_link(&from, &to, true).map_err(|error| io_error(&to, error))?;
713            continue;
714        }
715        if metadata.file_type().is_dir() {
716            // Nested node_modules are never instrumented, so they are not
717            // mirrored file by file: on a real monorepo (many packages and
718            // examples, each with node_modules) the deep copy was 43-52
719            // seconds of every run's startup.
720            //
721            // Preferred: a copy-on-write clone of the whole directory. It is
722            // one call on APFS (85k files in ~1.2s, no bytes duplicated until
723            // written) and leaves the workspace self-contained -- a suite that
724            // mounts the workspace into a VM or container sees real dependency
725            // trees, and writes stay in the workspace instead of passing
726            // through to the user's tree.
727            //
728            // Next best, where directories cannot be cloned (Linux): the same
729            // tree as hard links -- real directories inside a mount, no bytes
730            // copied, one link call per file.
731            //
732            // Last resort: one symlink per package pointing at the original
733            // tree, the same semantics the root has. The directory itself is
734            // real, so tools creating NEW entries (vite's .cache) still write
735            // into the workspace, but the links dangle wherever the original
736            // path is not visible (a mounted VM), and npm then treats them as
737            // broken installs and fails to re-link them across the mount.
738            #[cfg(unix)]
739            if name_text == "node_modules" && !root_level {
740                if clone_directory(&from, &to) || hard_link_tree(&from, &to) {
741                    // A workspace package's own tools are started from its
742                    // own `.bin`, which comes before the root's on PATH.
743                    crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
744                        .map_err(|error| io_error(&to, error))?;
745                    continue;
746                }
747                fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
748                let mut packages = fs::read_dir(&from)
749                    .map_err(|error| io_error(&from, error))?
750                    .collect::<Result<Vec<_>, _>>()
751                    .map_err(|error| io_error(&from, error))?;
752                packages.sort_by_key(fs::DirEntry::file_name);
753                for package in packages {
754                    let link_destination = to.join(package.file_name());
755                    if package.file_name() == ".bin" && package.path().is_dir() {
756                        link_tools(&package.path(), &link_destination, operations)?;
757                        continue;
758                    }
759                    create_link(&package.path(), &link_destination, false)
760                        .map_err(|error| io_error(&link_destination, error))?;
761                }
762                continue;
763            }
764            copy_tree(&from, &to, roots, false, operations)?;
765            if name_text == "node_modules" && !root_level {
766                crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
767                    .map_err(|error| io_error(&to, error))?;
768            }
769        } else if metadata.file_type().is_symlink() {
770            let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
771            let unresolved_target = if link.is_absolute() {
772                link.clone()
773            } else {
774                from.parent().expect("entry parent").join(&link)
775            };
776            let Some(lexical_target) = lexical_normalize(&unresolved_target) else {
777                omit_escaping_link(&from, &link);
778                continue;
779            };
780            // A dangling symlink is a fact of the user's tree that their own
781            // tooling tolerates: npm and pnpm workspaces routinely leave links
782            // to packages that are not installed, and plain `npm test` never
783            // resolves them. Refusing to mirror the project over one broke a
784            // real monorepo on first touch. It resolves to nothing, so it can
785            // leak nothing; the LEXICAL containment check still applies, and
786            // the link is preserved as-is so the workspace matches the source.
787            let canonical_target = match canonicalize_simplified(&from) {
788                Ok(target) => Some(target),
789                Err(error) if error.kind() == io::ErrorKind::NotFound => None,
790                Err(error) => return Err(io_error(&from, error)),
791            };
792            let escapes = !inside(roots.source, &lexical_target)
793                || canonical_target
794                    .as_deref()
795                    .is_some_and(|target| !inside(roots.canonical_source, target));
796            if escapes {
797                omit_escaping_link(&from, &link);
798                continue;
799            }
800            let local_target = lexical_target
801                .strip_prefix(roots.source)
802                .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
803            let relocated = roots.destination.join(local_target);
804            let Some(canonical_target) = canonical_target else {
805                if cfg!(windows) {
806                    // Windows link creation needs the target's type, which a
807                    // dangling link cannot provide; the entry resolves to
808                    // nothing either way.
809                    continue;
810                }
811                let isolated_link = if link.is_absolute() {
812                    pathdiff(&to, &relocated)?
813                } else {
814                    link
815                };
816                create_link(&isolated_link, &to, false).map_err(|error| io_error(&to, error))?;
817                continue;
818            };
819            let target_metadata = fs::metadata(&canonical_target)
820                .map_err(|error| io_error(&canonical_target, error))?;
821            let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
822                roots.final_destination.join(local_target)
823            } else if link.is_absolute() {
824                pathdiff(&to, &relocated)?
825            } else {
826                link
827            };
828            create_link(&isolated_link, &to, target_metadata.is_dir())
829                .map_err(|error| io_error(&to, error))?;
830        } else if metadata.file_type().is_file() {
831            operations.copy_file(&from, &to)?;
832        }
833        // A socket or FIFO -- a dev server's `tmp/puma.sock`, a database
834        // socket kept under the project -- cannot be copied and holds no
835        // source. Refusing it stopped the run over a file no test reads by
836        // copy.
837    }
838    Ok(())
839}
840
841fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
842    let from = from
843        .parent()
844        .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
845    let from_components = from.components().collect::<Vec<_>>();
846    let to_components = to.components().collect::<Vec<_>>();
847    let common = from_components
848        .iter()
849        .zip(&to_components)
850        .take_while(|(left, right)| left == right)
851        .count();
852    if common == 0 {
853        return Err(WorkspaceError::UnsafePath(to.into()));
854    }
855    let mut relative = PathBuf::new();
856    for _ in common..from_components.len() {
857        relative.push("..");
858    }
859    for component in &to_components[common..] {
860        relative.push(component.as_os_str());
861    }
862    Ok(relative)
863}
864
865/// One entry of the root `node_modules`. A package of the project's own
866/// workspaces -- `node_modules/money` linked to `../packages/money` by npm,
867/// pnpm or Yarn -- links to its copy in the workspace, which is the
868/// instrumented one: linked to the project's entry it resolved to the
869/// original, so another package's tests ran its code unmeasured and a change
870/// to it never named them. Everything else links to the project's entry. A
871/// scope directory (`@acme/`) is mirrored and its packages linked the same
872/// way. Links to a copy are relative, so they hold when the staged workspace
873/// is moved into place.
874#[cfg(unix)]
875fn link_package(
876    root: &Path,
877    workspace: &Path,
878    target: &Path,
879    to: &Path,
880) -> Result<(), WorkspaceError> {
881    let metadata = fs::symlink_metadata(target).map_err(|error| io_error(target, error))?;
882    let scope = target
883        .file_name()
884        .and_then(|name| name.to_str())
885        .is_some_and(|name| name.starts_with('@'));
886    if scope && metadata.file_type().is_dir() {
887        fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
888        let mut packages = fs::read_dir(target)
889            .map_err(|error| io_error(target, error))?
890            .collect::<Result<Vec<_>, _>>()
891            .map_err(|error| io_error(target, error))?;
892        packages.sort_by_key(fs::DirEntry::file_name);
893        for package in packages {
894            link_package(
895                root,
896                workspace,
897                &package.path(),
898                &to.join(package.file_name()),
899            )?;
900        }
901        return Ok(());
902    }
903    if metadata.file_type().is_symlink()
904        && let (Ok(resolved), Ok(canonical_root)) = (
905            canonicalize_simplified(target),
906            canonicalize_simplified(root),
907        )
908        && let Ok(local) = resolved.strip_prefix(&canonical_root)
909        && !local.as_os_str().is_empty()
910        && !local.components().any(|part| {
911            matches!(
912                part.as_os_str().to_str(),
913                Some("node_modules" | ".supercov")
914            )
915        })
916    {
917        let copy = workspace.join(local);
918        if fs::symlink_metadata(&copy).is_ok() {
919            return create_link(&pathdiff(to, &copy)?, to, true)
920                .map_err(|error| io_error(to, error));
921        }
922    }
923    create_link(target, to, false).map_err(|error| io_error(to, error))
924}
925
926/// The workspace's own `node_modules/.bin`: every tool of the project's, and
927/// in place of each that judges source as text a launcher that runs it on
928/// the source as it was written (see `source_tools`). Package managers put
929/// this directory first on a script's PATH, so nothing else decides what
930/// `biome` in a test script is.
931fn link_tools<Operations: WorkspaceOperations>(
932    tools: &Path,
933    to: &Path,
934    operations: &mut Operations,
935) -> Result<(), WorkspaceError> {
936    #[cfg(unix)]
937    let _ = &operations;
938    fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
939    let judges = crate::source_tools::source_tools();
940    let binary = crate::source_tools::launching_binary();
941    let mut entries = fs::read_dir(tools)
942        .map_err(|error| io_error(tools, error))?
943        .collect::<Result<Vec<_>, _>>()
944        .map_err(|error| io_error(tools, error))?;
945    entries.sort_by_key(fs::DirEntry::file_name);
946    for entry in entries {
947        let (tool, link) = (entry.path(), to.join(entry.file_name()));
948        let launcher = binary.as_deref().and_then(|binary| {
949            crate::source_tools::launcher_for(&judges, binary, tools, &entry.file_name())
950        });
951        if let Some(launcher) = launcher {
952            crate::source_tools::write_launcher(&link, &launcher)
953                .map_err(|error| io_error(&link, error))?;
954            continue;
955        }
956        // A link, where one needs no privilege. On Windows the shims are
957        // small files that find their package from where they are, which
958        // the workspace's `node_modules` has a link for.
959        #[cfg(unix)]
960        create_link(&tool, &link, false).map_err(|error| io_error(&link, error))?;
961        #[cfg(windows)]
962        if fs::metadata(&tool).is_ok_and(|metadata| metadata.is_file()) {
963            operations.copy_file(&tool, &link)?;
964        }
965    }
966    Ok(())
967}
968
969fn link_node_modules<Operations: WorkspaceOperations>(
970    root: &Path,
971    workspace: &Path,
972    operations: &mut Operations,
973) -> Result<(), WorkspaceError> {
974    #[cfg(unix)]
975    let _ = &operations;
976    let source = root.join("node_modules");
977    let source_metadata = match fs::symlink_metadata(&source) {
978        Ok(metadata) => metadata,
979        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
980        Err(error) => return Err(io_error(&source, error)),
981    };
982    // pnpm setups routinely make the root node_modules itself a symlink into a
983    // store elsewhere. The mirror links each entry to its absolute target
984    // anyway, so following the root link loses no isolation -- refusing it
985    // forced one user to materialise a 3.7 GB tree by hand.
986    let source = if source_metadata.file_type().is_symlink() {
987        let resolved =
988            canonicalize_simplified(&source).map_err(|error| io_error(&source, error))?;
989        if !fs::symlink_metadata(&resolved)
990            .map_err(|error| io_error(&resolved, error))?
991            .file_type()
992            .is_dir()
993        {
994            return Err(WorkspaceError::UnsafePath(source));
995        }
996        resolved
997    } else if source_metadata.file_type().is_dir() {
998        source
999    } else {
1000        return Err(WorkspaceError::UnsafePath(source));
1001    };
1002    let destination = workspace.join("node_modules");
1003    fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
1004    let mut entries = fs::read_dir(&source)
1005        .map_err(|error| io_error(&source, error))?
1006        .collect::<Result<Vec<_>, _>>()
1007        .map_err(|error| io_error(&source, error))?;
1008    entries.sort_by_key(fs::DirEntry::file_name);
1009    for entry in entries {
1010        let target = entry.path();
1011        let to = destination.join(entry.file_name());
1012        if entry.file_name() == ".bin"
1013            && fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir())
1014        {
1015            link_tools(&target, &to, operations)?;
1016            continue;
1017        }
1018        #[cfg(unix)]
1019        link_package(root, workspace, &target, &to)?;
1020        #[cfg(windows)]
1021        {
1022            let file_type = entry
1023                .file_type()
1024                .map_err(|error| io_error(&target, error))?;
1025            let resolved = if file_type.is_symlink() {
1026                canonicalize_simplified(&target).map_err(|error| io_error(&target, error))?
1027            } else {
1028                target.clone()
1029            };
1030            let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
1031            if metadata.is_dir() {
1032                create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
1033            } else if metadata.is_file() {
1034                // Top-level node_modules metadata files are cheap to copy and a
1035                // link would unnecessarily require Windows symlink privileges.
1036                operations.copy_file(&resolved, &to)?;
1037            } else {
1038                return Err(WorkspaceError::UnsupportedEntry(target));
1039            }
1040        }
1041    }
1042    Ok(())
1043}
1044
1045fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
1046    if lock.protects(root) {
1047        Ok(())
1048    } else {
1049        Err(WorkspaceError::MissingLock)
1050    }
1051}
1052
1053pub fn prepare_isolated_workspace(
1054    root: &Path,
1055    run_id: &str,
1056    lock: &ProjectLock,
1057) -> Result<PathBuf, WorkspaceError> {
1058    require_lock(root, lock)?;
1059    ensure_container(root)?;
1060    let mut operations = SystemWorkspaceOperations;
1061    let workspace = isolated_workspace_path(root, run_id)?;
1062    remove_stored_tree_deferred(root, &workspace)?;
1063    let canonical_root = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1064    copy_tree(
1065        root,
1066        &workspace,
1067        CopyRoots {
1068            source: root,
1069            destination: &workspace,
1070            final_destination: &workspace,
1071            canonical_source: &canonical_root,
1072        },
1073        true,
1074        &mut operations,
1075    )?;
1076    link_node_modules(root, &workspace, &mut operations)?;
1077    link_repository_around(root, &workspace);
1078    Ok(workspace)
1079}
1080
1081fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
1082    Ok(format!(
1083        ".{}.{}-",
1084        project_name(root)?.to_string_lossy(),
1085        kind
1086    ))
1087}
1088
1089fn transaction_path(
1090    root: &Path,
1091    lock: &ProjectLock,
1092    kind: &str,
1093) -> Result<PathBuf, WorkspaceError> {
1094    let workspace = cached_workspace_path(root, lock)?;
1095    Ok(workspace.parent().expect("workspace parent").join(format!(
1096        "{}{}",
1097        transaction_prefix(root, kind)?,
1098        unique()
1099    )))
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1103#[serde(rename_all = "camelCase")]
1104pub struct CacheRecoveryResult {
1105    pub restored_previous: bool,
1106    pub removed_staging: usize,
1107    pub removed_previous: usize,
1108}
1109
1110pub fn recover_cached_workspace(
1111    root: &Path,
1112    lock: &ProjectLock,
1113) -> Result<CacheRecoveryResult, WorkspaceError> {
1114    require_lock(root, lock)?;
1115    let workspace = cached_workspace_path(root, lock)?;
1116    let parent = workspace.parent().expect("workspace parent");
1117    let entries = match fs::read_dir(parent) {
1118        Ok(entries) => entries,
1119        Err(error) if error.kind() == io::ErrorKind::NotFound => {
1120            return Ok(CacheRecoveryResult {
1121                restored_previous: false,
1122                removed_staging: 0,
1123                removed_previous: 0,
1124            });
1125        }
1126        Err(error) => return Err(io_error(parent, error)),
1127    };
1128    let staging_prefix = transaction_prefix(root, "staging")?;
1129    let previous_prefix = transaction_prefix(root, "previous")?;
1130    let mut staging = Vec::new();
1131    let mut previous = Vec::new();
1132    let mut invalid_previous = Vec::new();
1133    for entry in entries {
1134        let entry = entry.map_err(|error| io_error(parent, error))?;
1135        let name = entry.file_name().to_string_lossy().into_owned();
1136        if name.starts_with(&staging_prefix) {
1137            staging.push(entry.path());
1138        } else if name.starts_with(&previous_prefix) {
1139            let metadata = entry
1140                .file_type()
1141                .map_err(|error| io_error(&entry.path(), error))?;
1142            if metadata.is_dir() {
1143                let modified = entry
1144                    .metadata()
1145                    .and_then(|metadata| metadata.modified())
1146                    .unwrap_or(UNIX_EPOCH);
1147                previous.push((modified, entry.path()));
1148            } else {
1149                invalid_previous.push(entry.path());
1150            }
1151        }
1152    }
1153    previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
1154    let mut restored = false;
1155    if fs::symlink_metadata(&workspace).is_err()
1156        && let Some((_, newest)) = previous.first()
1157    {
1158        atomic_rename(newest, &workspace)?;
1159        previous.remove(0);
1160        restored = true;
1161    }
1162    let removed_staging = staging.len();
1163    let removed_previous = previous.len() + invalid_previous.len();
1164    for path in staging
1165        .into_iter()
1166        .chain(previous.into_iter().map(|(_, path)| path))
1167        .chain(invalid_previous)
1168    {
1169        remove_stored_tree_deferred(root, &path)?;
1170    }
1171    Ok(CacheRecoveryResult {
1172        restored_previous: restored,
1173        removed_staging,
1174        removed_previous,
1175    })
1176}
1177
1178fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
1179    if requested.is_absolute()
1180        || requested
1181            .components()
1182            .any(|component| !matches!(component, Component::Normal(_)))
1183    {
1184        return Err(WorkspaceError::UnsafePath(requested.into()));
1185    }
1186    let path = workspace.join(requested);
1187    if path == workspace || fs::symlink_metadata(&path).is_err() {
1188        return Err(WorkspaceError::UnsafePath(requested.into()));
1189    }
1190    Ok(path)
1191}
1192
1193pub fn prepare_cached_workspace(
1194    root: &Path,
1195    lock: &ProjectLock,
1196    reuse_paths: &[PathBuf],
1197) -> Result<PathBuf, WorkspaceError> {
1198    let mut operations = SystemWorkspaceOperations;
1199    prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
1200}
1201
1202fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1203    root: &Path,
1204    lock: &ProjectLock,
1205    reuse_paths: &[PathBuf],
1206    operations: &mut Operations,
1207) -> Result<PathBuf, WorkspaceError> {
1208    require_lock(root, lock)?;
1209    ensure_container(root)?;
1210    recover_cached_workspace(root, lock)?;
1211    let workspace = cached_workspace_path(root, lock)?;
1212    let staging = transaction_path(root, lock, "staging")?;
1213    let previous = transaction_path(root, lock, "previous")?;
1214    let result = (|| {
1215        let canonical_root =
1216            canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1217        copy_tree(
1218            root,
1219            &staging,
1220            CopyRoots {
1221                source: root,
1222                destination: &staging,
1223                final_destination: &workspace,
1224                canonical_source: &canonical_root,
1225            },
1226            true,
1227            operations,
1228        )?;
1229        link_node_modules(root, &staging, operations)?;
1230        for requested in reuse_paths {
1231            let from = checked_reuse_path(&workspace, requested)?;
1232            let to = staging.join(requested);
1233            // Output directories are excluded from the mirror only at the
1234            // root, so the staging tree may already hold a stale source copy
1235            // of a nested artifact. The cached artifact is the exact
1236            // post-build state and replaces it wholesale.
1237            match fs::symlink_metadata(&to) {
1238                Ok(existing) if existing.file_type().is_dir() => {
1239                    fs::remove_dir_all(&to).map_err(|error| io_error(&to, error))?;
1240                }
1241                Ok(_) => {
1242                    fs::remove_file(&to).map_err(|error| io_error(&to, error))?;
1243                }
1244                Err(_) => {}
1245            }
1246            let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
1247            if metadata.file_type().is_dir() {
1248                let canonical_workspace = canonicalize_simplified(&workspace)
1249                    .map_err(|error| io_error(&workspace, error))?;
1250                copy_tree(
1251                    &from,
1252                    &to,
1253                    CopyRoots {
1254                        source: &workspace,
1255                        destination: &staging,
1256                        final_destination: &workspace,
1257                        canonical_source: &canonical_workspace,
1258                    },
1259                    false,
1260                    operations,
1261                )?;
1262            } else if metadata.file_type().is_file() {
1263                fs::create_dir_all(to.parent().expect("reuse parent"))
1264                    .map_err(|error| io_error(&to, error))?;
1265                operations.copy_file(&from, &to)?;
1266            } else {
1267                return Err(WorkspaceError::UnsupportedEntry(from));
1268            }
1269        }
1270        let mut moved_previous = false;
1271        if fs::symlink_metadata(&workspace).is_ok() {
1272            operations.rename(&workspace, &previous)?;
1273            moved_previous = true;
1274        }
1275        if let Err(error) = operations.rename(&staging, &workspace) {
1276            if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1277                let _ = operations.rename(&previous, &workspace);
1278            }
1279            return Err(error);
1280        }
1281        if fs::symlink_metadata(&previous).is_ok() {
1282            remove_stored_tree_deferred(root, &previous)?;
1283        }
1284        link_repository_around(root, &workspace);
1285        Ok(workspace.clone())
1286    })();
1287    if fs::symlink_metadata(&staging).is_ok() {
1288        remove_stored_tree_deferred(root, &staging)?;
1289    }
1290    result
1291}
1292
1293/// The state of every regular file in the mirror the moment before the
1294/// wrapped command starts: relative path to (length, modified time). Cheap to
1295/// take (stat only) and precise enough to attribute changes to the command.
1296pub struct WorkspaceOutputBaseline {
1297    entries: BTreeMap<PathBuf, (u64, SystemTime)>,
1298}
1299
1300/// What flowed back to the real project after the wrapped command finished.
1301#[derive(Debug, Default, PartialEq, Eq)]
1302pub struct CommandOutputSync {
1303    pub synced: usize,
1304    /// Where the synced files went, for the line that reports them.
1305    pub synced_paths: Vec<PathBuf>,
1306    /// Source files the command modified inside the workspace. Their mirror
1307    /// copies are instrumented, so copying them back would inject probes into
1308    /// the user's repository; they are reported instead.
1309    pub skipped_instrumented: Vec<PathBuf>,
1310    /// Files the command deleted inside the workspace. Deletions are reported
1311    /// rather than propagated: a defect here would destroy user data.
1312    pub deleted_in_workspace: Vec<PathBuf>,
1313}
1314
1315fn walk_output_files(
1316    workspace: &Path,
1317    directory: &Path,
1318    entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1319) -> Result<(), WorkspaceError> {
1320    for entry in fs::read_dir(directory)
1321        .map_err(|error| io_error(directory, error))?
1322        .collect::<Result<Vec<_>, _>>()
1323        .map_err(|error| io_error(directory, error))?
1324    {
1325        let path = entry.path();
1326        let name = entry.file_name();
1327        let relative = path
1328            .strip_prefix(workspace)
1329            .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1330            .to_owned();
1331        // Supercov's generated state never flows back, `.git` is not a
1332        // command output, and symlinks (the node_modules link above all) point
1333        // at the real project already.
1334        if relative.components().count() == 1
1335            && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1336        {
1337            continue;
1338        }
1339        let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1340        // Dependencies are never command outputs. Nested node_modules may be
1341        // materialised clones (see copy_tree), and a tool's cache inside any
1342        // node_modules must not flow back into the project's dependency tree.
1343        if metadata.is_dir() && name.to_str() == Some("node_modules") {
1344            continue;
1345        }
1346        if fs::symlink_metadata(&path)
1347            .map_err(|error| io_error(&path, error))?
1348            .file_type()
1349            .is_symlink()
1350        {
1351            continue;
1352        }
1353        if metadata.is_dir() {
1354            walk_output_files(workspace, &path, entries)?;
1355        } else if metadata.is_file() {
1356            let modified = metadata
1357                .modified()
1358                .map_err(|error| io_error(&path, error))?;
1359            entries.insert(relative, (metadata.len(), modified));
1360        }
1361    }
1362    Ok(())
1363}
1364
1365pub fn workspace_output_baseline(
1366    workspace: &Path,
1367) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1368    let mut entries = BTreeMap::new();
1369    walk_output_files(workspace, workspace, &mut entries)?;
1370    Ok(WorkspaceOutputBaseline { entries })
1371}
1372
1373/// Refuse to copy through any pre-existing symlink component under the
1374/// project root, so a command output can never be redirected outside it.
1375fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1376    if relative.as_os_str().is_empty()
1377        || relative
1378            .components()
1379            .any(|component| !matches!(component, Component::Normal(_)))
1380    {
1381        return Err(WorkspaceError::UnsafePath(relative.into()));
1382    }
1383    let mut current = root.to_owned();
1384    for component in relative.components() {
1385        current.push(component.as_os_str());
1386        match fs::symlink_metadata(&current) {
1387            Ok(metadata) if metadata.file_type().is_symlink() => {
1388                return Err(WorkspaceError::UnsafePath(current));
1389            }
1390            Ok(_) => {}
1391            Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1392            Err(error) => return Err(io_error(&current, error)),
1393        }
1394    }
1395    Ok(())
1396}
1397
1398/// Paths by the place a reader would look for them: each top-level directory
1399/// with how many of the files it holds, the largest first, and files at the
1400/// root by name. "synced 2553 file(s)" named none of them, and 2,547 were a
1401/// build nobody expected in the checkout.
1402pub fn summarize_paths(paths: &[PathBuf]) -> String {
1403    const SHOWN: usize = 4;
1404    let mut places = BTreeMap::<String, usize>::new();
1405    for path in paths {
1406        let mut components = path.components();
1407        let first = components
1408            .next()
1409            .map(|component| component.as_os_str().to_string_lossy().into_owned())
1410            .unwrap_or_default();
1411        let place = if components.next().is_some() {
1412            format!("{first}/")
1413        } else {
1414            first
1415        };
1416        *places.entry(place).or_default() += 1;
1417    }
1418    let mut places = places.into_iter().collect::<Vec<_>>();
1419    places.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1420    let mut shown = places
1421        .iter()
1422        .take(SHOWN)
1423        .map(|(place, count)| {
1424            if place.ends_with('/') {
1425                format!("{place} {count}")
1426            } else {
1427                place.clone()
1428            }
1429        })
1430        .collect::<Vec<_>>();
1431    if places.len() > SHOWN {
1432        let rest = places[SHOWN..]
1433            .iter()
1434            .map(|(_, count)| count)
1435            .sum::<usize>();
1436        shown.push(format!("{rest} elsewhere"));
1437    }
1438    shown.join(", ")
1439}
1440
1441/// The build output directories of files built from instrumented source.
1442///
1443/// Such a directory is the outermost one holding the file that the project
1444/// either ignores in git or does not have: `.next`, `dist`, `build`. A
1445/// directory the project tracks is not one, so a generated file next to
1446/// updated snapshots withholds only itself.
1447struct BuiltDirectories<'a> {
1448    root: &'a Path,
1449    judged: BTreeMap<PathBuf, bool>,
1450    directories: BTreeSet<PathBuf>,
1451}
1452
1453impl<'a> BuiltDirectories<'a> {
1454    fn new(root: &'a Path) -> Self {
1455        Self {
1456            root,
1457            judged: BTreeMap::new(),
1458            directories: BTreeSet::new(),
1459        }
1460    }
1461
1462    fn add(&mut self, relative: &Path) {
1463        let mut directory = PathBuf::new();
1464        let Some(parent) = relative.parent() else {
1465            return;
1466        };
1467        for component in parent.components() {
1468            directory.push(component);
1469            let root = self.root;
1470            let output = *self
1471                .judged
1472                .entry(directory.clone())
1473                .or_insert_with(|| is_build_output(root, &directory));
1474            if output {
1475                self.directories.insert(directory);
1476                return;
1477            }
1478        }
1479    }
1480
1481    fn holds(&self, relative: &Path) -> bool {
1482        relative
1483            .ancestors()
1484            .skip(1)
1485            .any(|directory| self.directories.contains(directory))
1486    }
1487}
1488
1489/// Cargo's target directories inside a copy, judged once each. Anything
1490/// Cargo built in a copy was compiled from its instrumented sources: a test
1491/// command that ran `cargo build` there synced thousands of files back over
1492/// the project's `target/`, and the project's next build linked crates with
1493/// probes in them. A target directory is told by what Cargo keeps in it:
1494/// `.rustc_info.json` at its root, `.cargo-lock` in each profile directory,
1495/// and a `CACHEDIR.TAG` naming Cargo -- which Cargo writes only when it made
1496/// the directory itself, and a test had already made `target/` there.
1497struct CargoTargets<'a> {
1498    workspace: &'a Path,
1499    judged: BTreeMap<PathBuf, bool>,
1500}
1501
1502impl<'a> CargoTargets<'a> {
1503    fn new(workspace: &'a Path) -> Self {
1504        Self {
1505            workspace,
1506            judged: BTreeMap::new(),
1507        }
1508    }
1509
1510    fn hold(&mut self, relative: &Path) -> bool {
1511        let mut directory = PathBuf::new();
1512        let Some(parent) = relative.parent() else {
1513            return false;
1514        };
1515        for component in parent.components() {
1516            directory.push(component);
1517            let workspace = self.workspace;
1518            let target = *self.judged.entry(directory.clone()).or_insert_with(|| {
1519                let path = workspace.join(&directory);
1520                path.join(".rustc_info.json").is_file()
1521                    || path.join(".cargo-lock").is_file()
1522                    || fs::read(path.join("CACHEDIR.TAG"))
1523                        .is_ok_and(|tag| tag.windows(5).any(|window| window == b"cargo"))
1524            });
1525            if target {
1526                return true;
1527            }
1528        }
1529        false
1530    }
1531}
1532
1533/// A bundle, a module or the source map of one.
1534fn compiled_javascript(path: &Path) -> bool {
1535    path.extension()
1536        .and_then(|extension| extension.to_str())
1537        .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "map"))
1538}
1539
1540/// A wasm module with Supercov's region export in it: built from a crate
1541/// the run instrumented.
1542fn instrumented_wasm(path: &Path) -> bool {
1543    path.extension()
1544        .is_some_and(|extension| extension == "wasm")
1545        && fs::read(path).is_ok_and(|bytes| {
1546            bytes
1547                .windows(b"__supercov_region_".len())
1548                .any(|window| window == b"__supercov_region_")
1549        })
1550}
1551
1552fn is_build_output(root: &Path, directory: &Path) -> bool {
1553    if fs::symlink_metadata(root.join(directory)).is_err() {
1554        return true;
1555    }
1556    std::process::Command::new("git")
1557        .arg("-C")
1558        .arg(root)
1559        .args(["check-ignore", "-q", "--"])
1560        .arg(directory)
1561        .env("GIT_TERMINAL_PROMPT", "0")
1562        .stdin(std::process::Stdio::null())
1563        .stdout(std::process::Stdio::null())
1564        .stderr(std::process::Stdio::null())
1565        .status()
1566        .is_ok_and(|status| status.code() == Some(0))
1567}
1568
1569/// A text file larger than this is copied as it is.
1570const NAMED_FILE_LIMIT: u64 = 16 << 20;
1571
1572/// The spellings of the copy's directory a file can hold, each with the
1573/// project's directory spelled the same way: as the path is, as JSON writes
1574/// a Windows path (`C:\\project`), and as a URL writes one (`C:/project`).
1575fn copy_and_project(root: &Path, workspace: &Path) -> Vec<(String, String)> {
1576    let mut places = Vec::new();
1577    let mut add = |copy: &Path, project: &Path| {
1578        let (copy, project) = (copy.to_string_lossy(), project.to_string_lossy());
1579        for (from, to) in [("\\", "\\"), ("\\", "\\\\"), ("\\", "/")] {
1580            let pair = (copy.replace(from, to), project.replace(from, to));
1581            if !pair.0.is_empty() && pair.0 != pair.1 && !places.contains(&pair) {
1582                places.push(pair);
1583            }
1584        }
1585    };
1586    add(workspace, root);
1587    if let (Ok(copy), Ok(project)) = (
1588        canonicalize_simplified(workspace),
1589        canonicalize_simplified(root),
1590    ) {
1591        add(&copy, &project);
1592    }
1593    // The longest first, so one spelling inside another is not cut short.
1594    places.sort_by_key(|(copy, _)| std::cmp::Reverse(copy.len()));
1595    places
1596}
1597
1598/// The text of a file the command wrote, with the copy's directory replaced
1599/// by the project's, when the file is text that names the copy.
1600///
1601/// The command ran in the copy, so anything that wrote where it was wrote
1602/// the copy's path: a coverage report's `coverage-final.json` keyed every
1603/// file by it, `clover.xml` and a Cobertura report's `<source>` named it, and
1604/// a snapshot of a working directory holds it. Run without Supercov the same
1605/// files name the project, and a file that flows back to the project is to
1606/// be the file the command alone would have left there.
1607fn naming_the_project(
1608    file: &Path,
1609    places: &[(String, String)],
1610) -> Result<Option<String>, WorkspaceError> {
1611    let metadata = fs::metadata(file).map_err(|error| io_error(file, error))?;
1612    if !metadata.is_file() || metadata.len() > NAMED_FILE_LIMIT {
1613        return Ok(None);
1614    }
1615    let bytes = fs::read(file).map_err(|error| io_error(file, error))?;
1616    if bytes.contains(&0) {
1617        return Ok(None);
1618    }
1619    let Ok(text) = String::from_utf8(bytes) else {
1620        return Ok(None);
1621    };
1622    Ok(project_named(&text, places))
1623}
1624
1625/// What a Go or JVM command prints, passed on with the copy named as the
1626/// project. Maven ends a failed run with "Please refer to
1627/// <copy>/target/surefire-reports", Gradle with a `file://` address of its
1628/// report in the copy, and Go names a file in a compile error from where it
1629/// runs: `../../../../crypto.go:9:2` for `./crypto.go:9:2`.
1630pub fn console_names(root: &Path, workspace: &Path) -> crate::process_supervision::ConsoleNames {
1631    let up = workspace
1632        .strip_prefix(root)
1633        .ok()
1634        .map(|inside| inside.components().count())
1635        .filter(|levels| *levels > 0)
1636        .map(|levels| "../".repeat(levels));
1637    crate::process_supervision::ConsoleNames {
1638        places: copy_and_project(root, workspace),
1639        up_to_the_project: up,
1640    }
1641}
1642
1643/// `text` with each spelling of the copy's directory replaced by the
1644/// project's, when it holds one.
1645pub(crate) fn project_named(text: &str, places: &[(String, String)]) -> Option<String> {
1646    if !places.iter().any(|(copy, _)| text.contains(copy.as_str())) {
1647        return None;
1648    }
1649    let mut out = String::with_capacity(text.len());
1650    let mut rest = text;
1651    // The next place the copy is named, the longest spelling where two start
1652    // together (`places` is sorted so).
1653    while let Some((at, copy, project)) = places
1654        .iter()
1655        .filter_map(|(copy, project)| Some((rest.find(copy.as_str())?, copy, project)))
1656        .min_by_key(|(at, _, _)| *at)
1657    {
1658        out.push_str(&rest[..at]);
1659        let after = &rest[at + copy.len()..];
1660        // The directory itself, not a neighbour whose name begins with it
1661        // (`app` beside `app-old`).
1662        let continues = after
1663            .chars()
1664            .next()
1665            .is_some_and(|next| next.is_alphanumeric() || matches!(next, '-' | '_'));
1666        if continues {
1667            let first = rest[at..].chars().next().expect("a match is not empty");
1668            out.push(first);
1669            rest = &rest[at + first.len_utf8()..];
1670        } else {
1671            out.push_str(project);
1672            rest = after;
1673        }
1674    }
1675    out.push_str(rest);
1676    (out != text).then_some(out)
1677}
1678
1679/// Copy files the wrapped command created or changed in the mirror back to
1680/// the real project, so `supercov -- <command>` leaves the working tree in
1681/// the same state `<command>` alone would have: updated snapshots, generated
1682/// fixtures, and reports land in the repository, not in a cache directory.
1683/// `protected` names relative paths whose mirror copies are instrumented and
1684/// must never flow back.
1685pub fn sync_command_outputs(
1686    root: &Path,
1687    workspace: &Path,
1688    baseline: &WorkspaceOutputBaseline,
1689    protected: &BTreeSet<PathBuf>,
1690) -> Result<CommandOutputSync, WorkspaceError> {
1691    sync_outputs_of(
1692        CommandOutputs::JavaScript,
1693        root,
1694        workspace,
1695        baseline,
1696        protected,
1697    )
1698}
1699
1700/// Whose command ran in the copy, for what of its output is the copy's own.
1701///
1702/// A JavaScript build carries the instrumentation in what it compiles, and
1703/// is recognised by the runtime it imports. Go builds nothing into the tree
1704/// unless asked, and what it is asked to build holds the runtime's package.
1705/// Maven and Gradle compile into the tree, `target/` and `build/`, and what
1706/// they compile there is the copy's: only the reports they write beside it
1707/// are the command's own. Cargo builds outside the copy.
1708#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1709pub enum CommandOutputs {
1710    JavaScript,
1711    Go,
1712    Jvm,
1713    Rust,
1714}
1715
1716/// A Go binary built in the copy: it links the runtime, whose package path
1717/// is in it.
1718fn links_the_go_runtime(path: &Path) -> Result<bool, WorkspaceError> {
1719    const MARKER: &[u8] = b"_supercov_runtime";
1720    let bytes = fs::read(path).map_err(|error| io_error(path, error))?;
1721    Ok(bytes.contains(&0) && bytes.windows(MARKER.len()).any(|window| window == MARKER))
1722}
1723
1724/// The directories a JVM build writes its reports to, inside its output.
1725const JVM_REPORTS: &[&str] = &[
1726    "surefire-reports",
1727    "failsafe-reports",
1728    "test-results",
1729    "reports",
1730    "site",
1731];
1732
1733/// Whether a file a JVM build wrote is the build's own state or what it
1734/// compiled, rather than a report: anything in Gradle's own directory, and
1735/// anything in a module's output directory outside the report directories.
1736/// An output directory is `target` beside a `pom.xml` and `build` beside a
1737/// Gradle build file, so a package named `build` is not one.
1738fn jvm_build_state(workspace: &Path, relative: &Path) -> bool {
1739    let parts = relative
1740        .components()
1741        .map(|part| part.as_os_str().to_string_lossy().into_owned())
1742        .collect::<Vec<_>>();
1743    if parts.first().is_some_and(|first| first == ".gradle") {
1744        return true;
1745    }
1746    // The copy's build files carry what Supercov added to them.
1747    if parts.last().is_some_and(|name| {
1748        matches!(
1749            name.as_str(),
1750            "pom.xml"
1751                | "build.gradle"
1752                | "build.gradle.kts"
1753                | "settings.gradle"
1754                | "settings.gradle.kts"
1755        )
1756    }) {
1757        return true;
1758    }
1759    if relative
1760        .extension()
1761        .is_some_and(|extension| extension == "class" || extension == "jar")
1762    {
1763        return true;
1764    }
1765    let mut module = workspace.to_path_buf();
1766    for (index, part) in parts.iter().enumerate() {
1767        let output = match part.as_str() {
1768            "target" => module.join("pom.xml").is_file(),
1769            "build" => [
1770                "build.gradle",
1771                "build.gradle.kts",
1772                "settings.gradle",
1773                "settings.gradle.kts",
1774            ]
1775            .iter()
1776            .any(|file| module.join(file).is_file()),
1777            _ => false,
1778        };
1779        if output {
1780            return !parts[index + 1..]
1781                .iter()
1782                .any(|inside| JVM_REPORTS.contains(&inside.as_str()));
1783        }
1784        module.push(part);
1785    }
1786    false
1787}
1788
1789/// `sync_command_outputs` for a command of any of the languages that run in
1790/// a copy.
1791pub fn sync_outputs_of(
1792    kind: CommandOutputs,
1793    root: &Path,
1794    workspace: &Path,
1795    baseline: &WorkspaceOutputBaseline,
1796    protected: &BTreeSet<PathBuf>,
1797) -> Result<CommandOutputSync, WorkspaceError> {
1798    let mut current = BTreeMap::new();
1799    walk_output_files(workspace, workspace, &mut current)?;
1800    let mut sync = CommandOutputSync::default();
1801    // A file the command built FROM an instrumented source carries the
1802    // instrumentation with it. Copying that into the project would leave
1803    // probes and a workspace-only runtime import in the application's own
1804    // build output, which is the one thing isolation promises never
1805    // happens. The generated runtime lives only inside a workspace, so a
1806    // reference to it is proof the file is not the command's own output.
1807    //
1808    // Nor is the rest of the build it belongs to. `next build` wrote 2,547
1809    // files of which a handful imported the runtime; the others (chunks with
1810    // probes, manifests naming workspace paths) were copied, and a later
1811    // `next start` in the project would have served an instrumented build.
1812    let places = copy_and_project(root, workspace);
1813    let mut changed = Vec::new();
1814    let mut built = BTreeSet::new();
1815    let mut built_directories = BuiltDirectories::new(root);
1816    let mut cargo_targets = CargoTargets::new(workspace);
1817    for (relative, state) in &current {
1818        if baseline.entries.get(relative) == Some(state) {
1819            continue;
1820        }
1821        if !protected.contains(relative) {
1822            match kind {
1823                CommandOutputs::JavaScript => {
1824                    // Compiled code marks a build. A report that quotes a
1825                    // stack frame of the runtime does not: the screenshots
1826                    // and traces beside a failed Playwright test's
1827                    // `error-context.md` are the command's own output.
1828                    let compiled = compiled_javascript(relative);
1829                    if built_from_instrumented_source(&workspace.join(relative), compiled)? {
1830                        built.insert(relative.clone());
1831                        if compiled {
1832                            built_directories.add(relative);
1833                        }
1834                    } else if cargo_targets.hold(relative)
1835                        || relative
1836                            .file_name()
1837                            .is_some_and(|name| name == "Cargo.lock")
1838                    {
1839                        // A Rust crate the run instrumented, which the suite
1840                        // built in the copy (a Worker, a wasm library): what
1841                        // Cargo built carries the probes.
1842                        built.insert(relative.clone());
1843                    } else if instrumented_wasm(&workspace.join(relative)) {
1844                        // And so does the wasm generated from it, with the
1845                        // glue beside it: `pkg/` would hand the project a
1846                        // module that records coverage.
1847                        built.insert(relative.clone());
1848                        built_directories.add(relative);
1849                    }
1850                }
1851                CommandOutputs::Go => {
1852                    if links_the_go_runtime(&workspace.join(relative))? {
1853                        built.insert(relative.clone());
1854                    }
1855                }
1856                CommandOutputs::Jvm => {
1857                    if jvm_build_state(workspace, relative) {
1858                        built.insert(relative.clone());
1859                    }
1860                }
1861                // Any Rust file in the copy may have been rewritten, the
1862                // tests among them for their assertions. And the lockfile
1863                // Cargo writes when a project has none is what the run's
1864                // dependencies are read from: returned, it made the run
1865                // that wrote it stale the moment it ended, and every test
1866                // affected by a change to one line. What Cargo built in the
1867                // copy was built from those rewritten files.
1868                CommandOutputs::Rust => {
1869                    if relative
1870                        .extension()
1871                        .is_some_and(|extension| extension == "rs")
1872                        || relative
1873                            .file_name()
1874                            .is_some_and(|name| name == "Cargo.lock")
1875                        || cargo_targets.hold(relative)
1876                    {
1877                        built.insert(relative.clone());
1878                    } else if instrumented_wasm(&workspace.join(relative)) {
1879                        // A Worker a test started had `wrangler dev` build
1880                        // it from the rewritten crate: returned, its `build/`
1881                        // put a module that records coverage in the project,
1882                        // where a build that embeds it would ship it.
1883                        built.insert(relative.clone());
1884                        built_directories.add(relative);
1885                    }
1886                }
1887            }
1888        }
1889        changed.push(relative);
1890    }
1891    for relative in changed {
1892        if protected.contains(relative)
1893            || built.contains(relative)
1894            || built_directories.holds(relative)
1895        {
1896            sync.skipped_instrumented.push(relative.clone());
1897            continue;
1898        }
1899        let from = workspace.join(relative);
1900        if unaccompanied_install_manifest(root, workspace, relative) {
1901            continue;
1902        }
1903        validate_writeback_destination(root, relative)?;
1904        let to = root.join(relative);
1905        if let Some(parent) = to.parent() {
1906            fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1907        }
1908        match naming_the_project(&from, &places)? {
1909            Some(text) => {
1910                fs::write(&to, text).map_err(|error| io_error(&to, error))?;
1911                if let Ok(metadata) = fs::metadata(&from) {
1912                    let _ = fs::set_permissions(&to, metadata.permissions());
1913                }
1914            }
1915            None => {
1916                fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1917            }
1918        }
1919        sync.synced += 1;
1920        sync.synced_paths.push(relative.clone());
1921    }
1922    for relative in baseline.entries.keys() {
1923        // Only what the project still has is worth a line: a rebuilt `.next`
1924        // that never left the workspace "deleted" files nobody can find.
1925        if !current.contains_key(relative) && fs::symlink_metadata(root.join(relative)).is_ok() {
1926            sync.deleted_in_workspace.push(relative.clone());
1927        }
1928    }
1929    Ok(sync)
1930}
1931
1932/// What flowed back and what stayed, as the lines a run prints about it.
1933pub fn describe_command_outputs(outputs: &CommandOutputSync) -> Vec<String> {
1934    let mut lines = Vec::new();
1935    if outputs.synced > 0 {
1936        lines.push(format!(
1937            "[supercov] synced {} file(s) the command created or changed back to the project: {}",
1938            outputs.synced,
1939            summarize_paths(&outputs.synced_paths)
1940        ));
1941    }
1942    if !outputs.skipped_instrumented.is_empty() {
1943        lines.push(format!(
1944            "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
1945            outputs.skipped_instrumented.len(),
1946            summarize_paths(&outputs.skipped_instrumented)
1947        ));
1948    }
1949    if !outputs.deleted_in_workspace.is_empty() {
1950        lines.push(format!(
1951            "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project: {}",
1952            outputs.deleted_in_workspace.len(),
1953            summarize_paths(&outputs.deleted_in_workspace)
1954        ));
1955    }
1956    lines
1957}
1958
1959/// Give the workspace a git repository of its own, as the project has one.
1960///
1961/// Tools in a test command read git: npm's template-oss-check derives the
1962/// repository and release branches it expects from it, and semver's posttest
1963/// failed without one ("\"repository\" ... expected to be removed"). The
1964/// project's own .git is never shared -- a test's `git add` or `commit`
1965/// would then change the user's index and refs -- so this is a clone that
1966/// borrows the project's objects and has its own refs, index and config: the
1967/// project's HEAD, remotes and remote branches, and an index read from HEAD.
1968/// Best effort: without git, or on any failure, the workspace has no .git.
1969pub fn mirror_git_repository(root: &Path, workspace: &Path) -> Result<(), String> {
1970    if fs::symlink_metadata(root.join(".git")).is_err() {
1971        return Ok(());
1972    }
1973    let target = workspace.join(".git");
1974    let git = |directory: &Path, arguments: &[&str]| -> Result<String, String> {
1975        let output = std::process::Command::new("git")
1976            .arg("-C")
1977            .arg(directory)
1978            .args(arguments)
1979            .env("GIT_TERMINAL_PROMPT", "0")
1980            .stdin(std::process::Stdio::null())
1981            .output()
1982            .map_err(|error| format!("git {}: {error}", arguments.join(" ")))?;
1983        if output.status.success() {
1984            Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1985        } else {
1986            Err(format!(
1987                "git {}: {}",
1988                arguments.join(" "),
1989                String::from_utf8_lossy(&output.stderr).trim()
1990            ))
1991        }
1992    };
1993    let result = (|| {
1994        if fs::symlink_metadata(&target).is_ok() {
1995            fs::remove_dir_all(&target).map_err(|error| error.to_string())?;
1996        }
1997        let source = root.to_string_lossy();
1998        let destination = target.to_string_lossy();
1999        git(
2000            workspace,
2001            &[
2002                "clone",
2003                "--quiet",
2004                "--shared",
2005                "--bare",
2006                &source,
2007                &destination,
2008            ],
2009        )?;
2010        git(workspace, &["config", "core.bare", "false"])?;
2011        match git(root, &["symbolic-ref", "-q", "HEAD"]) {
2012            Ok(branch) => git(workspace, &["symbolic-ref", "HEAD", &branch])?,
2013            Err(_) => {
2014                let commit = git(root, &["rev-parse", "HEAD"])?;
2015                git(workspace, &["update-ref", "--no-deref", "HEAD", &commit])?
2016            }
2017        };
2018        git(workspace, &["remote", "remove", "origin"])?;
2019        let remotes = git(root, &["remote"])?;
2020        for name in remotes.lines().filter(|name| !name.is_empty()) {
2021            // As configured: get-url applies the user's insteadOf rewrites,
2022            // which the clone's own reads apply again.
2023            let url = git(root, &["config", "--get", &format!("remote.{name}.url")])?;
2024            git(workspace, &["remote", "add", name, &url])?;
2025        }
2026        git(
2027            workspace,
2028            &[
2029                "fetch",
2030                "--quiet",
2031                "--no-tags",
2032                &source,
2033                "+refs/remotes/*:refs/remotes/*",
2034            ],
2035        )?;
2036        git(workspace, &["read-tree", "HEAD"])?;
2037        // Supercov's own directories are not the command's untracked files.
2038        let exclude = target.join("info/exclude");
2039        fs::create_dir_all(target.join("info")).map_err(|error| error.to_string())?;
2040        let mut patterns = fs::read_to_string(&exclude).unwrap_or_default();
2041        patterns.push_str("\n.supercov/\n");
2042        fs::write(&exclude, patterns).map_err(|error| error.to_string())?;
2043        Ok(())
2044    })();
2045    if result.is_err() {
2046        let _ = fs::remove_dir_all(&target);
2047    }
2048    result
2049}
2050
2051/// Keep the files Supercov rewrote out of the workspace repository's status:
2052/// natively they are unmodified, and a test that checks for a clean tree must
2053/// find one. Best effort, as the repository is.
2054pub fn hide_rewritten_files(workspace: &Path, files: &[String]) {
2055    if files.is_empty() || !workspace.join(".git").is_dir() {
2056        return;
2057    }
2058    let run = |arguments: &[&str], input: &str| {
2059        let mut child = std::process::Command::new("git")
2060            .arg("-C")
2061            .arg(workspace)
2062            .args(arguments)
2063            .stdin(std::process::Stdio::piped())
2064            .stdout(std::process::Stdio::piped())
2065            .stderr(std::process::Stdio::null())
2066            .spawn()
2067            .ok()?;
2068        child.stdin.take()?.write_all(input.as_bytes()).ok()?;
2069        let output = child.wait_with_output().ok()?;
2070        output
2071            .status
2072            .success()
2073            .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
2074    };
2075    // Only tracked files carry the bit; git refuses the rest.
2076    let Some(listed) = run(&["ls-files", "-z"], "") else {
2077        return;
2078    };
2079    let tracked = listed.split('\0').collect::<BTreeSet<_>>();
2080    let hidden = files
2081        .iter()
2082        .filter(|file| tracked.contains(file.as_str()))
2083        .map(String::as_str)
2084        .collect::<Vec<_>>();
2085    if !hidden.is_empty() {
2086        let _ = run(
2087            &["update-index", "--skip-worktree", "--stdin"],
2088            &hidden.join("\n"),
2089        );
2090    }
2091}
2092
2093/// Whether `relative` is the manifest or lockfile of an install the command
2094/// made in the workspace alone. node_modules never flows back, so copying
2095/// these would leave the project claiming an install it does not have: tap
2096/// installs its plugins into .tap/plugins, found package.json and a lockfile
2097/// there on the next run without Supercov, and failed to import them. Left
2098/// out, the tool installs them again as it would the first time.
2099fn unaccompanied_install_manifest(root: &Path, workspace: &Path, relative: &Path) -> bool {
2100    let manifest = relative
2101        .file_name()
2102        .and_then(|name| name.to_str())
2103        .is_some_and(|name| {
2104            matches!(
2105                name,
2106                "package.json"
2107                    | "package-lock.json"
2108                    | "npm-shrinkwrap.json"
2109                    | "yarn.lock"
2110                    | "pnpm-lock.yaml"
2111                    | "bun.lock"
2112                    | "bun.lockb"
2113            )
2114        });
2115    let Some(directory) = relative.parent() else {
2116        return false;
2117    };
2118    manifest
2119        && workspace.join(directory).join("node_modules").is_dir()
2120        && !root.join(directory).join("node_modules").exists()
2121}
2122
2123/// Whether `path` mentions the generated runtime module, which exists only
2124/// inside an instrumented workspace. Read as bytes: build output can be
2125/// minified, source-mapped or not valid UTF-8, and the marker is ASCII.
2126/// Whether a file the command wrote came out of instrumented source: it
2127/// names the generated runtime, which lives only inside a workspace, or it is
2128/// compiled code that calls the probes. A bundler that inlines the runtime
2129/// leaves no path to it in a chunk (Turbopack's name it only in their source
2130/// maps), but the probe calls are still there. A minifier renames the
2131/// probes' local names and cannot rename the global they are read from: a
2132/// Vite production build of the instrumented copy held none of the first
2133/// two, and was synced over the project's `dist/`.
2134fn built_from_instrumented_source(path: &Path, compiled: bool) -> Result<bool, WorkspaceError> {
2135    const RUNTIME: &[u8] = b".supercov/node_modules/";
2136    const PROBE: &[u8] = b"__supercov";
2137    const GLOBAL: &[u8] = b"__SUPERCOV_DIRECT_RUNTIME__";
2138    let contents = match fs::read(path) {
2139        Ok(contents) => contents,
2140        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2141        Err(error) => return Err(io_error(path, error)),
2142    };
2143    let holds = |marker: &[u8]| {
2144        contents
2145            .windows(marker.len())
2146            .any(|window| window == marker)
2147    };
2148    Ok(holds(RUNTIME) || (compiled && (holds(PROBE) || holds(GLOBAL))))
2149}
2150
2151pub fn prune_cached_workspace_sources(
2152    root: &Path,
2153    lock: &ProjectLock,
2154) -> Result<Vec<String>, WorkspaceError> {
2155    require_lock(root, lock)?;
2156    let workspace = cached_workspace_path(root, lock)?;
2157    if fs::symlink_metadata(&workspace).is_err() {
2158        return Ok(Vec::new());
2159    }
2160    let keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
2161    let mut removed = Vec::new();
2162    for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
2163        let entry = entry.map_err(|error| io_error(&workspace, error))?;
2164        let name = entry
2165            .file_name()
2166            .into_string()
2167            .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
2168        if keep.contains(&name) {
2169            continue;
2170        }
2171        remove_stored_tree_deferred(root, &entry.path())?;
2172        removed.push(name);
2173    }
2174    // The configurations and helpers each run writes into the copy's
2175    // `.supercov/` (playwright.config.mjs, vitest.config.mjs ...) are
2176    // written again by the next run. Left behind, they are scripts inside the
2177    // project that a linter run over `.` reads: ESLint's flat config ignores
2178    // only node_modules and .git, so `eslint .` failed after a measured run.
2179    let generated = workspace.join(".supercov");
2180    if let Ok(entries) = fs::read_dir(&generated) {
2181        for entry in entries.flatten() {
2182            let path = entry.path();
2183            let script = path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
2184                matches!(
2185                    e,
2186                    "js" | "mjs" | "cjs" | "ts" | "mts" | "cts" | "jsx" | "tsx"
2187                )
2188            });
2189            if script && entry.file_type().is_ok_and(|kind| kind.is_file()) {
2190                fs::remove_file(&path).map_err(|error| io_error(&path, error))?;
2191                removed.push(format!(".supercov/{}", entry.file_name().to_string_lossy()));
2192            }
2193        }
2194    }
2195    removed.sort();
2196    Ok(removed)
2197}
2198
2199#[cfg(test)]
2200mod tests {
2201    #[test]
2202    fn file_urls_match_what_node_derives_for_every_path_shape() {
2203        use super::file_url_from_slash_path;
2204        assert_eq!(
2205            file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
2206            "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
2207        );
2208        assert_eq!(
2209            file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
2210            "file:///C:/Users/a%20b/%231/register.mjs"
2211        );
2212        assert_eq!(
2213            file_url_from_slash_path("//server/share/dir/register.mjs"),
2214            "file://server/share/dir/register.mjs"
2215        );
2216        assert_eq!(
2217            file_url_from_slash_path("/w/p/register.mjs"),
2218            "file:///w/p/register.mjs"
2219        );
2220        assert_eq!(
2221            file_url_from_slash_path("/w/p x?q/ü.mjs"),
2222            "file:///w/p%20x%3Fq/%C3%BC.mjs"
2223        );
2224    }
2225
2226    #[test]
2227    fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
2228        use super::strip_verbatim;
2229        assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
2230        assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
2231        assert_eq!(
2232            strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
2233            Some(r"\\server\share\dir")
2234        );
2235        assert_eq!(strip_verbatim(r"C:\a\b"), None);
2236        assert_eq!(strip_verbatim("/w/project"), None);
2237        assert_eq!(strip_verbatim(r"\\server\share"), None);
2238        assert_eq!(strip_verbatim(r"\\?\"), None);
2239    }
2240
2241    use super::*;
2242
2243    fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
2244        let base =
2245            std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
2246        if base.exists() {
2247            fs::remove_dir_all(&base).unwrap();
2248        }
2249        let root = base.join("project");
2250        let workspace = base.join("workspace");
2251        fs::create_dir_all(root.join("src")).unwrap();
2252        fs::create_dir_all(workspace.join("src")).unwrap();
2253        fs::create_dir_all(workspace.join(".supercov")).unwrap();
2254        fs::write(root.join("src/app.ts"), "original\n").unwrap();
2255        fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
2256        fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
2257        (root, workspace)
2258    }
2259
2260    #[test]
2261    fn the_workspace_has_a_repository_of_its_own_that_reads_like_the_project() {
2262        let (root, workspace) = writeback_fixture("git-mirror");
2263        let git = |directory: &Path, arguments: &[&str]| {
2264            let output = std::process::Command::new("git")
2265                .arg("-C")
2266                .arg(directory)
2267                .args(arguments)
2268                .output()
2269                .unwrap();
2270            assert!(output.status.success(), "git {arguments:?}: {output:?}");
2271            String::from_utf8(output.stdout).unwrap().trim().to_owned()
2272        };
2273        git(&root, &["init", "--quiet", "--initial-branch=release/v2"]);
2274        git(&root, &["config", "user.email", "test@example.com"]);
2275        git(&root, &["config", "user.name", "Test"]);
2276        git(
2277            &root,
2278            &[
2279                "remote",
2280                "add",
2281                "origin",
2282                "https://github.com/example/app.git",
2283            ],
2284        );
2285        git(&root, &["add", "src/app.ts"]);
2286        git(&root, &["commit", "--quiet", "-m", "first"]);
2287        let head = git(&root, &["rev-parse", "HEAD"]);
2288
2289        mirror_git_repository(&root, &workspace).unwrap();
2290        assert_eq!(git(&workspace, &["rev-parse", "HEAD"]), head);
2291        assert_eq!(
2292            git(&workspace, &["symbolic-ref", "HEAD"]),
2293            "refs/heads/release/v2"
2294        );
2295        assert_eq!(
2296            git(&workspace, &["config", "--get", "remote.origin.url"]),
2297            "https://github.com/example/app.git"
2298        );
2299        // The workspace copy is instrumented and .supercov is Supercov's.
2300        hide_rewritten_files(&workspace, &["src/app.ts".to_owned()]);
2301        assert_eq!(git(&workspace, &["status", "--porcelain"]), "");
2302
2303        // A commit a test makes stays in the workspace's own repository.
2304        fs::write(workspace.join("new.txt"), "new\n").unwrap();
2305        git(&workspace, &["add", "new.txt"]);
2306        git(
2307            &workspace,
2308            &[
2309                "-c",
2310                "user.email=t@example.com",
2311                "-c",
2312                "user.name=T",
2313                "commit",
2314                "--quiet",
2315                "-m",
2316                "second",
2317            ],
2318        );
2319        assert_eq!(git(&root, &["rev-parse", "HEAD"]), head);
2320        assert_eq!(git(&root, &["status", "--porcelain"]), "");
2321        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2322    }
2323
2324    #[test]
2325    fn an_install_the_command_made_does_not_flow_back_without_its_dependencies() {
2326        let (root, workspace) = writeback_fixture("tool-install");
2327        fs::write(workspace.join("package.json"), "{}").unwrap();
2328        fs::write(root.join("package.json"), "{}").unwrap();
2329        fs::create_dir_all(workspace.join("node_modules")).unwrap();
2330        fs::create_dir_all(root.join("node_modules")).unwrap();
2331        let baseline = workspace_output_baseline(&workspace).unwrap();
2332        // tap installs the plugins its config names into .tap/plugins.
2333        fs::create_dir_all(workspace.join(".tap/plugins/node_modules/@tapjs/clock")).unwrap();
2334        fs::write(workspace.join(".tap/plugins/package.json"), "{}").unwrap();
2335        fs::write(workspace.join(".tap/plugins/package-lock.json"), "{}").unwrap();
2336        fs::create_dir_all(workspace.join(".tap/test-results")).unwrap();
2337        fs::write(workspace.join(".tap/test-results/basic.tap"), "ok").unwrap();
2338        // The project's own manifest still flows back beside its own install.
2339        fs::write(workspace.join("package.json"), "{\"version\":\"2\"}").unwrap();
2340        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2341        assert!(!root.join(".tap/plugins/package.json").exists());
2342        assert!(!root.join(".tap/plugins/package-lock.json").exists());
2343        assert!(root.join(".tap/test-results/basic.tap").is_file());
2344        assert_eq!(
2345            fs::read_to_string(root.join("package.json")).unwrap(),
2346            "{\"version\":\"2\"}"
2347        );
2348        assert_eq!(sync.synced, 2);
2349        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2350    }
2351
2352    #[test]
2353    fn a_file_that_flows_back_names_the_project_and_not_the_copy() {
2354        // Vitest's coverage report, written by a command that ran in the copy:
2355        // `coverage-final.json` keyed every file by the copy's path, where the
2356        // same command without Supercov keys it by the project's.
2357        let (root, workspace) = writeback_fixture("names");
2358        let baseline = workspace_output_baseline(&workspace).unwrap();
2359        let (copy, project) = (workspace.display().to_string(), root.display().to_string());
2360        fs::create_dir_all(workspace.join("coverage")).unwrap();
2361        fs::write(
2362            workspace.join("coverage/coverage-final.json"),
2363            format!("{{\"{copy}/src/fee.js\": {{\"path\": \"{copy}/src/fee.js\"}}}}"),
2364        )
2365        .unwrap();
2366        fs::write(
2367            workspace.join("coverage/cobertura.xml"),
2368            format!("<sources><source>{copy}</source></sources>"),
2369        )
2370        .unwrap();
2371        // A neighbour whose name only begins with the copy's is not the copy.
2372        fs::write(
2373            workspace.join("coverage/neighbour.txt"),
2374            format!("{copy}-old/file and {copy}"),
2375        )
2376        .unwrap();
2377        // Bytes that are not text are copied as they are.
2378        let mut binary = copy.clone().into_bytes();
2379        binary.push(0);
2380        fs::write(workspace.join("coverage/cache.bin"), &binary).unwrap();
2381        fs::write(workspace.join("coverage/plain.txt"), "nothing to name").unwrap();
2382        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2383        assert_eq!(sync.synced, 5);
2384        assert_eq!(
2385            fs::read_to_string(root.join("coverage/coverage-final.json")).unwrap(),
2386            format!("{{\"{project}/src/fee.js\": {{\"path\": \"{project}/src/fee.js\"}}}}")
2387        );
2388        assert_eq!(
2389            fs::read_to_string(root.join("coverage/cobertura.xml")).unwrap(),
2390            format!("<sources><source>{project}</source></sources>")
2391        );
2392        assert_eq!(
2393            fs::read_to_string(root.join("coverage/neighbour.txt")).unwrap(),
2394            format!("{copy}-old/file and {project}")
2395        );
2396        assert_eq!(fs::read(root.join("coverage/cache.bin")).unwrap(), binary);
2397        assert_eq!(
2398            fs::read_to_string(root.join("coverage/plain.txt")).unwrap(),
2399            "nothing to name"
2400        );
2401        // The copy keeps what the command wrote.
2402        assert!(
2403            fs::read_to_string(workspace.join("coverage/cobertura.xml"))
2404                .unwrap()
2405                .contains(&copy)
2406        );
2407        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2408    }
2409
2410    #[test]
2411    fn what_a_go_jvm_or_rust_command_writes_flows_back_and_what_it_builds_stays() {
2412        // Only JavaScript's command had its files returned. `go test
2413        // -coverprofile=cover.out` left no `cover.out` in the project, `mvn
2414        // test` no `target/surefire-reports`, and a snapshot a Rust test
2415        // accepted stayed in the copy.
2416        let write = |workspace: &Path, file: &str, contents: &[u8]| {
2417            let path = workspace.join(file);
2418            fs::create_dir_all(path.parent().unwrap()).unwrap();
2419            fs::write(path, contents).unwrap();
2420        };
2421        let flows = |kind: CommandOutputs, name: &str, files: &[(&str, &[u8])]| {
2422            let (root, workspace) = writeback_fixture(name);
2423            // The build files are the copy's before the command runs.
2424            write(&workspace, "pom.xml", b"<project/>");
2425            write(&workspace, "app/build.gradle.kts", b"plugins {}");
2426            let baseline = workspace_output_baseline(&workspace).unwrap();
2427            for (file, contents) in files {
2428                write(&workspace, file, contents);
2429            }
2430            let sync =
2431                sync_outputs_of(kind, &root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2432            let synced = files
2433                .iter()
2434                .filter(|(file, _)| root.join(file).is_file())
2435                .map(|(file, _)| (*file).to_owned())
2436                .collect::<Vec<_>>();
2437            assert_eq!(sync.synced, synced.len(), "{sync:?}");
2438            fs::remove_dir_all(root.parent().unwrap()).unwrap();
2439            synced
2440        };
2441        let mut binary = b"\x7fELF\0\0example.com/shop/_supercov_runtime\0".to_vec();
2442        binary.extend([0u8; 16]);
2443        assert_eq!(
2444            flows(
2445                CommandOutputs::Go,
2446                "go",
2447                &[
2448                    ("cover.out", b"mode: set\n"),
2449                    ("testdata/golden.png", b"\x89PNG\0\0"),
2450                    ("shop.test", &binary),
2451                ]
2452            ),
2453            ["cover.out", "testdata/golden.png"]
2454        );
2455        assert_eq!(
2456            flows(
2457                CommandOutputs::Jvm,
2458                "jvm",
2459                &[
2460                    (
2461                        "target/surefire-reports/TEST-shop.CryptoTest.xml",
2462                        b"<testsuite/>"
2463                    ),
2464                    ("target/site/jacoco/index.html", b"<html/>"),
2465                    ("app/build/test-results/test/TEST-a.xml", b"<testsuite/>"),
2466                    ("app/build/reports/tests/test/index.html", b"<html/>"),
2467                    ("src/test/resources/approved/output.txt", b"approved"),
2468                    // What the build compiled, and its own state.
2469                    ("target/classes/shop/Crypto.class", b"\xca\xfe\xba\xbe"),
2470                    (
2471                        "target/maven-status/compile/createdFiles.lst",
2472                        b"shop/Crypto.class"
2473                    ),
2474                    ("app/build/classes/kotlin/main/A.class", b"\xca\xfe\xba\xbe"),
2475                    ("app/build/tmp/compileKotlin/cache.txt", b"state"),
2476                    (".gradle/8.10/fileHashes/fileHashes.lock", b"lock"),
2477                    ("pom.xml", b"<project><!-- changed --></project>"),
2478                    // A package named `build` is not a build's output.
2479                    ("src/build/notes.txt", b"kept"),
2480                ]
2481            ),
2482            [
2483                "target/surefire-reports/TEST-shop.CryptoTest.xml",
2484                "target/site/jacoco/index.html",
2485                "app/build/test-results/test/TEST-a.xml",
2486                "app/build/reports/tests/test/index.html",
2487                "src/test/resources/approved/output.txt",
2488                "src/build/notes.txt",
2489            ]
2490        );
2491        assert_eq!(
2492            flows(
2493                CommandOutputs::Rust,
2494                "rust",
2495                &[
2496                    ("src/snapshots/sealed.snap", b"SNAP"),
2497                    ("report.json", b"{}"),
2498                    ("Cargo.lock", b"# lock"),
2499                    ("src/generated.rs", b"pub fn made() {}"),
2500                ]
2501            ),
2502            ["src/snapshots/sealed.snap", "report.json"]
2503        );
2504    }
2505
2506    #[test]
2507    fn dependency_trees_never_flow_back() {
2508        // Nested node_modules may be materialised clones, and any node_modules
2509        // can hold a tool's cache: neither is a command output.
2510        let (root, workspace) = writeback_fixture("dependencies");
2511        let baseline = workspace_output_baseline(&workspace).unwrap();
2512        fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
2513        fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
2514        fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
2515        fs::write(
2516            workspace.join("packages/app/node_modules/dep/index.js"),
2517            "dep",
2518        )
2519        .unwrap();
2520        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2521        assert_eq!(sync.synced, 0);
2522        assert!(sync.deleted_in_workspace.is_empty());
2523        assert!(!root.join("node_modules").exists());
2524        assert!(!root.join("packages").exists());
2525        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2526    }
2527
2528    #[test]
2529    fn output_built_from_instrumented_sources_never_flows_back() {
2530        // A build the command runs inside the workspace compiles the
2531        // instrumented copies, so its output carries probes and an import of a
2532        // runtime that exists only in the workspace. Writing that into the
2533        // project would leave the application's own build output instrumented
2534        // after the run, which isolation promises never happens.
2535        let (root, workspace) = writeback_fixture("built-output");
2536        let baseline = workspace_output_baseline(&workspace).unwrap();
2537        fs::create_dir_all(workspace.join("dist")).unwrap();
2538        fs::write(
2539            workspace.join("dist/app.js"),
2540            "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
2541        )
2542        .unwrap();
2543        fs::write(
2544            workspace.join("dist/app.d.ts"),
2545            "export declare const a: number;\n",
2546        )
2547        .unwrap();
2548
2549        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2550
2551        // The declarations beside it were inferred from the instrumented
2552        // copies too, so the whole build stays behind, not a part of it.
2553        assert_eq!(
2554            sync.skipped_instrumented,
2555            vec![PathBuf::from("dist/app.d.ts"), PathBuf::from("dist/app.js")],
2556            "the instrumented build is reported, not copied"
2557        );
2558        assert_eq!(sync.synced, 0);
2559        assert!(!root.join("dist").exists());
2560        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2561    }
2562
2563    #[test]
2564    fn what_cargo_built_in_the_copy_stays_there() {
2565        // A test command that runs `cargo build` in the copy compiles its
2566        // instrumented crates. Synced back, the project's next build linked
2567        // them: Supercov's own end-to-end gates did this to its `target/`.
2568        let (root, workspace) = writeback_fixture("cargo-target");
2569        let baseline = workspace_output_baseline(&workspace).unwrap();
2570        for target in ["target", "tests/fixture/target"] {
2571            fs::create_dir_all(workspace.join(target).join("debug/deps")).unwrap();
2572            fs::write(
2573                workspace.join(target).join("debug/deps/libapp.rlib"),
2574                b"\0rlib",
2575            )
2576            .unwrap();
2577        }
2578        // Cargo made this one, and tagged it.
2579        fs::write(
2580            workspace.join("tests/fixture/target/CACHEDIR.TAG"),
2581            "Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by cargo.\n",
2582        )
2583        .unwrap();
2584        // A test made this one before Cargo built into it: no tag, only what
2585        // Cargo keeps there. Supercov's own suite does this.
2586        fs::write(workspace.join("target/.rustc_info.json"), "{}").unwrap();
2587        fs::write(workspace.join("target/debug/.cargo-lock"), "").unwrap();
2588        // A directory named `target` that Cargo did not make is the
2589        // command's own.
2590        fs::create_dir_all(workspace.join("reports/target")).unwrap();
2591        fs::write(workspace.join("reports/target/summary.txt"), "ok\n").unwrap();
2592
2593        let sync = sync_outputs_of(
2594            CommandOutputs::Rust,
2595            &root,
2596            &workspace,
2597            &baseline,
2598            &BTreeSet::new(),
2599        )
2600        .unwrap();
2601
2602        assert_eq!(
2603            sync.skipped_instrumented,
2604            vec![
2605                PathBuf::from("target/.rustc_info.json"),
2606                PathBuf::from("target/debug/.cargo-lock"),
2607                PathBuf::from("target/debug/deps/libapp.rlib"),
2608                PathBuf::from("tests/fixture/target/CACHEDIR.TAG"),
2609                PathBuf::from("tests/fixture/target/debug/deps/libapp.rlib"),
2610            ]
2611        );
2612        assert_eq!(sync.synced, 1);
2613        assert!(!root.join("target").exists());
2614        assert_eq!(
2615            fs::read_to_string(root.join("reports/target/summary.txt")).unwrap(),
2616            "ok\n"
2617        );
2618        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2619    }
2620
2621    #[test]
2622    fn a_built_file_in_a_directory_the_project_keeps_withholds_only_itself() {
2623        // `src` is the project's own directory, not a build's: the snapshot a
2624        // test updated next to a generated bundle still belongs to the project.
2625        let (root, workspace) = writeback_fixture("built-beside-outputs");
2626        let baseline = workspace_output_baseline(&workspace).unwrap();
2627        fs::write(
2628            workspace.join("src/bundle.js"),
2629            "import \"../.supercov/node_modules/runtime.mjs\";\n",
2630        )
2631        .unwrap();
2632        fs::write(workspace.join("src/app.snap"), "updated\n").unwrap();
2633
2634        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2635
2636        assert_eq!(
2637            sync.skipped_instrumented,
2638            vec![PathBuf::from("src/bundle.js")]
2639        );
2640        assert_eq!(sync.synced, 1);
2641        assert_eq!(
2642            fs::read_to_string(root.join("src/app.snap")).unwrap(),
2643            "updated\n"
2644        );
2645        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2646    }
2647
2648    #[test]
2649    fn paths_are_summarized_by_where_they_are() {
2650        let paths = [
2651            ".next/BUILD_ID",
2652            ".next/server/app.js",
2653            ".next/server/chunks/a.js",
2654            "tests/e2e/runs/1/report.json",
2655            "coverage/report.json",
2656            "playwright-report/index.html",
2657            "notes.txt",
2658            "out/a.txt",
2659        ]
2660        .map(PathBuf::from);
2661        assert_eq!(
2662            summarize_paths(&paths),
2663            ".next/ 3, coverage/ 1, notes.txt, out/ 1, 2 elsewhere"
2664        );
2665        assert_eq!(summarize_paths(&paths[..1]), ".next/ 1");
2666    }
2667
2668    #[test]
2669    fn a_report_that_quotes_the_runtime_does_not_make_its_directory_a_build() {
2670        // A failed Playwright test writes its stack, runtime frames included,
2671        // next to the screenshot and trace a developer opens to debug it.
2672        let (root, workspace) = writeback_fixture("report-quotes-runtime");
2673        let baseline = workspace_output_baseline(&workspace).unwrap();
2674        fs::create_dir_all(workspace.join("test-results/adds-an-item")).unwrap();
2675        fs::write(
2676            workspace.join("test-results/adds-an-item/error-context.md"),
2677            "at launch (/w/.supercov/node_modules/playwright.mjs:907:44)\n",
2678        )
2679        .unwrap();
2680        fs::write(
2681            workspace.join("test-results/adds-an-item/trace.zip"),
2682            "trace",
2683        )
2684        .unwrap();
2685
2686        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2687
2688        assert_eq!(
2689            sync.skipped_instrumented,
2690            vec![PathBuf::from("test-results/adds-an-item/error-context.md")]
2691        );
2692        assert_eq!(sync.synced, 1);
2693        assert!(root.join("test-results/adds-an-item/trace.zip").exists());
2694        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2695    }
2696
2697    #[test]
2698    fn a_build_directory_git_ignores_stays_behind_even_when_the_project_has_one() {
2699        let (root, workspace) = writeback_fixture("built-ignored");
2700        let git = |arguments: &[&str]| {
2701            std::process::Command::new("git")
2702                .arg("-C")
2703                .arg(&root)
2704                .args(arguments)
2705                .output()
2706                .unwrap()
2707        };
2708        if !git(&["init", "-q"]).status.success() {
2709            fs::remove_dir_all(root.parent().unwrap()).unwrap();
2710            return;
2711        }
2712        fs::write(root.join(".gitignore"), ".next\n").unwrap();
2713        fs::create_dir_all(root.join(".next")).unwrap();
2714        fs::write(root.join(".next/BUILD_ID"), "mine\n").unwrap();
2715        let baseline = workspace_output_baseline(&workspace).unwrap();
2716        fs::create_dir_all(workspace.join(".next/server")).unwrap();
2717        fs::write(
2718            workspace.join(".next/server/chunk.js"),
2719            // Turbopack inlines the runtime: a chunk holds probe calls and
2720            // no path to it.
2721            "__supercovCoverageHitV3(__supercovProbeFileV2, 0);\n",
2722        )
2723        .unwrap();
2724        fs::write(workspace.join(".next/BUILD_ID"), "instrumented\n").unwrap();
2725
2726        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2727
2728        assert_eq!(sync.synced, 0);
2729        assert_eq!(sync.skipped_instrumented.len(), 2);
2730        assert_eq!(
2731            fs::read_to_string(root.join(".next/BUILD_ID")).unwrap(),
2732            "mine\n"
2733        );
2734        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2735    }
2736
2737    #[test]
2738    fn a_minified_build_of_the_instrumented_copy_stays_in_the_workspace() {
2739        let (root, workspace) = writeback_fixture("built-minified");
2740        let initialized = std::process::Command::new("git")
2741            .arg("-C")
2742            .arg(&root)
2743            .args(["init", "-q"])
2744            .output()
2745            .unwrap();
2746        if !initialized.status.success() {
2747            fs::remove_dir_all(root.parent().unwrap()).unwrap();
2748            return;
2749        }
2750        fs::write(root.join(".gitignore"), "dist\n").unwrap();
2751        fs::create_dir_all(root.join("dist")).unwrap();
2752        fs::write(root.join("dist/index.html"), "mine\n").unwrap();
2753        let baseline = workspace_output_baseline(&workspace).unwrap();
2754        fs::create_dir_all(workspace.join("dist/assets")).unwrap();
2755        fs::write(
2756            workspace.join("dist/assets/index-DzZxFCVp.js"),
2757            // A minifier renames each probe's local name. The global they
2758            // are read from is a property name, which it leaves.
2759            "const e=globalThis.__SUPERCOV_DIRECT_RUNTIME__.coverageHitV2;e(t,0);\n",
2760        )
2761        .unwrap();
2762        fs::write(workspace.join("dist/index.html"), "instrumented\n").unwrap();
2763
2764        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2765
2766        assert_eq!(sync.synced, 0);
2767        assert_eq!(sync.skipped_instrumented.len(), 2);
2768        assert_eq!(
2769            fs::read_to_string(root.join("dist/index.html")).unwrap(),
2770            "mine\n"
2771        );
2772        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2773    }
2774
2775    #[test]
2776    fn command_outputs_flow_back_to_the_project() {
2777        let (root, workspace) = writeback_fixture("outputs");
2778        let baseline = workspace_output_baseline(&workspace).unwrap();
2779        fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
2780        fs::write(
2781            workspace.join("src/__snapshots__/app.snap"),
2782            "updated snapshot\n",
2783        )
2784        .unwrap();
2785        fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
2786        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2787        assert_eq!(sync.synced, 1);
2788        assert_eq!(
2789            fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
2790            "updated snapshot\n"
2791        );
2792        assert!(!root.join(".supercov/state.json").exists());
2793        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2794    }
2795
2796    #[test]
2797    fn instrumented_sources_never_flow_back() {
2798        let (root, workspace) = writeback_fixture("protected");
2799        let baseline = workspace_output_baseline(&workspace).unwrap();
2800        // A formatter run by the command rewrites the instrumented copy; the
2801        // real source must keep its original bytes.
2802        fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
2803        let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
2804        let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
2805        assert_eq!(sync.synced, 0);
2806        assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
2807        assert_eq!(
2808            fs::read_to_string(root.join("src/app.ts")).unwrap(),
2809            "original\n"
2810        );
2811        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2812    }
2813
2814    #[test]
2815    fn workspace_deletions_are_reported_but_never_propagated() {
2816        let (root, workspace) = writeback_fixture("deletions");
2817        fs::write(workspace.join("stale.txt"), "old\n").unwrap();
2818        fs::write(root.join("stale.txt"), "old\n").unwrap();
2819        // Gone from the workspace only: the project never had it.
2820        fs::write(workspace.join("built.txt"), "old\n").unwrap();
2821        let baseline = workspace_output_baseline(&workspace).unwrap();
2822        fs::remove_file(workspace.join("stale.txt")).unwrap();
2823        fs::remove_file(workspace.join("built.txt")).unwrap();
2824        let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2825        assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
2826        assert!(root.join("stale.txt").exists());
2827        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2828    }
2829
2830    /// Names that arrive from outside -- a run id, a path to reuse, a path to
2831    /// write back -- are refused when they could leave the place they name.
2832    #[test]
2833    fn names_that_could_leave_their_directory_are_refused() {
2834        let (root, workspace) = writeback_fixture("names");
2835        let unsafe_path = |result: Result<PathBuf, WorkspaceError>| {
2836            matches!(result, Err(WorkspaceError::UnsafePath(_)))
2837        };
2838        for run_id in ["", ".", "..", "a/b", "a\\b", "a\0b", "a\nb"] {
2839            assert!(
2840                unsafe_path(isolated_workspace_path(&root, run_id)),
2841                "{run_id:?}"
2842            );
2843        }
2844        let isolated = isolated_workspace_path(&root, "run_1").unwrap();
2845        assert!(isolated.starts_with(workspace_container(&root).join("work").join("run_1")));
2846
2847        fs::write(workspace.join("kept.txt"), "kept\n").unwrap();
2848        for requested in [
2849            workspace.join("kept.txt"),
2850            PathBuf::from("../kept.txt"),
2851            PathBuf::from("./kept.txt"),
2852            PathBuf::new(),
2853            PathBuf::from("missing.txt"),
2854        ] {
2855            assert!(
2856                unsafe_path(checked_reuse_path(&workspace, &requested)),
2857                "{requested:?}"
2858            );
2859        }
2860        assert_eq!(
2861            checked_reuse_path(&workspace, Path::new("kept.txt")).unwrap(),
2862            workspace.join("kept.txt")
2863        );
2864
2865        for relative in ["", "../escape.txt", "/etc/passwd", "a/../../b"] {
2866            assert!(
2867                matches!(
2868                    validate_writeback_destination(&root, Path::new(relative)),
2869                    Err(WorkspaceError::UnsafePath(_))
2870                ),
2871                "{relative:?}"
2872            );
2873        }
2874        // A destination that does not exist yet is fine.
2875        validate_writeback_destination(&root, Path::new("reports/new/result.txt")).unwrap();
2876        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2877    }
2878
2879    #[cfg(unix)]
2880    #[test]
2881    fn writeback_refuses_a_symlinked_destination() {
2882        let (root, workspace) = writeback_fixture("symlink");
2883        let outside = root.parent().unwrap().join("outside");
2884        fs::create_dir_all(&outside).unwrap();
2885        std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
2886        let baseline = workspace_output_baseline(&workspace).unwrap();
2887        fs::create_dir_all(workspace.join("reports")).unwrap();
2888        fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
2889        let error =
2890            sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
2891        assert!(matches!(error, WorkspaceError::UnsafePath(_)));
2892        assert!(!outside.join("result.txt").exists());
2893        fs::remove_dir_all(root.parent().unwrap()).unwrap();
2894    }
2895
2896    struct OrdinaryCopyOperations;
2897
2898    impl WorkspaceOperations for OrdinaryCopyOperations {
2899        fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2900            fs::copy(source, destination)
2901                .map(|_| ())
2902                .map_err(|error| io_error(destination, error))
2903        }
2904
2905        fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2906            atomic_rename(source, destination).map_err(WorkspaceError::from)
2907        }
2908    }
2909
2910    struct FaultOperations {
2911        copy_count: usize,
2912        fail_copy_at: Option<usize>,
2913        rename_count: usize,
2914        fail_rename_at: Option<usize>,
2915    }
2916
2917    impl WorkspaceOperations for FaultOperations {
2918        fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2919            self.copy_count += 1;
2920            if self.fail_copy_at == Some(self.copy_count) {
2921                return Err(io_error(
2922                    destination,
2923                    io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
2924                ));
2925            }
2926            SystemWorkspaceOperations.copy_file(source, destination)
2927        }
2928
2929        fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2930            self.rename_count += 1;
2931            if self.fail_rename_at == Some(self.rename_count) {
2932                return Err(io_error(
2933                    destination,
2934                    io::Error::other("injected publication rename failure"),
2935                ));
2936            }
2937            SystemWorkspaceOperations.rename(source, destination)
2938        }
2939    }
2940
2941    fn transaction_debris(root: &Path) -> Vec<String> {
2942        let workspace = copy_path(root, 1).unwrap();
2943        let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
2944        fs::read_dir(workspace.parent().unwrap())
2945            .unwrap()
2946            .filter_map(Result::ok)
2947            .map(|entry| entry.file_name().to_string_lossy().into_owned())
2948            .filter(|name| name.starts_with(&prefix))
2949            .collect()
2950    }
2951
2952    fn project() -> PathBuf {
2953        let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
2954        fs::create_dir_all(root.join("src")).unwrap();
2955        fs::write(root.join("src/index.js"), "one").unwrap();
2956        fs::write(root.join("package.json"), "{}").unwrap();
2957        root
2958    }
2959
2960    #[test]
2961    #[cfg(unix)]
2962    fn root_node_modules_symlink_is_followed() {
2963        // pnpm layouts often make the project's node_modules a symlink into an
2964        // external store. Refusing it forced a 3.7 GB manual materialisation;
2965        // entries are linked to absolute targets regardless, so following the
2966        // root link is isolation-neutral.
2967        let root = project();
2968        let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
2969        fs::create_dir_all(store.join("left-pad")).unwrap();
2970        fs::write(store.join("left-pad/package.json"), "{}").unwrap();
2971        std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
2972        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2973        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2974        assert!(
2975            fs::symlink_metadata(workspace.join("node_modules/left-pad"))
2976                .unwrap()
2977                .file_type()
2978                .is_symlink()
2979        );
2980        lock.release().unwrap();
2981        fs::remove_dir_all(root).unwrap();
2982        fs::remove_dir_all(store).unwrap();
2983    }
2984
2985    #[test]
2986    fn a_package_copy_sits_where_the_package_is_in_its_repository() {
2987        // skyhook-io/radar's Playwright config in web/ starts its server with
2988        // `go run ../cmd/testserver`, which a copy of web/ alone did not have.
2989        let repository = project();
2990        fs::create_dir_all(repository.join(".git")).unwrap();
2991        fs::create_dir_all(repository.join("cmd/testserver")).unwrap();
2992        fs::write(repository.join("cmd/testserver/main.go"), "package main\n").unwrap();
2993        fs::write(repository.join("tsconfig.base.json"), "{}").unwrap();
2994        fs::write(repository.join("package-lock.json"), "{}").unwrap();
2995        let root = repository.join("packages/web");
2996        fs::create_dir_all(root.join("src")).unwrap();
2997        fs::write(root.join("src/app.js"), "export {};\n").unwrap();
2998        fs::create_dir_all(repository.join("packages/ui")).unwrap();
2999        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3000        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3001        let name = repository.file_name().unwrap();
3002        assert!(workspace.ends_with(Path::new(name).join("packages/web")));
3003        assert!(workspace.join("src/app.js").is_file());
3004        assert_eq!(
3005            fs::read_to_string(workspace.join("../../cmd/testserver/main.go")).unwrap(),
3006            "package main\n"
3007        );
3008        assert!(workspace.join("../../tsconfig.base.json").is_file());
3009        assert!(workspace.join("../ui").is_dir());
3010        // The repository's .git is never shared with a test, and its
3011        // lockfile is not there to be found twice.
3012        assert!(fs::symlink_metadata(workspace.join("../../.git")).is_err());
3013        assert!(fs::symlink_metadata(workspace.join("../../package-lock.json")).is_err());
3014        lock.release().unwrap();
3015        fs::remove_dir_all(repository).unwrap();
3016    }
3017
3018    #[test]
3019    #[cfg(unix)]
3020    fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
3021        // npm and pnpm workspaces routinely leave symlinks to packages that
3022        // are not installed. superinterface's examples/*/node_modules carried
3023        // one, plain `npm test` never resolves it, and the mirror died on
3024        // `canonicalize` with ENOENT before any test ran. A dangling link
3025        // resolves to nothing, so it can leak nothing: it is preserved as-is,
3026        // while the lexical containment check still applies.
3027        let root = project();
3028        let nested = root.join("examples/app/node_modules/@scope");
3029        fs::create_dir_all(&nested).unwrap();
3030        std::os::unix::fs::symlink(
3031            "../../../../packages/react/node_modules/@scope/pkg",
3032            nested.join("pkg"),
3033        )
3034        .unwrap();
3035        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3036        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3037        let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
3038        let metadata = fs::symlink_metadata(&mirrored).unwrap();
3039        assert!(metadata.file_type().is_symlink());
3040        assert_eq!(
3041            fs::read_link(&mirrored).unwrap(),
3042            PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
3043        );
3044        lock.release().unwrap();
3045        fs::remove_dir_all(root).unwrap();
3046    }
3047
3048    #[test]
3049    #[cfg(target_os = "macos")]
3050    fn nested_node_modules_are_cloned_not_linked() {
3051        // A suite that mounts the workspace into a VM sees no host paths, so
3052        // entry links into the original tree dangle there and npm fails to
3053        // re-link them across the mount. APFS clones the directory instead:
3054        // real files, relative links inside kept verbatim, and writes staying
3055        // in the workspace.
3056        let root = project();
3057        let nested = root.join("packages/app/node_modules");
3058        fs::create_dir_all(nested.join("dep")).unwrap();
3059        fs::create_dir_all(nested.join(".bin")).unwrap();
3060        fs::write(nested.join("dep/index.js"), "dep").unwrap();
3061        std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
3062        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3063        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3064        let mirrored = workspace.join("packages/app/node_modules");
3065        assert!(
3066            fs::symlink_metadata(mirrored.join("dep"))
3067                .unwrap()
3068                .file_type()
3069                .is_dir()
3070        );
3071        assert_eq!(
3072            fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
3073            "dep"
3074        );
3075        assert_eq!(
3076            fs::read_link(mirrored.join(".bin/dep")).unwrap(),
3077            PathBuf::from("../dep/index.js")
3078        );
3079        fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
3080        assert_eq!(
3081            fs::read_to_string(nested.join("dep/index.js")).unwrap(),
3082            "dep"
3083        );
3084        lock.release().unwrap();
3085        fs::remove_dir_all(root).unwrap();
3086    }
3087
3088    #[test]
3089    #[cfg(unix)]
3090    fn hard_linked_dependency_tree_is_real_and_replaceable() {
3091        // The Linux materialisation: files share inodes with the originals,
3092        // symlinks are carried verbatim, and replacing an entry in the
3093        // workspace (npm's reify) leaves the user's tree untouched.
3094        use std::os::unix::fs::MetadataExt;
3095        let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
3096        let source = base.join("node_modules");
3097        fs::create_dir_all(source.join("dep")).unwrap();
3098        fs::create_dir_all(source.join(".bin")).unwrap();
3099        fs::write(source.join("dep/index.js"), "dep").unwrap();
3100        std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
3101        let destination = base.join("workspace/node_modules");
3102        fs::create_dir_all(destination.parent().unwrap()).unwrap();
3103        assert!(hard_link_tree(&source, &destination));
3104        assert_eq!(
3105            fs::metadata(destination.join("dep/index.js"))
3106                .unwrap()
3107                .ino(),
3108            fs::metadata(source.join("dep/index.js")).unwrap().ino()
3109        );
3110        assert_eq!(
3111            fs::read_link(destination.join(".bin/dep")).unwrap(),
3112            PathBuf::from("../dep/index.js")
3113        );
3114        fs::remove_dir_all(destination.join("dep")).unwrap();
3115        fs::create_dir_all(destination.join("dep")).unwrap();
3116        fs::write(destination.join("dep/index.js"), "replaced").unwrap();
3117        assert_eq!(
3118            fs::read_to_string(source.join("dep/index.js")).unwrap(),
3119            "dep"
3120        );
3121        fs::remove_dir_all(base).unwrap();
3122    }
3123
3124    #[test]
3125    #[cfg(unix)]
3126    fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
3127        // The escape check guards the MIRRORED source tree. node_modules (root
3128        // and nested) are linked or cloned from the user's originals instead,
3129        // so the escaping fixture lives outside node_modules here.
3130        // Leftover tool state (a Chrome test profile linking into /tmp) must
3131        // not abort measurement: the entry is omitted and the run proceeds.
3132        let root = project();
3133        let nested = root.join("examples/app/lib");
3134        fs::create_dir_all(&nested).unwrap();
3135        std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
3136            .unwrap();
3137        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3138        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3139        assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
3140        assert!(workspace.join("src/index.js").exists());
3141        lock.release().unwrap();
3142        fs::remove_dir_all(root).unwrap();
3143    }
3144
3145    #[test]
3146    fn isolated_copy_never_changes_source_and_requires_the_lock() {
3147        let root = project();
3148        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3149        let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3150        fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
3151        assert_eq!(
3152            fs::read_to_string(root.join("src/index.js")).unwrap(),
3153            "one"
3154        );
3155        lock.release().unwrap();
3156        assert!(matches!(
3157            prepare_isolated_workspace(&root, "other", &lock),
3158            Err(WorkspaceError::MissingLock)
3159        ));
3160        fs::remove_dir_all(root).unwrap();
3161    }
3162
3163    #[test]
3164    fn everything_supercov_writes_lives_under_the_store() {
3165        let root = project();
3166        assert_eq!(
3167            workspace_container(&root),
3168            root.join(".supercov/workspaces")
3169        );
3170        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3171        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3172        assert!(workspace.starts_with(root.join(".supercov")));
3173        assert_eq!(
3174            fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
3175            "*\n"
3176        );
3177        lock.release().unwrap();
3178        fs::remove_dir_all(root).unwrap();
3179    }
3180
3181    #[test]
3182    fn an_unowned_container_gets_a_deterministic_fallback() {
3183        let root = project();
3184        fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
3185        fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
3186        let container = workspace_container(&root);
3187        assert_ne!(container, root.join(".supercov/workspaces"));
3188        let name = container
3189            .file_name()
3190            .unwrap()
3191            .to_string_lossy()
3192            .into_owned();
3193        assert!(name.starts_with("workspaces-"));
3194        fs::remove_dir_all(root).unwrap();
3195    }
3196
3197    #[test]
3198    fn user_supercov_directory_is_copied_and_never_adopted() {
3199        let root = project();
3200        fs::create_dir(root.join("supercov")).unwrap();
3201        fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
3202        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3203        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3204        assert_ne!(workspace_container(&root), root.join("supercov"));
3205        assert_eq!(
3206            fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
3207            "export default 1;\n"
3208        );
3209        assert_eq!(
3210            fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
3211            "export default 1;\n"
3212        );
3213        assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
3214        lock.release().unwrap();
3215        fs::remove_dir_all(root).unwrap();
3216    }
3217
3218    #[cfg(unix)]
3219    #[test]
3220    fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
3221        let root = project();
3222        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3223        let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3224        fs::create_dir_all(first.join("build")).unwrap();
3225        fs::write(first.join("build/output.js"), "instrumented").unwrap();
3226        fs::write(first.join("stale.txt"), "stale").unwrap();
3227        fs::write(root.join("src/index.js"), "two").unwrap();
3228        let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
3229        assert_eq!(first, second);
3230        assert_eq!(
3231            fs::read_to_string(second.join("src/index.js")).unwrap(),
3232            "two"
3233        );
3234        assert_eq!(
3235            fs::read_to_string(second.join("build/output.js")).unwrap(),
3236            "instrumented"
3237        );
3238        assert!(!second.join("stale.txt").exists());
3239        lock.release().unwrap();
3240        fs::remove_dir_all(root).unwrap();
3241    }
3242
3243    #[test]
3244    fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
3245        let root = project();
3246        fs::create_dir_all(root.join("packages/app/dist")).unwrap();
3247        fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
3248        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3249        let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3250        fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
3251        fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
3252        let second =
3253            prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
3254        assert_eq!(
3255            fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
3256            "instrumented"
3257        );
3258        assert!(!second.join("packages/app/dist/stale.js").exists());
3259        lock.release().unwrap();
3260        fs::remove_dir_all(root).unwrap();
3261    }
3262
3263    #[test]
3264    fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
3265        let root = project();
3266        fs::create_dir_all(root.join("tests/e2e")).unwrap();
3267        fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
3268        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3269        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3270        let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
3271        fs::create_dir_all(&artifacts).unwrap();
3272        fs::write(artifacts.join("digest"), "instrumented test").unwrap();
3273        fs::write(
3274            workspace.join(".supercov/frontend-cache.json"),
3275            "{\"schemaVersion\":2}",
3276        )
3277        .unwrap();
3278        fs::write(
3279            workspace.join(".supercov/vitest.config.mjs"),
3280            "export default {}",
3281        )
3282        .unwrap();
3283        fs::write(workspace.join(".supercov/manifest.json"), "{}").unwrap();
3284        prune_cached_workspace_sources(&root, &lock).unwrap();
3285        assert!(!workspace.join("tests/e2e/app.spec.js").exists());
3286        assert_eq!(
3287            fs::read_to_string(artifacts.join("digest")).unwrap(),
3288            "instrumented test"
3289        );
3290        // A script a linter over `.` would read is gone; caches stay.
3291        assert!(!workspace.join(".supercov/vitest.config.mjs").exists());
3292        assert!(workspace.join(".supercov/manifest.json").exists());
3293        lock.release().unwrap();
3294        fs::remove_dir_all(root).unwrap();
3295    }
3296
3297    #[test]
3298    fn ordinary_copy_fallback_preserves_workspace_semantics() {
3299        let root = project();
3300        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3301        let mut operations = OrdinaryCopyOperations;
3302        let workspace =
3303            prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
3304        assert_eq!(
3305            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3306            "one"
3307        );
3308        assert_eq!(
3309            fs::read_to_string(root.join("src/index.js")).unwrap(),
3310            "one"
3311        );
3312        assert!(transaction_debris(&root).is_empty());
3313        lock.release().unwrap();
3314        fs::remove_dir_all(root).unwrap();
3315    }
3316
3317    #[test]
3318    fn enospc_during_copy_preserves_the_complete_generation() {
3319        let root = project();
3320        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3321        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3322        fs::write(workspace.join("generation"), "complete").unwrap();
3323        fs::write(root.join("src/index.js"), "new source").unwrap();
3324        let mut operations = FaultOperations {
3325            copy_count: 0,
3326            fail_copy_at: Some(1),
3327            rename_count: 0,
3328            fail_rename_at: None,
3329        };
3330        let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
3331            .unwrap_err();
3332        assert!(matches!(
3333            error,
3334            WorkspaceError::Io { ref source, .. }
3335                if source.kind() == io::ErrorKind::StorageFull
3336        ));
3337        assert_eq!(
3338            fs::read_to_string(workspace.join("generation")).unwrap(),
3339            "complete"
3340        );
3341        assert_eq!(
3342            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3343            "one"
3344        );
3345        assert!(transaction_debris(&root).is_empty());
3346        lock.release().unwrap();
3347        fs::remove_dir_all(root).unwrap();
3348    }
3349
3350    #[test]
3351    fn failed_publication_rename_restores_the_complete_generation() {
3352        let root = project();
3353        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3354        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3355        fs::write(workspace.join("generation"), "complete").unwrap();
3356        fs::write(root.join("src/index.js"), "new source").unwrap();
3357        let mut operations = FaultOperations {
3358            copy_count: 0,
3359            fail_copy_at: None,
3360            rename_count: 0,
3361            fail_rename_at: Some(2),
3362        };
3363        let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
3364            .unwrap_err();
3365        assert!(matches!(
3366            error,
3367            WorkspaceError::Io { ref source, .. }
3368                if source.kind() == io::ErrorKind::Other
3369        ));
3370        assert_eq!(
3371            operations.rename_count, 3,
3372            "prior generation was not restored"
3373        );
3374        assert_eq!(
3375            fs::read_to_string(workspace.join("generation")).unwrap(),
3376            "complete"
3377        );
3378        assert_eq!(
3379            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3380            "one"
3381        );
3382        assert!(transaction_debris(&root).is_empty());
3383        lock.release().unwrap();
3384        fs::remove_dir_all(root).unwrap();
3385    }
3386
3387    #[cfg(unix)]
3388    #[test]
3389    fn relocates_internal_links_and_rejects_external_links() {
3390        use std::os::unix::fs::symlink;
3391
3392        let root = project();
3393        fs::create_dir_all(root.join("shared")).unwrap();
3394        fs::write(root.join("shared/value"), "inside").unwrap();
3395        symlink("../shared/value", root.join("src/value-link")).unwrap();
3396        let external = project();
3397        fs::write(external.join("outside"), "outside").unwrap();
3398        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3399        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3400        assert_eq!(
3401            fs::read_to_string(workspace.join("src/value-link")).unwrap(),
3402            "inside"
3403        );
3404        symlink(external.join("outside"), root.join("src/external-link")).unwrap();
3405        let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3406        assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
3407        assert_eq!(
3408            fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3409            "one"
3410        );
3411        lock.release().unwrap();
3412        fs::remove_dir_all(root).unwrap();
3413        fs::remove_dir_all(external).unwrap();
3414    }
3415
3416    #[cfg(windows)]
3417    #[test]
3418    fn relocates_internal_junctions_without_symlink_privileges() {
3419        let root = project();
3420        fs::create_dir_all(root.join("shared")).unwrap();
3421        fs::write(root.join("shared/value"), "inside").unwrap();
3422        junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
3423        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3424        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3425        let isolated_link = workspace.join("linked-shared");
3426        assert!(junction::exists(&isolated_link).unwrap());
3427        assert_eq!(
3428            canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
3429            canonicalize_simplified(workspace.join("shared")).unwrap()
3430        );
3431        assert_eq!(
3432            fs::read_to_string(isolated_link.join("value")).unwrap(),
3433            "inside"
3434        );
3435        lock.release().unwrap();
3436        fs::remove_dir_all(root).unwrap();
3437    }
3438
3439    #[cfg(windows)]
3440    #[test]
3441    fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
3442        let root = project();
3443        fs::create_dir_all(root.join("node_modules/example")).unwrap();
3444        fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
3445        fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
3446        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3447        let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3448        let package = workspace.join("node_modules/example");
3449        assert!(junction::exists(&package).unwrap());
3450        assert_eq!(
3451            canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
3452            canonicalize_simplified(root.join("node_modules/example")).unwrap()
3453        );
3454        let metadata = workspace.join("node_modules/.package-lock.json");
3455        assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
3456        assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
3457        lock.release().unwrap();
3458        fs::remove_dir_all(root).unwrap();
3459    }
3460
3461    #[test]
3462    fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
3463        let root = project();
3464        let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3465        ensure_container(&root).unwrap();
3466        let workspace = cached_workspace_path(&root, &lock).unwrap();
3467        let parent = workspace.parent().unwrap();
3468        fs::create_dir_all(parent).unwrap();
3469        let previous = parent.join(format!(
3470            "{}old",
3471            transaction_prefix(&root, "previous").unwrap()
3472        ));
3473        let staging = parent.join(format!(
3474            "{}new",
3475            transaction_prefix(&root, "staging").unwrap()
3476        ));
3477        fs::create_dir_all(&previous).unwrap();
3478        fs::write(previous.join("complete"), "yes").unwrap();
3479        fs::create_dir_all(&staging).unwrap();
3480        let result = recover_cached_workspace(&root, &lock).unwrap();
3481        assert!(result.restored_previous);
3482        assert_eq!(result.removed_staging, 1);
3483        assert_eq!(
3484            fs::read_to_string(workspace.join("complete")).unwrap(),
3485            "yes"
3486        );
3487        lock.release().unwrap();
3488        fs::remove_dir_all(root).unwrap();
3489    }
3490}