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