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