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