1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fs::{self, OpenOptions},
6 io::{self, Write},
7 path::{Component, Path, PathBuf},
8 sync::atomic::{AtomicU64, Ordering},
9 time::{SystemTime, UNIX_EPOCH},
10};
11
12use serde::Serialize;
13use sha2::{Digest, Sha256};
14
15use crate::lifecycle::{LifecycleError, ProjectLock, atomic_rename, remove_stored_tree_deferred};
16
17const WORKSPACE_MARKER: &str = ".supercov-workspace-store";
18const WORKSPACE_MARKER_CONTENTS: &[u8] = b"Supercov instrumented workspace. Safe to delete.\n";
19const ROOT_EXCLUSIONS: &[&str] = &[
20 ".cache",
21 ".git",
22 ".supercov",
23 ".mcdc-pool",
24 "node_modules",
25 "build",
26 "dist",
27 ".next",
28 ".nuxt",
29 ".output",
30 "coverage",
31 "playwright-report",
32 "test-results",
33 "target",
34];
35const NESTED_EXCLUSIONS: &[&str] = &[".supercov", ".mcdc-pool"];
36static UNIQUE: AtomicU64 = AtomicU64::new(0);
37
38#[derive(Debug)]
39pub enum WorkspaceError {
40 Io { path: PathBuf, source: io::Error },
41 UnsafePath(PathBuf),
42 UnsupportedEntry(PathBuf),
43 MissingLock,
44 Lifecycle(LifecycleError),
45 UnsupportedPlatform(&'static str),
46}
47
48impl std::fmt::Display for WorkspaceError {
49 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
52 Self::UnsafePath(path) => {
53 write!(formatter, "unsafe workspace path: {}", path.display())
54 }
55 Self::UnsupportedEntry(path) => {
56 write!(
57 formatter,
58 "unsupported filesystem entry in isolated project: {}",
59 path.display()
60 )
61 }
62 Self::MissingLock => write!(
63 formatter,
64 "isolated workspace preparation requires the active project lock"
65 ),
66 Self::Lifecycle(error) => write!(formatter, "{error}"),
67 Self::UnsupportedPlatform(reason) => {
68 write!(formatter, "unsupported workspace platform: {reason}")
69 }
70 }
71 }
72}
73
74impl std::error::Error for WorkspaceError {}
75
76impl From<LifecycleError> for WorkspaceError {
77 fn from(value: LifecycleError) -> Self {
78 Self::Lifecycle(value)
79 }
80}
81
82fn omit_escaping_link(path: &Path, target: &Path) {
87 eprintln!(
88 "[supercov] omitting symlink outside the isolated project: {} -> {}",
89 path.display(),
90 target.display()
91 );
92}
93
94pub(crate) fn canonicalize_simplified<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
102 fs::canonicalize(path).map(simplified)
103}
104
105#[cfg(windows)]
106pub(crate) fn simplified(path: PathBuf) -> PathBuf {
107 match path.to_str().and_then(strip_verbatim) {
108 Some(plain) => PathBuf::from(plain),
109 None => path,
110 }
111}
112
113#[cfg(not(windows))]
114pub(crate) fn simplified(path: PathBuf) -> PathBuf {
115 path
116}
117
118#[cfg_attr(not(windows), allow(dead_code))]
122pub(crate) fn strip_verbatim(path: &str) -> Option<String> {
123 let rest = path.strip_prefix(r"\\?\")?;
124 if let Some(unc) = rest.strip_prefix(r"UNC\") {
125 return Some(format!(r"\\{unc}"));
126 }
127 let mut chars = rest.chars();
128 let drive = chars.next()?;
129 if drive.is_ascii_alphabetic() && chars.next() == Some(':') {
130 return Some(rest.to_owned());
131 }
132 None
133}
134
135pub(crate) fn file_url(path: &Path) -> String {
144 let text = path.to_string_lossy();
145 #[cfg(windows)]
146 let text = text.replace('\\', "/");
147 file_url_from_slash_path(&text)
148}
149
150pub(crate) fn file_url_from_slash_path(slash_path: &str) -> String {
152 let mut url = String::from("file://");
153 let mut rest = slash_path;
154 if let Some(unc) = rest.strip_prefix("//") {
155 let (host, tail) = unc.split_once('/').unwrap_or((unc, ""));
157 url.push_str(host);
158 rest = tail;
159 url.push('/');
160 } else if !rest.starts_with('/') {
161 url.push('/');
163 }
164 for byte in rest.bytes() {
165 let keep =
166 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':');
167 if keep {
168 url.push(byte as char);
169 } else {
170 url.push_str(&format!("%{byte:02X}"));
171 }
172 }
173 url
174}
175
176fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
177 WorkspaceError::Io {
178 path: path.to_owned(),
179 source,
180 }
181}
182
183fn unique() -> String {
184 let nanos = SystemTime::now()
185 .duration_since(UNIX_EPOCH)
186 .unwrap_or_default()
187 .as_nanos();
188 format!(
189 "{}-{nanos}-{}",
190 std::process::id(),
191 UNIQUE.fetch_add(1, Ordering::Relaxed)
192 )
193}
194
195fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
196 root.file_name()
197 .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
198}
199
200pub fn workspace_container(root: &Path) -> PathBuf {
201 let preferred = root.join(".supercov/workspaces");
206 if fs::symlink_metadata(&preferred).is_err() || owned_workspace_path(&preferred) {
207 return preferred;
208 }
209 let digest = format!("{:x}", Sha256::digest(root.as_os_str().as_encoded_bytes()));
210 for sequence in 0..1_024usize {
211 let name = if sequence == 0 {
212 format!(".supercov/workspaces-{}", &digest[..16])
213 } else {
214 format!(".supercov/workspaces-{}-{sequence}", &digest[..16])
215 };
216 let candidate = root.join(name);
217 if fs::symlink_metadata(&candidate).is_err() || owned_workspace_path(&candidate) {
218 return candidate;
219 }
220 }
221 root.join(format!(".supercov/workspaces-{}-overflow", &digest[..16]))
222}
223
224pub fn planted_cargo_configuration(root: &Path, workspace: &Path) -> Option<PathBuf> {
231 if !workspace.starts_with(root) {
232 return None;
233 }
234 workspace
235 .ancestors()
236 .skip(1)
237 .take_while(|directory| *directory != root)
238 .map(|directory| directory.join(".cargo"))
239 .find(|path| fs::symlink_metadata(path).is_ok())
240}
241
242pub fn cached_workspace_path(root: &Path) -> Result<PathBuf, WorkspaceError> {
243 Ok(workspace_container(root)
244 .join("workspace")
245 .join(project_name(root)?))
246}
247
248pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
249 if run_id.is_empty()
250 || run_id == "."
251 || run_id == ".."
252 || run_id
253 .chars()
254 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
255 {
256 return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
257 }
258 Ok(workspace_container(root)
259 .join("work")
260 .join(run_id)
261 .join(project_name(root)?))
262}
263
264pub(crate) fn owned_workspace_path(path: &Path) -> bool {
265 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
266 && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
267 .is_ok_and(|metadata| metadata.file_type().is_file())
268 && fs::read(path.join(WORKSPACE_MARKER))
269 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
270}
271
272fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
273 let container = workspace_container(root);
274 let mut created = false;
275 match fs::symlink_metadata(&container) {
276 Ok(metadata) if !metadata.file_type().is_dir() => {
277 return Err(WorkspaceError::UnsafePath(container));
278 }
279 Ok(_) => {}
280 Err(error) if error.kind() == io::ErrorKind::NotFound => {
281 fs::create_dir_all(&container).map_err(|source| io_error(&container, source))?;
282 let store_ignore = root.join(".supercov/.gitignore");
285 if fs::symlink_metadata(&store_ignore).is_err() {
286 let _ = fs::write(&store_ignore, b"*\n");
287 }
288 created = true;
289 }
290 Err(source) => return Err(io_error(&container, source)),
291 }
292 let result = (|| {
293 if !created && !owned_workspace_path(&container) {
294 return Err(WorkspaceError::UnsafePath(container.clone()));
295 }
296 for (name, contents) in [
297 (".gitignore", b"*\n".as_slice()),
298 (WORKSPACE_MARKER, WORKSPACE_MARKER_CONTENTS),
299 ] {
300 let path = container.join(name);
301 match OpenOptions::new().write(true).create_new(true).open(&path) {
302 Ok(mut file) => file
303 .write_all(contents)
304 .and_then(|_| file.sync_all())
305 .map_err(|source| io_error(&path, source))?,
306 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
307 if !fs::symlink_metadata(&path)
308 .is_ok_and(|metadata| metadata.file_type().is_file())
309 {
310 return Err(WorkspaceError::UnsafePath(path));
311 }
312 if name == WORKSPACE_MARKER
313 && !fs::read(&path)
314 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
315 {
316 return Err(WorkspaceError::UnsafePath(path));
317 }
318 }
319 Err(source) => return Err(io_error(&path, source)),
320 }
321 }
322 Ok(container.clone())
323 })();
324 if result.is_err() && created {
325 let _ = fs::remove_dir_all(&container);
326 }
327 result
328}
329
330fn lexical_normalize(path: &Path) -> Option<PathBuf> {
331 let mut normalized = PathBuf::new();
332 for component in path.components() {
333 match component {
334 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
335 Component::RootDir => normalized.push(component.as_os_str()),
336 Component::CurDir => {}
337 Component::ParentDir => {
338 if !normalized.pop() {
339 return None;
340 }
341 }
342 Component::Normal(value) => normalized.push(value),
343 }
344 }
345 Some(normalized)
346}
347
348fn inside(root: &Path, path: &Path) -> bool {
349 path == root
350 || path.strip_prefix(root).is_ok_and(|local| {
351 !local.as_os_str().is_empty()
352 && local
353 .components()
354 .all(|component| matches!(component, Component::Normal(_)))
355 })
356}
357
358trait WorkspaceOperations {
359 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
360 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
361}
362
363struct SystemWorkspaceOperations;
364
365impl WorkspaceOperations for SystemWorkspaceOperations {
366 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
367 const REFLINK_MINIMUM_BYTES: u64 = 1024 * 1024;
372 let bytes = fs::symlink_metadata(source)
373 .map_err(|source_error| io_error(source, source_error))?
374 .len();
375 if bytes < REFLINK_MINIMUM_BYTES {
376 fs::copy(source, destination)
377 .map(|_| ())
378 .map_err(|source_error| io_error(destination, source_error))
379 } else {
380 reflink_copy::reflink_or_copy(source, destination)
381 .map(|_| ())
382 .map_err(|source_error| io_error(destination, source_error))
383 }
384 }
385
386 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
387 atomic_rename(source, destination).map_err(WorkspaceError::from)
388 }
389}
390
391#[cfg(unix)]
392pub(crate) fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
393 std::os::unix::fs::symlink(target, destination)
394}
395
396#[cfg(windows)]
397pub(crate) fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
398 if directory {
399 match junction::create(target, destination) {
406 Ok(()) => Ok(()),
407 Err(junction_error) => {
408 let _ = fs::remove_dir(destination);
409 std::os::windows::fs::symlink_dir(target, destination).map_err(
410 |symlink_error| {
411 io::Error::new(
412 symlink_error.kind(),
413 format!(
414 "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
415 ),
416 )
417 },
418 )
419 }
420 }
421 } else {
422 std::os::windows::fs::symlink_file(target, destination)
426 }
427}
428
429#[cfg(target_os = "macos")]
435fn clone_directory(source: &Path, destination: &Path) -> bool {
436 use std::{ffi::CString, os::unix::ffi::OsStrExt};
437
438 let (Ok(source_c), Ok(destination_c)) = (
439 CString::new(source.as_os_str().as_bytes()),
440 CString::new(destination.as_os_str().as_bytes()),
441 ) else {
442 return false;
443 };
444 let status = unsafe { libc::clonefile(source_c.as_ptr(), destination_c.as_ptr(), 0) };
447 if status == 0 {
448 return true;
449 }
450 let _ = fs::remove_dir_all(destination);
451 false
452}
453
454#[cfg(all(unix, not(target_os = "macos")))]
455fn clone_directory(_source: &Path, _destination: &Path) -> bool {
456 false
457}
458
459#[cfg(unix)]
471fn hard_link_tree(source: &Path, destination: &Path) -> bool {
472 fn link(source: &Path, destination: &Path) -> io::Result<()> {
473 fs::create_dir(destination)?;
474 for entry in fs::read_dir(source)? {
475 let entry = entry?;
476 let from = entry.path();
477 let to = destination.join(entry.file_name());
478 let file_type = entry.file_type()?;
479 if file_type.is_dir() {
480 link(&from, &to)?;
481 } else if file_type.is_symlink() {
482 std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?;
483 } else if file_type.is_file() {
484 fs::hard_link(&from, &to)?;
485 } else {
486 return Err(io::Error::new(
487 io::ErrorKind::Unsupported,
488 "entry is neither a file, a directory nor a symlink",
489 ));
490 }
491 }
492 Ok(())
493 }
494 if link(source, destination).is_ok() {
495 return true;
496 }
497 let _ = fs::remove_dir_all(destination);
498 false
499}
500
501#[derive(Clone, Copy)]
502struct CopyRoots<'a> {
503 source: &'a Path,
504 destination: &'a Path,
505 final_destination: &'a Path,
506 canonical_source: &'a Path,
507}
508
509fn copy_tree<Operations: WorkspaceOperations>(
510 source: &Path,
511 destination: &Path,
512 roots: CopyRoots<'_>,
513 root_level: bool,
514 operations: &mut Operations,
515) -> Result<(), WorkspaceError> {
516 fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
517 let mut entries = fs::read_dir(source)
518 .map_err(|error| io_error(source, error))?
519 .collect::<Result<Vec<_>, _>>()
520 .map_err(|error| io_error(source, error))?;
521 entries.sort_by_key(fs::DirEntry::file_name);
522 for entry in entries {
523 let name = entry.file_name();
524 let name_text = name
525 .to_str()
526 .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
527 if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
528 || NESTED_EXCLUSIONS.contains(&name_text)
529 {
530 continue;
531 }
532 let from = entry.path();
533 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
534 if metadata.file_type().is_dir() && owned_workspace_path(&from) {
535 continue;
536 }
537 let to = destination.join(&name);
538 if metadata.file_type().is_dir() {
539 #[cfg(unix)]
562 if name_text == "node_modules" && !root_level {
563 if clone_directory(&from, &to) || hard_link_tree(&from, &to) {
564 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
567 .map_err(|error| io_error(&to, error))?;
568 continue;
569 }
570 fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
571 let mut packages = fs::read_dir(&from)
572 .map_err(|error| io_error(&from, error))?
573 .collect::<Result<Vec<_>, _>>()
574 .map_err(|error| io_error(&from, error))?;
575 packages.sort_by_key(fs::DirEntry::file_name);
576 for package in packages {
577 let link_destination = to.join(package.file_name());
578 if package.file_name() == ".bin" && package.path().is_dir() {
579 link_tools(&package.path(), &link_destination, operations)?;
580 continue;
581 }
582 create_link(&package.path(), &link_destination, false)
583 .map_err(|error| io_error(&link_destination, error))?;
584 }
585 continue;
586 }
587 copy_tree(&from, &to, roots, false, operations)?;
588 if name_text == "node_modules" && !root_level {
589 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
590 .map_err(|error| io_error(&to, error))?;
591 }
592 } else if metadata.file_type().is_symlink() {
593 let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
594 let unresolved_target = if link.is_absolute() {
595 link.clone()
596 } else {
597 from.parent().expect("entry parent").join(&link)
598 };
599 let Some(lexical_target) = lexical_normalize(&unresolved_target) else {
600 omit_escaping_link(&from, &link);
601 continue;
602 };
603 let canonical_target = match canonicalize_simplified(&from) {
611 Ok(target) => Some(target),
612 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
613 Err(error) => return Err(io_error(&from, error)),
614 };
615 let escapes = !inside(roots.source, &lexical_target)
616 || canonical_target
617 .as_deref()
618 .is_some_and(|target| !inside(roots.canonical_source, target));
619 if escapes {
620 omit_escaping_link(&from, &link);
621 continue;
622 }
623 let local_target = lexical_target
624 .strip_prefix(roots.source)
625 .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
626 let relocated = roots.destination.join(local_target);
627 let Some(canonical_target) = canonical_target else {
628 if cfg!(windows) {
629 continue;
633 }
634 let isolated_link = if link.is_absolute() {
635 pathdiff(&to, &relocated)?
636 } else {
637 link
638 };
639 create_link(&isolated_link, &to, false).map_err(|error| io_error(&to, error))?;
640 continue;
641 };
642 let target_metadata = fs::metadata(&canonical_target)
643 .map_err(|error| io_error(&canonical_target, error))?;
644 let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
645 roots.final_destination.join(local_target)
646 } else if link.is_absolute() {
647 pathdiff(&to, &relocated)?
648 } else {
649 link
650 };
651 create_link(&isolated_link, &to, target_metadata.is_dir())
652 .map_err(|error| io_error(&to, error))?;
653 } else if metadata.file_type().is_file() {
654 operations.copy_file(&from, &to)?;
655 }
656 }
661 Ok(())
662}
663
664fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
665 let from = from
666 .parent()
667 .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
668 let from_components = from.components().collect::<Vec<_>>();
669 let to_components = to.components().collect::<Vec<_>>();
670 let common = from_components
671 .iter()
672 .zip(&to_components)
673 .take_while(|(left, right)| left == right)
674 .count();
675 if common == 0 {
676 return Err(WorkspaceError::UnsafePath(to.into()));
677 }
678 let mut relative = PathBuf::new();
679 for _ in common..from_components.len() {
680 relative.push("..");
681 }
682 for component in &to_components[common..] {
683 relative.push(component.as_os_str());
684 }
685 Ok(relative)
686}
687
688#[cfg(unix)]
698fn link_package(
699 root: &Path,
700 workspace: &Path,
701 target: &Path,
702 to: &Path,
703) -> Result<(), WorkspaceError> {
704 let metadata = fs::symlink_metadata(target).map_err(|error| io_error(target, error))?;
705 let scope = target
706 .file_name()
707 .and_then(|name| name.to_str())
708 .is_some_and(|name| name.starts_with('@'));
709 if scope && metadata.file_type().is_dir() {
710 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
711 let mut packages = fs::read_dir(target)
712 .map_err(|error| io_error(target, error))?
713 .collect::<Result<Vec<_>, _>>()
714 .map_err(|error| io_error(target, error))?;
715 packages.sort_by_key(fs::DirEntry::file_name);
716 for package in packages {
717 link_package(
718 root,
719 workspace,
720 &package.path(),
721 &to.join(package.file_name()),
722 )?;
723 }
724 return Ok(());
725 }
726 if metadata.file_type().is_symlink()
727 && let (Ok(resolved), Ok(canonical_root)) = (
728 canonicalize_simplified(target),
729 canonicalize_simplified(root),
730 )
731 && let Ok(local) = resolved.strip_prefix(&canonical_root)
732 && !local.as_os_str().is_empty()
733 && !local.components().any(|part| {
734 matches!(
735 part.as_os_str().to_str(),
736 Some("node_modules" | ".supercov")
737 )
738 })
739 {
740 let copy = workspace.join(local);
741 if fs::symlink_metadata(©).is_ok() {
742 return create_link(&pathdiff(to, ©)?, to, true)
743 .map_err(|error| io_error(to, error));
744 }
745 }
746 create_link(target, to, false).map_err(|error| io_error(to, error))
747}
748
749fn link_tools<Operations: WorkspaceOperations>(
755 tools: &Path,
756 to: &Path,
757 operations: &mut Operations,
758) -> Result<(), WorkspaceError> {
759 #[cfg(unix)]
760 let _ = &operations;
761 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
762 let judges = crate::source_tools::source_tools();
763 let binary = crate::source_tools::launching_binary();
764 let mut entries = fs::read_dir(tools)
765 .map_err(|error| io_error(tools, error))?
766 .collect::<Result<Vec<_>, _>>()
767 .map_err(|error| io_error(tools, error))?;
768 entries.sort_by_key(fs::DirEntry::file_name);
769 for entry in entries {
770 let (tool, link) = (entry.path(), to.join(entry.file_name()));
771 let launcher = binary.as_deref().and_then(|binary| {
772 crate::source_tools::launcher_for(&judges, binary, tools, &entry.file_name())
773 });
774 if let Some(launcher) = launcher {
775 crate::source_tools::write_launcher(&link, &launcher)
776 .map_err(|error| io_error(&link, error))?;
777 continue;
778 }
779 #[cfg(unix)]
783 create_link(&tool, &link, false).map_err(|error| io_error(&link, error))?;
784 #[cfg(windows)]
785 if fs::metadata(&tool).is_ok_and(|metadata| metadata.is_file()) {
786 operations.copy_file(&tool, &link)?;
787 }
788 }
789 Ok(())
790}
791
792fn link_node_modules<Operations: WorkspaceOperations>(
793 root: &Path,
794 workspace: &Path,
795 operations: &mut Operations,
796) -> Result<(), WorkspaceError> {
797 #[cfg(unix)]
798 let _ = &operations;
799 let source = root.join("node_modules");
800 let source_metadata = match fs::symlink_metadata(&source) {
801 Ok(metadata) => metadata,
802 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
803 Err(error) => return Err(io_error(&source, error)),
804 };
805 let source = if source_metadata.file_type().is_symlink() {
810 let resolved =
811 canonicalize_simplified(&source).map_err(|error| io_error(&source, error))?;
812 if !fs::symlink_metadata(&resolved)
813 .map_err(|error| io_error(&resolved, error))?
814 .file_type()
815 .is_dir()
816 {
817 return Err(WorkspaceError::UnsafePath(source));
818 }
819 resolved
820 } else if source_metadata.file_type().is_dir() {
821 source
822 } else {
823 return Err(WorkspaceError::UnsafePath(source));
824 };
825 let destination = workspace.join("node_modules");
826 fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
827 let mut entries = fs::read_dir(&source)
828 .map_err(|error| io_error(&source, error))?
829 .collect::<Result<Vec<_>, _>>()
830 .map_err(|error| io_error(&source, error))?;
831 entries.sort_by_key(fs::DirEntry::file_name);
832 for entry in entries {
833 let target = entry.path();
834 let to = destination.join(entry.file_name());
835 if entry.file_name() == ".bin"
836 && fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir())
837 {
838 link_tools(&target, &to, operations)?;
839 continue;
840 }
841 #[cfg(unix)]
842 link_package(root, workspace, &target, &to)?;
843 #[cfg(windows)]
844 {
845 let file_type = entry
846 .file_type()
847 .map_err(|error| io_error(&target, error))?;
848 let resolved = if file_type.is_symlink() {
849 canonicalize_simplified(&target).map_err(|error| io_error(&target, error))?
850 } else {
851 target.clone()
852 };
853 let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
854 if metadata.is_dir() {
855 create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
856 } else if metadata.is_file() {
857 operations.copy_file(&resolved, &to)?;
860 } else {
861 return Err(WorkspaceError::UnsupportedEntry(target));
862 }
863 }
864 }
865 Ok(())
866}
867
868fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
869 if lock.protects(root) {
870 Ok(())
871 } else {
872 Err(WorkspaceError::MissingLock)
873 }
874}
875
876pub fn prepare_isolated_workspace(
877 root: &Path,
878 run_id: &str,
879 lock: &ProjectLock,
880) -> Result<PathBuf, WorkspaceError> {
881 require_lock(root, lock)?;
882 ensure_container(root)?;
883 let mut operations = SystemWorkspaceOperations;
884 let workspace = isolated_workspace_path(root, run_id)?;
885 remove_stored_tree_deferred(root, &workspace)?;
886 let canonical_root = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
887 copy_tree(
888 root,
889 &workspace,
890 CopyRoots {
891 source: root,
892 destination: &workspace,
893 final_destination: &workspace,
894 canonical_source: &canonical_root,
895 },
896 true,
897 &mut operations,
898 )?;
899 link_node_modules(root, &workspace, &mut operations)?;
900 Ok(workspace)
901}
902
903fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
904 Ok(format!(
905 ".{}.{}-",
906 project_name(root)?.to_string_lossy(),
907 kind
908 ))
909}
910
911fn transaction_path(root: &Path, kind: &str) -> Result<PathBuf, WorkspaceError> {
912 let workspace = cached_workspace_path(root)?;
913 Ok(workspace.parent().expect("workspace parent").join(format!(
914 "{}{}",
915 transaction_prefix(root, kind)?,
916 unique()
917 )))
918}
919
920#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
921#[serde(rename_all = "camelCase")]
922pub struct CacheRecoveryResult {
923 pub restored_previous: bool,
924 pub removed_staging: usize,
925 pub removed_previous: usize,
926}
927
928pub fn recover_cached_workspace(
929 root: &Path,
930 lock: &ProjectLock,
931) -> Result<CacheRecoveryResult, WorkspaceError> {
932 require_lock(root, lock)?;
933 let workspace = cached_workspace_path(root)?;
934 let parent = workspace.parent().expect("workspace parent");
935 let entries = match fs::read_dir(parent) {
936 Ok(entries) => entries,
937 Err(error) if error.kind() == io::ErrorKind::NotFound => {
938 return Ok(CacheRecoveryResult {
939 restored_previous: false,
940 removed_staging: 0,
941 removed_previous: 0,
942 });
943 }
944 Err(error) => return Err(io_error(parent, error)),
945 };
946 let staging_prefix = transaction_prefix(root, "staging")?;
947 let previous_prefix = transaction_prefix(root, "previous")?;
948 let mut staging = Vec::new();
949 let mut previous = Vec::new();
950 let mut invalid_previous = Vec::new();
951 for entry in entries {
952 let entry = entry.map_err(|error| io_error(parent, error))?;
953 let name = entry.file_name().to_string_lossy().into_owned();
954 if name.starts_with(&staging_prefix) {
955 staging.push(entry.path());
956 } else if name.starts_with(&previous_prefix) {
957 let metadata = entry
958 .file_type()
959 .map_err(|error| io_error(&entry.path(), error))?;
960 if metadata.is_dir() {
961 let modified = entry
962 .metadata()
963 .and_then(|metadata| metadata.modified())
964 .unwrap_or(UNIX_EPOCH);
965 previous.push((modified, entry.path()));
966 } else {
967 invalid_previous.push(entry.path());
968 }
969 }
970 }
971 previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
972 let mut restored = false;
973 if fs::symlink_metadata(&workspace).is_err()
974 && let Some((_, newest)) = previous.first()
975 {
976 atomic_rename(newest, &workspace)?;
977 previous.remove(0);
978 restored = true;
979 }
980 let removed_staging = staging.len();
981 let removed_previous = previous.len() + invalid_previous.len();
982 for path in staging
983 .into_iter()
984 .chain(previous.into_iter().map(|(_, path)| path))
985 .chain(invalid_previous)
986 {
987 remove_stored_tree_deferred(root, &path)?;
988 }
989 Ok(CacheRecoveryResult {
990 restored_previous: restored,
991 removed_staging,
992 removed_previous,
993 })
994}
995
996fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
997 if requested.is_absolute()
998 || requested
999 .components()
1000 .any(|component| !matches!(component, Component::Normal(_)))
1001 {
1002 return Err(WorkspaceError::UnsafePath(requested.into()));
1003 }
1004 let path = workspace.join(requested);
1005 if path == workspace || fs::symlink_metadata(&path).is_err() {
1006 return Err(WorkspaceError::UnsafePath(requested.into()));
1007 }
1008 Ok(path)
1009}
1010
1011pub fn prepare_cached_workspace(
1012 root: &Path,
1013 lock: &ProjectLock,
1014 reuse_paths: &[PathBuf],
1015) -> Result<PathBuf, WorkspaceError> {
1016 let mut operations = SystemWorkspaceOperations;
1017 prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
1018}
1019
1020fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1021 root: &Path,
1022 lock: &ProjectLock,
1023 reuse_paths: &[PathBuf],
1024 operations: &mut Operations,
1025) -> Result<PathBuf, WorkspaceError> {
1026 require_lock(root, lock)?;
1027 ensure_container(root)?;
1028 recover_cached_workspace(root, lock)?;
1029 let workspace = cached_workspace_path(root)?;
1030 let staging = transaction_path(root, "staging")?;
1031 let previous = transaction_path(root, "previous")?;
1032 let result = (|| {
1033 let canonical_root =
1034 canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1035 copy_tree(
1036 root,
1037 &staging,
1038 CopyRoots {
1039 source: root,
1040 destination: &staging,
1041 final_destination: &workspace,
1042 canonical_source: &canonical_root,
1043 },
1044 true,
1045 operations,
1046 )?;
1047 link_node_modules(root, &staging, operations)?;
1048 for requested in reuse_paths {
1049 let from = checked_reuse_path(&workspace, requested)?;
1050 let to = staging.join(requested);
1051 match fs::symlink_metadata(&to) {
1056 Ok(existing) if existing.file_type().is_dir() => {
1057 fs::remove_dir_all(&to).map_err(|error| io_error(&to, error))?;
1058 }
1059 Ok(_) => {
1060 fs::remove_file(&to).map_err(|error| io_error(&to, error))?;
1061 }
1062 Err(_) => {}
1063 }
1064 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
1065 if metadata.file_type().is_dir() {
1066 let canonical_workspace = canonicalize_simplified(&workspace)
1067 .map_err(|error| io_error(&workspace, error))?;
1068 copy_tree(
1069 &from,
1070 &to,
1071 CopyRoots {
1072 source: &workspace,
1073 destination: &staging,
1074 final_destination: &workspace,
1075 canonical_source: &canonical_workspace,
1076 },
1077 false,
1078 operations,
1079 )?;
1080 } else if metadata.file_type().is_file() {
1081 fs::create_dir_all(to.parent().expect("reuse parent"))
1082 .map_err(|error| io_error(&to, error))?;
1083 operations.copy_file(&from, &to)?;
1084 } else {
1085 return Err(WorkspaceError::UnsupportedEntry(from));
1086 }
1087 }
1088 let mut moved_previous = false;
1089 if fs::symlink_metadata(&workspace).is_ok() {
1090 operations.rename(&workspace, &previous)?;
1091 moved_previous = true;
1092 }
1093 if let Err(error) = operations.rename(&staging, &workspace) {
1094 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1095 let _ = operations.rename(&previous, &workspace);
1096 }
1097 return Err(error);
1098 }
1099 if fs::symlink_metadata(&previous).is_ok() {
1100 remove_stored_tree_deferred(root, &previous)?;
1101 }
1102 Ok(workspace.clone())
1103 })();
1104 if fs::symlink_metadata(&staging).is_ok() {
1105 remove_stored_tree_deferred(root, &staging)?;
1106 }
1107 result
1108}
1109
1110pub struct WorkspaceOutputBaseline {
1114 entries: BTreeMap<PathBuf, (u64, SystemTime)>,
1115}
1116
1117#[derive(Debug, Default, PartialEq, Eq)]
1119pub struct CommandOutputSync {
1120 pub synced: usize,
1121 pub synced_paths: Vec<PathBuf>,
1123 pub skipped_instrumented: Vec<PathBuf>,
1127 pub deleted_in_workspace: Vec<PathBuf>,
1130}
1131
1132fn walk_output_files(
1133 workspace: &Path,
1134 directory: &Path,
1135 entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1136) -> Result<(), WorkspaceError> {
1137 for entry in fs::read_dir(directory)
1138 .map_err(|error| io_error(directory, error))?
1139 .collect::<Result<Vec<_>, _>>()
1140 .map_err(|error| io_error(directory, error))?
1141 {
1142 let path = entry.path();
1143 let name = entry.file_name();
1144 let relative = path
1145 .strip_prefix(workspace)
1146 .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1147 .to_owned();
1148 if relative.components().count() == 1
1152 && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1153 {
1154 continue;
1155 }
1156 let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1157 if metadata.is_dir() && name.to_str() == Some("node_modules") {
1161 continue;
1162 }
1163 if fs::symlink_metadata(&path)
1164 .map_err(|error| io_error(&path, error))?
1165 .file_type()
1166 .is_symlink()
1167 {
1168 continue;
1169 }
1170 if metadata.is_dir() {
1171 walk_output_files(workspace, &path, entries)?;
1172 } else if metadata.is_file() {
1173 let modified = metadata
1174 .modified()
1175 .map_err(|error| io_error(&path, error))?;
1176 entries.insert(relative, (metadata.len(), modified));
1177 }
1178 }
1179 Ok(())
1180}
1181
1182pub fn workspace_output_baseline(
1183 workspace: &Path,
1184) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1185 let mut entries = BTreeMap::new();
1186 walk_output_files(workspace, workspace, &mut entries)?;
1187 Ok(WorkspaceOutputBaseline { entries })
1188}
1189
1190fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1193 if relative.as_os_str().is_empty()
1194 || relative
1195 .components()
1196 .any(|component| !matches!(component, Component::Normal(_)))
1197 {
1198 return Err(WorkspaceError::UnsafePath(relative.into()));
1199 }
1200 let mut current = root.to_owned();
1201 for component in relative.components() {
1202 current.push(component.as_os_str());
1203 match fs::symlink_metadata(¤t) {
1204 Ok(metadata) if metadata.file_type().is_symlink() => {
1205 return Err(WorkspaceError::UnsafePath(current));
1206 }
1207 Ok(_) => {}
1208 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1209 Err(error) => return Err(io_error(¤t, error)),
1210 }
1211 }
1212 Ok(())
1213}
1214
1215pub fn summarize_paths(paths: &[PathBuf]) -> String {
1220 const SHOWN: usize = 4;
1221 let mut places = BTreeMap::<String, usize>::new();
1222 for path in paths {
1223 let mut components = path.components();
1224 let first = components
1225 .next()
1226 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1227 .unwrap_or_default();
1228 let place = if components.next().is_some() {
1229 format!("{first}/")
1230 } else {
1231 first
1232 };
1233 *places.entry(place).or_default() += 1;
1234 }
1235 let mut places = places.into_iter().collect::<Vec<_>>();
1236 places.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1237 let mut shown = places
1238 .iter()
1239 .take(SHOWN)
1240 .map(|(place, count)| {
1241 if place.ends_with('/') {
1242 format!("{place} {count}")
1243 } else {
1244 place.clone()
1245 }
1246 })
1247 .collect::<Vec<_>>();
1248 if places.len() > SHOWN {
1249 let rest = places[SHOWN..]
1250 .iter()
1251 .map(|(_, count)| count)
1252 .sum::<usize>();
1253 shown.push(format!("{rest} elsewhere"));
1254 }
1255 shown.join(", ")
1256}
1257
1258struct BuiltDirectories<'a> {
1265 root: &'a Path,
1266 judged: BTreeMap<PathBuf, bool>,
1267 directories: BTreeSet<PathBuf>,
1268}
1269
1270impl<'a> BuiltDirectories<'a> {
1271 fn new(root: &'a Path) -> Self {
1272 Self {
1273 root,
1274 judged: BTreeMap::new(),
1275 directories: BTreeSet::new(),
1276 }
1277 }
1278
1279 fn add(&mut self, relative: &Path) {
1280 let mut directory = PathBuf::new();
1281 let Some(parent) = relative.parent() else {
1282 return;
1283 };
1284 for component in parent.components() {
1285 directory.push(component);
1286 let root = self.root;
1287 let output = *self
1288 .judged
1289 .entry(directory.clone())
1290 .or_insert_with(|| is_build_output(root, &directory));
1291 if output {
1292 self.directories.insert(directory);
1293 return;
1294 }
1295 }
1296 }
1297
1298 fn holds(&self, relative: &Path) -> bool {
1299 relative
1300 .ancestors()
1301 .skip(1)
1302 .any(|directory| self.directories.contains(directory))
1303 }
1304}
1305
1306fn compiled_javascript(path: &Path) -> bool {
1308 path.extension()
1309 .and_then(|extension| extension.to_str())
1310 .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "map"))
1311}
1312
1313fn is_build_output(root: &Path, directory: &Path) -> bool {
1314 if fs::symlink_metadata(root.join(directory)).is_err() {
1315 return true;
1316 }
1317 std::process::Command::new("git")
1318 .arg("-C")
1319 .arg(root)
1320 .args(["check-ignore", "-q", "--"])
1321 .arg(directory)
1322 .env("GIT_TERMINAL_PROMPT", "0")
1323 .stdin(std::process::Stdio::null())
1324 .stdout(std::process::Stdio::null())
1325 .stderr(std::process::Stdio::null())
1326 .status()
1327 .is_ok_and(|status| status.code() == Some(0))
1328}
1329
1330pub fn sync_command_outputs(
1337 root: &Path,
1338 workspace: &Path,
1339 baseline: &WorkspaceOutputBaseline,
1340 protected: &BTreeSet<PathBuf>,
1341) -> Result<CommandOutputSync, WorkspaceError> {
1342 let mut current = BTreeMap::new();
1343 walk_output_files(workspace, workspace, &mut current)?;
1344 let mut sync = CommandOutputSync::default();
1345 let mut changed = Vec::new();
1357 let mut built = BTreeSet::new();
1358 let mut built_directories = BuiltDirectories::new(root);
1359 for (relative, state) in ¤t {
1360 if baseline.entries.get(relative) == Some(state) {
1361 continue;
1362 }
1363 if !protected.contains(relative) {
1364 let compiled = compiled_javascript(relative);
1369 if built_from_instrumented_source(&workspace.join(relative), compiled)? {
1370 built.insert(relative.clone());
1371 if compiled {
1372 built_directories.add(relative);
1373 }
1374 }
1375 }
1376 changed.push(relative);
1377 }
1378 for relative in changed {
1379 if protected.contains(relative)
1380 || built.contains(relative)
1381 || built_directories.holds(relative)
1382 {
1383 sync.skipped_instrumented.push(relative.clone());
1384 continue;
1385 }
1386 let from = workspace.join(relative);
1387 if unaccompanied_install_manifest(root, workspace, relative) {
1388 continue;
1389 }
1390 validate_writeback_destination(root, relative)?;
1391 let to = root.join(relative);
1392 if let Some(parent) = to.parent() {
1393 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1394 }
1395 fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1396 sync.synced += 1;
1397 sync.synced_paths.push(relative.clone());
1398 }
1399 for relative in baseline.entries.keys() {
1400 if !current.contains_key(relative) && fs::symlink_metadata(root.join(relative)).is_ok() {
1403 sync.deleted_in_workspace.push(relative.clone());
1404 }
1405 }
1406 Ok(sync)
1407}
1408
1409pub fn mirror_git_repository(root: &Path, workspace: &Path) -> Result<(), String> {
1420 if fs::symlink_metadata(root.join(".git")).is_err() {
1421 return Ok(());
1422 }
1423 let target = workspace.join(".git");
1424 let git = |directory: &Path, arguments: &[&str]| -> Result<String, String> {
1425 let output = std::process::Command::new("git")
1426 .arg("-C")
1427 .arg(directory)
1428 .args(arguments)
1429 .env("GIT_TERMINAL_PROMPT", "0")
1430 .stdin(std::process::Stdio::null())
1431 .output()
1432 .map_err(|error| format!("git {}: {error}", arguments.join(" ")))?;
1433 if output.status.success() {
1434 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1435 } else {
1436 Err(format!(
1437 "git {}: {}",
1438 arguments.join(" "),
1439 String::from_utf8_lossy(&output.stderr).trim()
1440 ))
1441 }
1442 };
1443 let result = (|| {
1444 if fs::symlink_metadata(&target).is_ok() {
1445 fs::remove_dir_all(&target).map_err(|error| error.to_string())?;
1446 }
1447 let source = root.to_string_lossy();
1448 let destination = target.to_string_lossy();
1449 git(
1450 workspace,
1451 &[
1452 "clone",
1453 "--quiet",
1454 "--shared",
1455 "--bare",
1456 &source,
1457 &destination,
1458 ],
1459 )?;
1460 git(workspace, &["config", "core.bare", "false"])?;
1461 match git(root, &["symbolic-ref", "-q", "HEAD"]) {
1462 Ok(branch) => git(workspace, &["symbolic-ref", "HEAD", &branch])?,
1463 Err(_) => {
1464 let commit = git(root, &["rev-parse", "HEAD"])?;
1465 git(workspace, &["update-ref", "--no-deref", "HEAD", &commit])?
1466 }
1467 };
1468 git(workspace, &["remote", "remove", "origin"])?;
1469 let remotes = git(root, &["remote"])?;
1470 for name in remotes.lines().filter(|name| !name.is_empty()) {
1471 let url = git(root, &["config", "--get", &format!("remote.{name}.url")])?;
1474 git(workspace, &["remote", "add", name, &url])?;
1475 }
1476 git(
1477 workspace,
1478 &[
1479 "fetch",
1480 "--quiet",
1481 "--no-tags",
1482 &source,
1483 "+refs/remotes/*:refs/remotes/*",
1484 ],
1485 )?;
1486 git(workspace, &["read-tree", "HEAD"])?;
1487 let exclude = target.join("info/exclude");
1489 fs::create_dir_all(target.join("info")).map_err(|error| error.to_string())?;
1490 let mut patterns = fs::read_to_string(&exclude).unwrap_or_default();
1491 patterns.push_str("\n.supercov/\n");
1492 fs::write(&exclude, patterns).map_err(|error| error.to_string())?;
1493 Ok(())
1494 })();
1495 if result.is_err() {
1496 let _ = fs::remove_dir_all(&target);
1497 }
1498 result
1499}
1500
1501pub fn hide_rewritten_files(workspace: &Path, files: &[String]) {
1505 if files.is_empty() || !workspace.join(".git").is_dir() {
1506 return;
1507 }
1508 let run = |arguments: &[&str], input: &str| {
1509 let mut child = std::process::Command::new("git")
1510 .arg("-C")
1511 .arg(workspace)
1512 .args(arguments)
1513 .stdin(std::process::Stdio::piped())
1514 .stdout(std::process::Stdio::piped())
1515 .stderr(std::process::Stdio::null())
1516 .spawn()
1517 .ok()?;
1518 child.stdin.take()?.write_all(input.as_bytes()).ok()?;
1519 let output = child.wait_with_output().ok()?;
1520 output
1521 .status
1522 .success()
1523 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1524 };
1525 let Some(listed) = run(&["ls-files", "-z"], "") else {
1527 return;
1528 };
1529 let tracked = listed.split('\0').collect::<BTreeSet<_>>();
1530 let hidden = files
1531 .iter()
1532 .filter(|file| tracked.contains(file.as_str()))
1533 .map(String::as_str)
1534 .collect::<Vec<_>>();
1535 if !hidden.is_empty() {
1536 let _ = run(
1537 &["update-index", "--skip-worktree", "--stdin"],
1538 &hidden.join("\n"),
1539 );
1540 }
1541}
1542
1543fn unaccompanied_install_manifest(root: &Path, workspace: &Path, relative: &Path) -> bool {
1550 let manifest = relative
1551 .file_name()
1552 .and_then(|name| name.to_str())
1553 .is_some_and(|name| {
1554 matches!(
1555 name,
1556 "package.json"
1557 | "package-lock.json"
1558 | "npm-shrinkwrap.json"
1559 | "yarn.lock"
1560 | "pnpm-lock.yaml"
1561 | "bun.lock"
1562 | "bun.lockb"
1563 )
1564 });
1565 let Some(directory) = relative.parent() else {
1566 return false;
1567 };
1568 manifest
1569 && workspace.join(directory).join("node_modules").is_dir()
1570 && !root.join(directory).join("node_modules").exists()
1571}
1572
1573fn built_from_instrumented_source(path: &Path, compiled: bool) -> Result<bool, WorkspaceError> {
1585 const RUNTIME: &[u8] = b".supercov/node_modules/";
1586 const PROBE: &[u8] = b"__supercov";
1587 const GLOBAL: &[u8] = b"__SUPERCOV_DIRECT_RUNTIME__";
1588 let contents = match fs::read(path) {
1589 Ok(contents) => contents,
1590 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1591 Err(error) => return Err(io_error(path, error)),
1592 };
1593 let holds = |marker: &[u8]| {
1594 contents
1595 .windows(marker.len())
1596 .any(|window| window == marker)
1597 };
1598 Ok(holds(RUNTIME) || (compiled && (holds(PROBE) || holds(GLOBAL))))
1599}
1600
1601pub fn prune_cached_workspace_sources(
1602 root: &Path,
1603 lock: &ProjectLock,
1604) -> Result<Vec<String>, WorkspaceError> {
1605 require_lock(root, lock)?;
1606 let workspace = cached_workspace_path(root)?;
1607 if fs::symlink_metadata(&workspace).is_err() {
1608 return Ok(Vec::new());
1609 }
1610 let keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
1611 let mut removed = Vec::new();
1612 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
1613 let entry = entry.map_err(|error| io_error(&workspace, error))?;
1614 let name = entry
1615 .file_name()
1616 .into_string()
1617 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
1618 if keep.contains(&name) {
1619 continue;
1620 }
1621 remove_stored_tree_deferred(root, &entry.path())?;
1622 removed.push(name);
1623 }
1624 removed.sort();
1625 Ok(removed)
1626}
1627
1628#[cfg(test)]
1629mod tests {
1630 #[test]
1631 fn file_urls_match_what_node_derives_for_every_path_shape() {
1632 use super::file_url_from_slash_path;
1633 assert_eq!(
1634 file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
1635 "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
1636 );
1637 assert_eq!(
1638 file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
1639 "file:///C:/Users/a%20b/%231/register.mjs"
1640 );
1641 assert_eq!(
1642 file_url_from_slash_path("//server/share/dir/register.mjs"),
1643 "file://server/share/dir/register.mjs"
1644 );
1645 assert_eq!(
1646 file_url_from_slash_path("/w/p/register.mjs"),
1647 "file:///w/p/register.mjs"
1648 );
1649 assert_eq!(
1650 file_url_from_slash_path("/w/p x?q/ü.mjs"),
1651 "file:///w/p%20x%3Fq/%C3%BC.mjs"
1652 );
1653 }
1654
1655 #[test]
1656 fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
1657 use super::strip_verbatim;
1658 assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
1659 assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
1660 assert_eq!(
1661 strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
1662 Some(r"\\server\share\dir")
1663 );
1664 assert_eq!(strip_verbatim(r"C:\a\b"), None);
1665 assert_eq!(strip_verbatim("/w/project"), None);
1666 assert_eq!(strip_verbatim(r"\\server\share"), None);
1667 assert_eq!(strip_verbatim(r"\\?\"), None);
1668 }
1669
1670 use super::*;
1671
1672 fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
1673 let base =
1674 std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
1675 if base.exists() {
1676 fs::remove_dir_all(&base).unwrap();
1677 }
1678 let root = base.join("project");
1679 let workspace = base.join("workspace");
1680 fs::create_dir_all(root.join("src")).unwrap();
1681 fs::create_dir_all(workspace.join("src")).unwrap();
1682 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1683 fs::write(root.join("src/app.ts"), "original\n").unwrap();
1684 fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
1685 fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
1686 (root, workspace)
1687 }
1688
1689 #[test]
1690 fn the_workspace_has_a_repository_of_its_own_that_reads_like_the_project() {
1691 let (root, workspace) = writeback_fixture("git-mirror");
1692 let git = |directory: &Path, arguments: &[&str]| {
1693 let output = std::process::Command::new("git")
1694 .arg("-C")
1695 .arg(directory)
1696 .args(arguments)
1697 .output()
1698 .unwrap();
1699 assert!(output.status.success(), "git {arguments:?}: {output:?}");
1700 String::from_utf8(output.stdout).unwrap().trim().to_owned()
1701 };
1702 git(&root, &["init", "--quiet", "--initial-branch=release/v2"]);
1703 git(&root, &["config", "user.email", "test@example.com"]);
1704 git(&root, &["config", "user.name", "Test"]);
1705 git(
1706 &root,
1707 &[
1708 "remote",
1709 "add",
1710 "origin",
1711 "https://github.com/example/app.git",
1712 ],
1713 );
1714 git(&root, &["add", "src/app.ts"]);
1715 git(&root, &["commit", "--quiet", "-m", "first"]);
1716 let head = git(&root, &["rev-parse", "HEAD"]);
1717
1718 mirror_git_repository(&root, &workspace).unwrap();
1719 assert_eq!(git(&workspace, &["rev-parse", "HEAD"]), head);
1720 assert_eq!(
1721 git(&workspace, &["symbolic-ref", "HEAD"]),
1722 "refs/heads/release/v2"
1723 );
1724 assert_eq!(
1725 git(&workspace, &["config", "--get", "remote.origin.url"]),
1726 "https://github.com/example/app.git"
1727 );
1728 hide_rewritten_files(&workspace, &["src/app.ts".to_owned()]);
1730 assert_eq!(git(&workspace, &["status", "--porcelain"]), "");
1731
1732 fs::write(workspace.join("new.txt"), "new\n").unwrap();
1734 git(&workspace, &["add", "new.txt"]);
1735 git(
1736 &workspace,
1737 &[
1738 "-c",
1739 "user.email=t@example.com",
1740 "-c",
1741 "user.name=T",
1742 "commit",
1743 "--quiet",
1744 "-m",
1745 "second",
1746 ],
1747 );
1748 assert_eq!(git(&root, &["rev-parse", "HEAD"]), head);
1749 assert_eq!(git(&root, &["status", "--porcelain"]), "");
1750 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1751 }
1752
1753 #[test]
1754 fn an_install_the_command_made_does_not_flow_back_without_its_dependencies() {
1755 let (root, workspace) = writeback_fixture("tool-install");
1756 fs::write(workspace.join("package.json"), "{}").unwrap();
1757 fs::write(root.join("package.json"), "{}").unwrap();
1758 fs::create_dir_all(workspace.join("node_modules")).unwrap();
1759 fs::create_dir_all(root.join("node_modules")).unwrap();
1760 let baseline = workspace_output_baseline(&workspace).unwrap();
1761 fs::create_dir_all(workspace.join(".tap/plugins/node_modules/@tapjs/clock")).unwrap();
1763 fs::write(workspace.join(".tap/plugins/package.json"), "{}").unwrap();
1764 fs::write(workspace.join(".tap/plugins/package-lock.json"), "{}").unwrap();
1765 fs::create_dir_all(workspace.join(".tap/test-results")).unwrap();
1766 fs::write(workspace.join(".tap/test-results/basic.tap"), "ok").unwrap();
1767 fs::write(workspace.join("package.json"), "{\"version\":\"2\"}").unwrap();
1769 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1770 assert!(!root.join(".tap/plugins/package.json").exists());
1771 assert!(!root.join(".tap/plugins/package-lock.json").exists());
1772 assert!(root.join(".tap/test-results/basic.tap").is_file());
1773 assert_eq!(
1774 fs::read_to_string(root.join("package.json")).unwrap(),
1775 "{\"version\":\"2\"}"
1776 );
1777 assert_eq!(sync.synced, 2);
1778 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1779 }
1780
1781 #[test]
1782 fn dependency_trees_never_flow_back() {
1783 let (root, workspace) = writeback_fixture("dependencies");
1786 let baseline = workspace_output_baseline(&workspace).unwrap();
1787 fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
1788 fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
1789 fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
1790 fs::write(
1791 workspace.join("packages/app/node_modules/dep/index.js"),
1792 "dep",
1793 )
1794 .unwrap();
1795 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1796 assert_eq!(sync.synced, 0);
1797 assert!(sync.deleted_in_workspace.is_empty());
1798 assert!(!root.join("node_modules").exists());
1799 assert!(!root.join("packages").exists());
1800 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1801 }
1802
1803 #[test]
1804 fn output_built_from_instrumented_sources_never_flows_back() {
1805 let (root, workspace) = writeback_fixture("built-output");
1811 let baseline = workspace_output_baseline(&workspace).unwrap();
1812 fs::create_dir_all(workspace.join("dist")).unwrap();
1813 fs::write(
1814 workspace.join("dist/app.js"),
1815 "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
1816 )
1817 .unwrap();
1818 fs::write(
1819 workspace.join("dist/app.d.ts"),
1820 "export declare const a: number;\n",
1821 )
1822 .unwrap();
1823
1824 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1825
1826 assert_eq!(
1829 sync.skipped_instrumented,
1830 vec![PathBuf::from("dist/app.d.ts"), PathBuf::from("dist/app.js")],
1831 "the instrumented build is reported, not copied"
1832 );
1833 assert_eq!(sync.synced, 0);
1834 assert!(!root.join("dist").exists());
1835 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1836 }
1837
1838 #[test]
1839 fn a_built_file_in_a_directory_the_project_keeps_withholds_only_itself() {
1840 let (root, workspace) = writeback_fixture("built-beside-outputs");
1843 let baseline = workspace_output_baseline(&workspace).unwrap();
1844 fs::write(
1845 workspace.join("src/bundle.js"),
1846 "import \"../.supercov/node_modules/runtime.mjs\";\n",
1847 )
1848 .unwrap();
1849 fs::write(workspace.join("src/app.snap"), "updated\n").unwrap();
1850
1851 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1852
1853 assert_eq!(
1854 sync.skipped_instrumented,
1855 vec![PathBuf::from("src/bundle.js")]
1856 );
1857 assert_eq!(sync.synced, 1);
1858 assert_eq!(
1859 fs::read_to_string(root.join("src/app.snap")).unwrap(),
1860 "updated\n"
1861 );
1862 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1863 }
1864
1865 #[test]
1866 fn paths_are_summarized_by_where_they_are() {
1867 let paths = [
1868 ".next/BUILD_ID",
1869 ".next/server/app.js",
1870 ".next/server/chunks/a.js",
1871 "tests/e2e/runs/1/report.json",
1872 "coverage/report.json",
1873 "playwright-report/index.html",
1874 "notes.txt",
1875 "out/a.txt",
1876 ]
1877 .map(PathBuf::from);
1878 assert_eq!(
1879 summarize_paths(&paths),
1880 ".next/ 3, coverage/ 1, notes.txt, out/ 1, 2 elsewhere"
1881 );
1882 assert_eq!(summarize_paths(&paths[..1]), ".next/ 1");
1883 }
1884
1885 #[test]
1886 fn a_report_that_quotes_the_runtime_does_not_make_its_directory_a_build() {
1887 let (root, workspace) = writeback_fixture("report-quotes-runtime");
1890 let baseline = workspace_output_baseline(&workspace).unwrap();
1891 fs::create_dir_all(workspace.join("test-results/adds-an-item")).unwrap();
1892 fs::write(
1893 workspace.join("test-results/adds-an-item/error-context.md"),
1894 "at launch (/w/.supercov/node_modules/playwright.mjs:907:44)\n",
1895 )
1896 .unwrap();
1897 fs::write(
1898 workspace.join("test-results/adds-an-item/trace.zip"),
1899 "trace",
1900 )
1901 .unwrap();
1902
1903 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1904
1905 assert_eq!(
1906 sync.skipped_instrumented,
1907 vec![PathBuf::from("test-results/adds-an-item/error-context.md")]
1908 );
1909 assert_eq!(sync.synced, 1);
1910 assert!(root.join("test-results/adds-an-item/trace.zip").exists());
1911 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1912 }
1913
1914 #[test]
1915 fn a_build_directory_git_ignores_stays_behind_even_when_the_project_has_one() {
1916 let (root, workspace) = writeback_fixture("built-ignored");
1917 let git = |arguments: &[&str]| {
1918 std::process::Command::new("git")
1919 .arg("-C")
1920 .arg(&root)
1921 .args(arguments)
1922 .output()
1923 .unwrap()
1924 };
1925 if !git(&["init", "-q"]).status.success() {
1926 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1927 return;
1928 }
1929 fs::write(root.join(".gitignore"), ".next\n").unwrap();
1930 fs::create_dir_all(root.join(".next")).unwrap();
1931 fs::write(root.join(".next/BUILD_ID"), "mine\n").unwrap();
1932 let baseline = workspace_output_baseline(&workspace).unwrap();
1933 fs::create_dir_all(workspace.join(".next/server")).unwrap();
1934 fs::write(
1935 workspace.join(".next/server/chunk.js"),
1936 "__supercovCoverageHitV3(__supercovProbeFileV2, 0);\n",
1939 )
1940 .unwrap();
1941 fs::write(workspace.join(".next/BUILD_ID"), "instrumented\n").unwrap();
1942
1943 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1944
1945 assert_eq!(sync.synced, 0);
1946 assert_eq!(sync.skipped_instrumented.len(), 2);
1947 assert_eq!(
1948 fs::read_to_string(root.join(".next/BUILD_ID")).unwrap(),
1949 "mine\n"
1950 );
1951 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1952 }
1953
1954 #[test]
1955 fn a_minified_build_of_the_instrumented_copy_stays_in_the_workspace() {
1956 let (root, workspace) = writeback_fixture("built-minified");
1957 let initialized = std::process::Command::new("git")
1958 .arg("-C")
1959 .arg(&root)
1960 .args(["init", "-q"])
1961 .output()
1962 .unwrap();
1963 if !initialized.status.success() {
1964 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1965 return;
1966 }
1967 fs::write(root.join(".gitignore"), "dist\n").unwrap();
1968 fs::create_dir_all(root.join("dist")).unwrap();
1969 fs::write(root.join("dist/index.html"), "mine\n").unwrap();
1970 let baseline = workspace_output_baseline(&workspace).unwrap();
1971 fs::create_dir_all(workspace.join("dist/assets")).unwrap();
1972 fs::write(
1973 workspace.join("dist/assets/index-DzZxFCVp.js"),
1974 "const e=globalThis.__SUPERCOV_DIRECT_RUNTIME__.coverageHitV2;e(t,0);\n",
1977 )
1978 .unwrap();
1979 fs::write(workspace.join("dist/index.html"), "instrumented\n").unwrap();
1980
1981 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1982
1983 assert_eq!(sync.synced, 0);
1984 assert_eq!(sync.skipped_instrumented.len(), 2);
1985 assert_eq!(
1986 fs::read_to_string(root.join("dist/index.html")).unwrap(),
1987 "mine\n"
1988 );
1989 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1990 }
1991
1992 #[test]
1993 fn command_outputs_flow_back_to_the_project() {
1994 let (root, workspace) = writeback_fixture("outputs");
1995 let baseline = workspace_output_baseline(&workspace).unwrap();
1996 fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
1997 fs::write(
1998 workspace.join("src/__snapshots__/app.snap"),
1999 "updated snapshot\n",
2000 )
2001 .unwrap();
2002 fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
2003 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2004 assert_eq!(sync.synced, 1);
2005 assert_eq!(
2006 fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
2007 "updated snapshot\n"
2008 );
2009 assert!(!root.join(".supercov/state.json").exists());
2010 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2011 }
2012
2013 #[test]
2014 fn instrumented_sources_never_flow_back() {
2015 let (root, workspace) = writeback_fixture("protected");
2016 let baseline = workspace_output_baseline(&workspace).unwrap();
2017 fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
2020 let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
2021 let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
2022 assert_eq!(sync.synced, 0);
2023 assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
2024 assert_eq!(
2025 fs::read_to_string(root.join("src/app.ts")).unwrap(),
2026 "original\n"
2027 );
2028 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2029 }
2030
2031 #[test]
2032 fn workspace_deletions_are_reported_but_never_propagated() {
2033 let (root, workspace) = writeback_fixture("deletions");
2034 fs::write(workspace.join("stale.txt"), "old\n").unwrap();
2035 fs::write(root.join("stale.txt"), "old\n").unwrap();
2036 fs::write(workspace.join("built.txt"), "old\n").unwrap();
2038 let baseline = workspace_output_baseline(&workspace).unwrap();
2039 fs::remove_file(workspace.join("stale.txt")).unwrap();
2040 fs::remove_file(workspace.join("built.txt")).unwrap();
2041 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2042 assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
2043 assert!(root.join("stale.txt").exists());
2044 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2045 }
2046
2047 #[cfg(unix)]
2048 #[test]
2049 fn writeback_refuses_a_symlinked_destination() {
2050 let (root, workspace) = writeback_fixture("symlink");
2051 let outside = root.parent().unwrap().join("outside");
2052 fs::create_dir_all(&outside).unwrap();
2053 std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
2054 let baseline = workspace_output_baseline(&workspace).unwrap();
2055 fs::create_dir_all(workspace.join("reports")).unwrap();
2056 fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
2057 let error =
2058 sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
2059 assert!(matches!(error, WorkspaceError::UnsafePath(_)));
2060 assert!(!outside.join("result.txt").exists());
2061 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2062 }
2063
2064 struct OrdinaryCopyOperations;
2065
2066 impl WorkspaceOperations for OrdinaryCopyOperations {
2067 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2068 fs::copy(source, destination)
2069 .map(|_| ())
2070 .map_err(|error| io_error(destination, error))
2071 }
2072
2073 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2074 atomic_rename(source, destination).map_err(WorkspaceError::from)
2075 }
2076 }
2077
2078 struct FaultOperations {
2079 copy_count: usize,
2080 fail_copy_at: Option<usize>,
2081 rename_count: usize,
2082 fail_rename_at: Option<usize>,
2083 }
2084
2085 impl WorkspaceOperations for FaultOperations {
2086 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2087 self.copy_count += 1;
2088 if self.fail_copy_at == Some(self.copy_count) {
2089 return Err(io_error(
2090 destination,
2091 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
2092 ));
2093 }
2094 SystemWorkspaceOperations.copy_file(source, destination)
2095 }
2096
2097 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2098 self.rename_count += 1;
2099 if self.fail_rename_at == Some(self.rename_count) {
2100 return Err(io_error(
2101 destination,
2102 io::Error::other("injected publication rename failure"),
2103 ));
2104 }
2105 SystemWorkspaceOperations.rename(source, destination)
2106 }
2107 }
2108
2109 fn transaction_debris(root: &Path) -> Vec<String> {
2110 let workspace = cached_workspace_path(root).unwrap();
2111 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
2112 fs::read_dir(workspace.parent().unwrap())
2113 .unwrap()
2114 .filter_map(Result::ok)
2115 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2116 .filter(|name| name.starts_with(&prefix))
2117 .collect()
2118 }
2119
2120 fn project() -> PathBuf {
2121 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
2122 fs::create_dir_all(root.join("src")).unwrap();
2123 fs::write(root.join("src/index.js"), "one").unwrap();
2124 fs::write(root.join("package.json"), "{}").unwrap();
2125 root
2126 }
2127
2128 #[test]
2129 #[cfg(unix)]
2130 fn root_node_modules_symlink_is_followed() {
2131 let root = project();
2136 let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
2137 fs::create_dir_all(store.join("left-pad")).unwrap();
2138 fs::write(store.join("left-pad/package.json"), "{}").unwrap();
2139 std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
2140 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2141 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2142 assert!(
2143 fs::symlink_metadata(workspace.join("node_modules/left-pad"))
2144 .unwrap()
2145 .file_type()
2146 .is_symlink()
2147 );
2148 lock.release().unwrap();
2149 fs::remove_dir_all(root).unwrap();
2150 fs::remove_dir_all(store).unwrap();
2151 }
2152
2153 #[test]
2154 #[cfg(unix)]
2155 fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
2156 let root = project();
2163 let nested = root.join("examples/app/node_modules/@scope");
2164 fs::create_dir_all(&nested).unwrap();
2165 std::os::unix::fs::symlink(
2166 "../../../../packages/react/node_modules/@scope/pkg",
2167 nested.join("pkg"),
2168 )
2169 .unwrap();
2170 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2171 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2172 let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
2173 let metadata = fs::symlink_metadata(&mirrored).unwrap();
2174 assert!(metadata.file_type().is_symlink());
2175 assert_eq!(
2176 fs::read_link(&mirrored).unwrap(),
2177 PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
2178 );
2179 lock.release().unwrap();
2180 fs::remove_dir_all(root).unwrap();
2181 }
2182
2183 #[test]
2184 #[cfg(target_os = "macos")]
2185 fn nested_node_modules_are_cloned_not_linked() {
2186 let root = project();
2192 let nested = root.join("packages/app/node_modules");
2193 fs::create_dir_all(nested.join("dep")).unwrap();
2194 fs::create_dir_all(nested.join(".bin")).unwrap();
2195 fs::write(nested.join("dep/index.js"), "dep").unwrap();
2196 std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
2197 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2198 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2199 let mirrored = workspace.join("packages/app/node_modules");
2200 assert!(
2201 fs::symlink_metadata(mirrored.join("dep"))
2202 .unwrap()
2203 .file_type()
2204 .is_dir()
2205 );
2206 assert_eq!(
2207 fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
2208 "dep"
2209 );
2210 assert_eq!(
2211 fs::read_link(mirrored.join(".bin/dep")).unwrap(),
2212 PathBuf::from("../dep/index.js")
2213 );
2214 fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
2215 assert_eq!(
2216 fs::read_to_string(nested.join("dep/index.js")).unwrap(),
2217 "dep"
2218 );
2219 lock.release().unwrap();
2220 fs::remove_dir_all(root).unwrap();
2221 }
2222
2223 #[test]
2224 #[cfg(unix)]
2225 fn hard_linked_dependency_tree_is_real_and_replaceable() {
2226 use std::os::unix::fs::MetadataExt;
2230 let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
2231 let source = base.join("node_modules");
2232 fs::create_dir_all(source.join("dep")).unwrap();
2233 fs::create_dir_all(source.join(".bin")).unwrap();
2234 fs::write(source.join("dep/index.js"), "dep").unwrap();
2235 std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
2236 let destination = base.join("workspace/node_modules");
2237 fs::create_dir_all(destination.parent().unwrap()).unwrap();
2238 assert!(hard_link_tree(&source, &destination));
2239 assert_eq!(
2240 fs::metadata(destination.join("dep/index.js"))
2241 .unwrap()
2242 .ino(),
2243 fs::metadata(source.join("dep/index.js")).unwrap().ino()
2244 );
2245 assert_eq!(
2246 fs::read_link(destination.join(".bin/dep")).unwrap(),
2247 PathBuf::from("../dep/index.js")
2248 );
2249 fs::remove_dir_all(destination.join("dep")).unwrap();
2250 fs::create_dir_all(destination.join("dep")).unwrap();
2251 fs::write(destination.join("dep/index.js"), "replaced").unwrap();
2252 assert_eq!(
2253 fs::read_to_string(source.join("dep/index.js")).unwrap(),
2254 "dep"
2255 );
2256 fs::remove_dir_all(base).unwrap();
2257 }
2258
2259 #[test]
2260 #[cfg(unix)]
2261 fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
2262 let root = project();
2268 let nested = root.join("examples/app/lib");
2269 fs::create_dir_all(&nested).unwrap();
2270 std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
2271 .unwrap();
2272 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2273 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2274 assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
2275 assert!(workspace.join("src/index.js").exists());
2276 lock.release().unwrap();
2277 fs::remove_dir_all(root).unwrap();
2278 }
2279
2280 #[test]
2281 fn isolated_copy_never_changes_source_and_requires_the_lock() {
2282 let root = project();
2283 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2284 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2285 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
2286 assert_eq!(
2287 fs::read_to_string(root.join("src/index.js")).unwrap(),
2288 "one"
2289 );
2290 lock.release().unwrap();
2291 assert!(matches!(
2292 prepare_isolated_workspace(&root, "other", &lock),
2293 Err(WorkspaceError::MissingLock)
2294 ));
2295 fs::remove_dir_all(root).unwrap();
2296 }
2297
2298 #[test]
2299 fn everything_supercov_writes_lives_under_the_store() {
2300 let root = project();
2301 assert_eq!(
2302 workspace_container(&root),
2303 root.join(".supercov/workspaces")
2304 );
2305 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2306 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2307 assert!(workspace.starts_with(root.join(".supercov")));
2308 assert_eq!(
2309 fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
2310 "*\n"
2311 );
2312 lock.release().unwrap();
2313 fs::remove_dir_all(root).unwrap();
2314 }
2315
2316 #[test]
2317 fn an_unowned_container_gets_a_deterministic_fallback() {
2318 let root = project();
2319 fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
2320 fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
2321 let container = workspace_container(&root);
2322 assert_ne!(container, root.join(".supercov/workspaces"));
2323 let name = container
2324 .file_name()
2325 .unwrap()
2326 .to_string_lossy()
2327 .into_owned();
2328 assert!(name.starts_with("workspaces-"));
2329 fs::remove_dir_all(root).unwrap();
2330 }
2331
2332 #[test]
2333 fn user_supercov_directory_is_copied_and_never_adopted() {
2334 let root = project();
2335 fs::create_dir(root.join("supercov")).unwrap();
2336 fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
2337 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2338 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2339 assert_ne!(workspace_container(&root), root.join("supercov"));
2340 assert_eq!(
2341 fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
2342 "export default 1;\n"
2343 );
2344 assert_eq!(
2345 fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
2346 "export default 1;\n"
2347 );
2348 assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
2349 lock.release().unwrap();
2350 fs::remove_dir_all(root).unwrap();
2351 }
2352
2353 #[cfg(unix)]
2354 #[test]
2355 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
2356 let root = project();
2357 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2358 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2359 fs::create_dir_all(first.join("build")).unwrap();
2360 fs::write(first.join("build/output.js"), "instrumented").unwrap();
2361 fs::write(first.join("stale.txt"), "stale").unwrap();
2362 fs::write(root.join("src/index.js"), "two").unwrap();
2363 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
2364 assert_eq!(first, second);
2365 assert_eq!(
2366 fs::read_to_string(second.join("src/index.js")).unwrap(),
2367 "two"
2368 );
2369 assert_eq!(
2370 fs::read_to_string(second.join("build/output.js")).unwrap(),
2371 "instrumented"
2372 );
2373 assert!(!second.join("stale.txt").exists());
2374 lock.release().unwrap();
2375 fs::remove_dir_all(root).unwrap();
2376 }
2377
2378 #[test]
2379 fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
2380 let root = project();
2381 fs::create_dir_all(root.join("packages/app/dist")).unwrap();
2382 fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
2383 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2384 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2385 fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
2386 fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
2387 let second =
2388 prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
2389 assert_eq!(
2390 fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
2391 "instrumented"
2392 );
2393 assert!(!second.join("packages/app/dist/stale.js").exists());
2394 lock.release().unwrap();
2395 fs::remove_dir_all(root).unwrap();
2396 }
2397
2398 #[test]
2399 fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
2400 let root = project();
2401 fs::create_dir_all(root.join("tests/e2e")).unwrap();
2402 fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
2403 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2404 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2405 let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
2406 fs::create_dir_all(&artifacts).unwrap();
2407 fs::write(artifacts.join("digest"), "instrumented test").unwrap();
2408 fs::write(
2409 workspace.join(".supercov/frontend-cache.json"),
2410 "{\"schemaVersion\":2}",
2411 )
2412 .unwrap();
2413 prune_cached_workspace_sources(&root, &lock).unwrap();
2414 assert!(!workspace.join("tests/e2e/app.spec.js").exists());
2415 assert_eq!(
2416 fs::read_to_string(artifacts.join("digest")).unwrap(),
2417 "instrumented test"
2418 );
2419 lock.release().unwrap();
2420 fs::remove_dir_all(root).unwrap();
2421 }
2422
2423 #[test]
2424 fn ordinary_copy_fallback_preserves_workspace_semantics() {
2425 let root = project();
2426 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2427 let mut operations = OrdinaryCopyOperations;
2428 let workspace =
2429 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
2430 assert_eq!(
2431 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2432 "one"
2433 );
2434 assert_eq!(
2435 fs::read_to_string(root.join("src/index.js")).unwrap(),
2436 "one"
2437 );
2438 assert!(transaction_debris(&root).is_empty());
2439 lock.release().unwrap();
2440 fs::remove_dir_all(root).unwrap();
2441 }
2442
2443 #[test]
2444 fn enospc_during_copy_preserves_the_complete_generation() {
2445 let root = project();
2446 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2447 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2448 fs::write(workspace.join("generation"), "complete").unwrap();
2449 fs::write(root.join("src/index.js"), "new source").unwrap();
2450 let mut operations = FaultOperations {
2451 copy_count: 0,
2452 fail_copy_at: Some(1),
2453 rename_count: 0,
2454 fail_rename_at: None,
2455 };
2456 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2457 .unwrap_err();
2458 assert!(matches!(
2459 error,
2460 WorkspaceError::Io { ref source, .. }
2461 if source.kind() == io::ErrorKind::StorageFull
2462 ));
2463 assert_eq!(
2464 fs::read_to_string(workspace.join("generation")).unwrap(),
2465 "complete"
2466 );
2467 assert_eq!(
2468 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2469 "one"
2470 );
2471 assert!(transaction_debris(&root).is_empty());
2472 lock.release().unwrap();
2473 fs::remove_dir_all(root).unwrap();
2474 }
2475
2476 #[test]
2477 fn failed_publication_rename_restores_the_complete_generation() {
2478 let root = project();
2479 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2480 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2481 fs::write(workspace.join("generation"), "complete").unwrap();
2482 fs::write(root.join("src/index.js"), "new source").unwrap();
2483 let mut operations = FaultOperations {
2484 copy_count: 0,
2485 fail_copy_at: None,
2486 rename_count: 0,
2487 fail_rename_at: Some(2),
2488 };
2489 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2490 .unwrap_err();
2491 assert!(matches!(
2492 error,
2493 WorkspaceError::Io { ref source, .. }
2494 if source.kind() == io::ErrorKind::Other
2495 ));
2496 assert_eq!(
2497 operations.rename_count, 3,
2498 "prior generation was not restored"
2499 );
2500 assert_eq!(
2501 fs::read_to_string(workspace.join("generation")).unwrap(),
2502 "complete"
2503 );
2504 assert_eq!(
2505 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2506 "one"
2507 );
2508 assert!(transaction_debris(&root).is_empty());
2509 lock.release().unwrap();
2510 fs::remove_dir_all(root).unwrap();
2511 }
2512
2513 #[cfg(unix)]
2514 #[test]
2515 fn relocates_internal_links_and_rejects_external_links() {
2516 use std::os::unix::fs::symlink;
2517
2518 let root = project();
2519 fs::create_dir_all(root.join("shared")).unwrap();
2520 fs::write(root.join("shared/value"), "inside").unwrap();
2521 symlink("../shared/value", root.join("src/value-link")).unwrap();
2522 let external = project();
2523 fs::write(external.join("outside"), "outside").unwrap();
2524 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2525 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2526 assert_eq!(
2527 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
2528 "inside"
2529 );
2530 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
2531 let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2532 assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
2533 assert_eq!(
2534 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2535 "one"
2536 );
2537 lock.release().unwrap();
2538 fs::remove_dir_all(root).unwrap();
2539 fs::remove_dir_all(external).unwrap();
2540 }
2541
2542 #[cfg(windows)]
2543 #[test]
2544 fn relocates_internal_junctions_without_symlink_privileges() {
2545 let root = project();
2546 fs::create_dir_all(root.join("shared")).unwrap();
2547 fs::write(root.join("shared/value"), "inside").unwrap();
2548 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
2549 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2550 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2551 let isolated_link = workspace.join("linked-shared");
2552 assert!(junction::exists(&isolated_link).unwrap());
2553 assert_eq!(
2554 canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
2555 canonicalize_simplified(workspace.join("shared")).unwrap()
2556 );
2557 assert_eq!(
2558 fs::read_to_string(isolated_link.join("value")).unwrap(),
2559 "inside"
2560 );
2561 lock.release().unwrap();
2562 fs::remove_dir_all(root).unwrap();
2563 }
2564
2565 #[cfg(windows)]
2566 #[test]
2567 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
2568 let root = project();
2569 fs::create_dir_all(root.join("node_modules/example")).unwrap();
2570 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
2571 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
2572 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2573 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2574 let package = workspace.join("node_modules/example");
2575 assert!(junction::exists(&package).unwrap());
2576 assert_eq!(
2577 canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
2578 canonicalize_simplified(root.join("node_modules/example")).unwrap()
2579 );
2580 let metadata = workspace.join("node_modules/.package-lock.json");
2581 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
2582 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
2583 lock.release().unwrap();
2584 fs::remove_dir_all(root).unwrap();
2585 }
2586
2587 #[test]
2588 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
2589 let root = project();
2590 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2591 ensure_container(&root).unwrap();
2592 let workspace = cached_workspace_path(&root).unwrap();
2593 let parent = workspace.parent().unwrap();
2594 fs::create_dir_all(parent).unwrap();
2595 let previous = parent.join(format!(
2596 "{}old",
2597 transaction_prefix(&root, "previous").unwrap()
2598 ));
2599 let staging = parent.join(format!(
2600 "{}new",
2601 transaction_prefix(&root, "staging").unwrap()
2602 ));
2603 fs::create_dir_all(&previous).unwrap();
2604 fs::write(previous.join("complete"), "yes").unwrap();
2605 fs::create_dir_all(&staging).unwrap();
2606 let result = recover_cached_workspace(&root, &lock).unwrap();
2607 assert!(result.restored_previous);
2608 assert_eq!(result.removed_staging, 1);
2609 assert_eq!(
2610 fs::read_to_string(workspace.join("complete")).unwrap(),
2611 "yes"
2612 );
2613 lock.release().unwrap();
2614 fs::remove_dir_all(root).unwrap();
2615 }
2616}