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