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
242fn copy_directory_name(copy: usize) -> String {
246 if copy <= 1 {
247 "workspace".into()
248 } else {
249 format!("workspace-{copy}")
250 }
251}
252
253pub(crate) fn copy_directories(container: &Path, copy: usize) -> [PathBuf; 2] {
256 let name = copy_directory_name(copy);
257 [
258 container.join(&name),
259 container.join(name.replacen("workspace", "source", 1)),
260 ]
261}
262
263pub fn cached_workspace_path(root: &Path, lock: &ProjectLock) -> Result<PathBuf, WorkspaceError> {
265 copy_path(root, lock.copy())
266}
267
268pub fn copy_path(root: &Path, copy: usize) -> Result<PathBuf, WorkspaceError> {
270 Ok(workspace_container(root)
271 .join(copy_directory_name(copy))
272 .join(project_name(root)?))
273}
274
275pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
276 if run_id.is_empty()
277 || run_id == "."
278 || run_id == ".."
279 || run_id
280 .chars()
281 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
282 {
283 return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
284 }
285 Ok(workspace_container(root)
286 .join("work")
287 .join(run_id)
288 .join(project_name(root)?))
289}
290
291pub(crate) fn owned_workspace_path(path: &Path) -> bool {
292 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
293 && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
294 .is_ok_and(|metadata| metadata.file_type().is_file())
295 && fs::read(path.join(WORKSPACE_MARKER))
296 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
297}
298
299fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
300 let container = workspace_container(root);
301 let mut created = false;
302 match fs::symlink_metadata(&container) {
303 Ok(metadata) if !metadata.file_type().is_dir() => {
304 return Err(WorkspaceError::UnsafePath(container));
305 }
306 Ok(_) => {}
307 Err(error) if error.kind() == io::ErrorKind::NotFound => {
308 let store = container.parent().expect("container parent");
309 fs::create_dir_all(store).map_err(|source| io_error(store, source))?;
310 let store_ignore = root.join(".supercov/.gitignore");
313 if fs::symlink_metadata(&store_ignore).is_err() {
314 let _ = fs::write(&store_ignore, b"*\n");
315 }
316 let made = store.join(format!(".workspaces-{}", unique()));
321 fs::create_dir(&made).map_err(|source| io_error(&made, source))?;
322 fs::write(made.join(WORKSPACE_MARKER), WORKSPACE_MARKER_CONTENTS)
323 .map_err(|source| io_error(&made, source))?;
324 match fs::rename(&made, &container) {
325 Ok(()) => created = true,
326 Err(_) => {
328 let _ = fs::remove_dir_all(&made);
329 }
330 }
331 }
332 Err(source) => return Err(io_error(&container, source)),
333 }
334 let result = (|| {
335 if !created && !owned_workspace_path(&container) {
336 return Err(WorkspaceError::UnsafePath(container.clone()));
337 }
338 for (name, contents) in [
339 (".gitignore", b"*\n".as_slice()),
340 (WORKSPACE_MARKER, WORKSPACE_MARKER_CONTENTS),
341 ] {
342 let path = container.join(name);
343 match OpenOptions::new().write(true).create_new(true).open(&path) {
344 Ok(mut file) => file
345 .write_all(contents)
346 .and_then(|_| file.sync_all())
347 .map_err(|source| io_error(&path, source))?,
348 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
349 if !fs::symlink_metadata(&path)
350 .is_ok_and(|metadata| metadata.file_type().is_file())
351 {
352 return Err(WorkspaceError::UnsafePath(path));
353 }
354 if name == WORKSPACE_MARKER
355 && !fs::read(&path)
356 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
357 {
358 return Err(WorkspaceError::UnsafePath(path));
359 }
360 }
361 Err(source) => return Err(io_error(&path, source)),
362 }
363 }
364 Ok(container.clone())
365 })();
366 if result.is_err() && created {
367 let _ = fs::remove_dir_all(&container);
368 }
369 result
370}
371
372fn lexical_normalize(path: &Path) -> Option<PathBuf> {
373 let mut normalized = PathBuf::new();
374 for component in path.components() {
375 match component {
376 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
377 Component::RootDir => normalized.push(component.as_os_str()),
378 Component::CurDir => {}
379 Component::ParentDir => {
380 if !normalized.pop() {
381 return None;
382 }
383 }
384 Component::Normal(value) => normalized.push(value),
385 }
386 }
387 Some(normalized)
388}
389
390fn inside(root: &Path, path: &Path) -> bool {
391 path == root
392 || path.strip_prefix(root).is_ok_and(|local| {
393 !local.as_os_str().is_empty()
394 && local
395 .components()
396 .all(|component| matches!(component, Component::Normal(_)))
397 })
398}
399
400trait WorkspaceOperations {
401 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
402 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
403}
404
405struct SystemWorkspaceOperations;
406
407impl WorkspaceOperations for SystemWorkspaceOperations {
408 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
409 const REFLINK_MINIMUM_BYTES: u64 = 1024 * 1024;
414 let bytes = fs::symlink_metadata(source)
415 .map_err(|source_error| io_error(source, source_error))?
416 .len();
417 if bytes < REFLINK_MINIMUM_BYTES {
418 fs::copy(source, destination)
419 .map(|_| ())
420 .map_err(|source_error| io_error(destination, source_error))
421 } else {
422 reflink_copy::reflink_or_copy(source, destination)
423 .map(|_| ())
424 .map_err(|source_error| io_error(destination, source_error))
425 }
426 }
427
428 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
429 atomic_rename(source, destination).map_err(WorkspaceError::from)
430 }
431}
432
433#[cfg(unix)]
434pub(crate) fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
435 std::os::unix::fs::symlink(target, destination)
436}
437
438#[cfg(windows)]
439pub(crate) fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
440 if directory {
441 match junction::create(target, destination) {
448 Ok(()) => Ok(()),
449 Err(junction_error) => {
450 let _ = fs::remove_dir(destination);
451 std::os::windows::fs::symlink_dir(target, destination).map_err(
452 |symlink_error| {
453 io::Error::new(
454 symlink_error.kind(),
455 format!(
456 "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
457 ),
458 )
459 },
460 )
461 }
462 }
463 } else {
464 std::os::windows::fs::symlink_file(target, destination)
468 }
469}
470
471#[cfg(target_os = "macos")]
477fn clone_directory(source: &Path, destination: &Path) -> bool {
478 use std::{ffi::CString, os::unix::ffi::OsStrExt};
479
480 let (Ok(source_c), Ok(destination_c)) = (
481 CString::new(source.as_os_str().as_bytes()),
482 CString::new(destination.as_os_str().as_bytes()),
483 ) else {
484 return false;
485 };
486 let status = unsafe { libc::clonefile(source_c.as_ptr(), destination_c.as_ptr(), 0) };
489 if status == 0 {
490 return true;
491 }
492 let _ = fs::remove_dir_all(destination);
493 false
494}
495
496#[cfg(all(unix, not(target_os = "macos")))]
497fn clone_directory(_source: &Path, _destination: &Path) -> bool {
498 false
499}
500
501#[cfg(unix)]
513fn hard_link_tree(source: &Path, destination: &Path) -> bool {
514 fn link(source: &Path, destination: &Path) -> io::Result<()> {
515 fs::create_dir(destination)?;
516 for entry in fs::read_dir(source)? {
517 let entry = entry?;
518 let from = entry.path();
519 let to = destination.join(entry.file_name());
520 let file_type = entry.file_type()?;
521 if file_type.is_dir() {
522 link(&from, &to)?;
523 } else if file_type.is_symlink() {
524 std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?;
525 } else if file_type.is_file() {
526 fs::hard_link(&from, &to)?;
527 } else {
528 return Err(io::Error::new(
529 io::ErrorKind::Unsupported,
530 "entry is neither a file, a directory nor a symlink",
531 ));
532 }
533 }
534 Ok(())
535 }
536 if link(source, destination).is_ok() {
537 return true;
538 }
539 let _ = fs::remove_dir_all(destination);
540 false
541}
542
543#[derive(Clone, Copy)]
544struct CopyRoots<'a> {
545 source: &'a Path,
546 destination: &'a Path,
547 final_destination: &'a Path,
548 canonical_source: &'a Path,
549}
550
551fn copy_tree<Operations: WorkspaceOperations>(
552 source: &Path,
553 destination: &Path,
554 roots: CopyRoots<'_>,
555 root_level: bool,
556 operations: &mut Operations,
557) -> Result<(), WorkspaceError> {
558 fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
559 let mut entries = fs::read_dir(source)
560 .map_err(|error| io_error(source, error))?
561 .collect::<Result<Vec<_>, _>>()
562 .map_err(|error| io_error(source, error))?;
563 entries.sort_by_key(fs::DirEntry::file_name);
564 for entry in entries {
565 let name = entry.file_name();
566 let name_text = name
567 .to_str()
568 .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
569 if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
570 || NESTED_EXCLUSIONS.contains(&name_text)
571 {
572 continue;
573 }
574 let from = entry.path();
575 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
576 if metadata.file_type().is_dir() && owned_workspace_path(&from) {
577 continue;
578 }
579 let to = destination.join(&name);
580 if metadata.file_type().is_dir() && from.join("pyvenv.cfg").is_file() {
585 create_link(&from, &to, true).map_err(|error| io_error(&to, error))?;
586 continue;
587 }
588 if metadata.file_type().is_dir() {
589 #[cfg(unix)]
612 if name_text == "node_modules" && !root_level {
613 if clone_directory(&from, &to) || hard_link_tree(&from, &to) {
614 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
617 .map_err(|error| io_error(&to, error))?;
618 continue;
619 }
620 fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
621 let mut packages = fs::read_dir(&from)
622 .map_err(|error| io_error(&from, error))?
623 .collect::<Result<Vec<_>, _>>()
624 .map_err(|error| io_error(&from, error))?;
625 packages.sort_by_key(fs::DirEntry::file_name);
626 for package in packages {
627 let link_destination = to.join(package.file_name());
628 if package.file_name() == ".bin" && package.path().is_dir() {
629 link_tools(&package.path(), &link_destination, operations)?;
630 continue;
631 }
632 create_link(&package.path(), &link_destination, false)
633 .map_err(|error| io_error(&link_destination, error))?;
634 }
635 continue;
636 }
637 copy_tree(&from, &to, roots, false, operations)?;
638 if name_text == "node_modules" && !root_level {
639 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
640 .map_err(|error| io_error(&to, error))?;
641 }
642 } else if metadata.file_type().is_symlink() {
643 let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
644 let unresolved_target = if link.is_absolute() {
645 link.clone()
646 } else {
647 from.parent().expect("entry parent").join(&link)
648 };
649 let Some(lexical_target) = lexical_normalize(&unresolved_target) else {
650 omit_escaping_link(&from, &link);
651 continue;
652 };
653 let canonical_target = match canonicalize_simplified(&from) {
661 Ok(target) => Some(target),
662 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
663 Err(error) => return Err(io_error(&from, error)),
664 };
665 let escapes = !inside(roots.source, &lexical_target)
666 || canonical_target
667 .as_deref()
668 .is_some_and(|target| !inside(roots.canonical_source, target));
669 if escapes {
670 omit_escaping_link(&from, &link);
671 continue;
672 }
673 let local_target = lexical_target
674 .strip_prefix(roots.source)
675 .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
676 let relocated = roots.destination.join(local_target);
677 let Some(canonical_target) = canonical_target else {
678 if cfg!(windows) {
679 continue;
683 }
684 let isolated_link = if link.is_absolute() {
685 pathdiff(&to, &relocated)?
686 } else {
687 link
688 };
689 create_link(&isolated_link, &to, false).map_err(|error| io_error(&to, error))?;
690 continue;
691 };
692 let target_metadata = fs::metadata(&canonical_target)
693 .map_err(|error| io_error(&canonical_target, error))?;
694 let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
695 roots.final_destination.join(local_target)
696 } else if link.is_absolute() {
697 pathdiff(&to, &relocated)?
698 } else {
699 link
700 };
701 create_link(&isolated_link, &to, target_metadata.is_dir())
702 .map_err(|error| io_error(&to, error))?;
703 } else if metadata.file_type().is_file() {
704 operations.copy_file(&from, &to)?;
705 }
706 }
711 Ok(())
712}
713
714fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
715 let from = from
716 .parent()
717 .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
718 let from_components = from.components().collect::<Vec<_>>();
719 let to_components = to.components().collect::<Vec<_>>();
720 let common = from_components
721 .iter()
722 .zip(&to_components)
723 .take_while(|(left, right)| left == right)
724 .count();
725 if common == 0 {
726 return Err(WorkspaceError::UnsafePath(to.into()));
727 }
728 let mut relative = PathBuf::new();
729 for _ in common..from_components.len() {
730 relative.push("..");
731 }
732 for component in &to_components[common..] {
733 relative.push(component.as_os_str());
734 }
735 Ok(relative)
736}
737
738#[cfg(unix)]
748fn link_package(
749 root: &Path,
750 workspace: &Path,
751 target: &Path,
752 to: &Path,
753) -> Result<(), WorkspaceError> {
754 let metadata = fs::symlink_metadata(target).map_err(|error| io_error(target, error))?;
755 let scope = target
756 .file_name()
757 .and_then(|name| name.to_str())
758 .is_some_and(|name| name.starts_with('@'));
759 if scope && metadata.file_type().is_dir() {
760 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
761 let mut packages = fs::read_dir(target)
762 .map_err(|error| io_error(target, error))?
763 .collect::<Result<Vec<_>, _>>()
764 .map_err(|error| io_error(target, error))?;
765 packages.sort_by_key(fs::DirEntry::file_name);
766 for package in packages {
767 link_package(
768 root,
769 workspace,
770 &package.path(),
771 &to.join(package.file_name()),
772 )?;
773 }
774 return Ok(());
775 }
776 if metadata.file_type().is_symlink()
777 && let (Ok(resolved), Ok(canonical_root)) = (
778 canonicalize_simplified(target),
779 canonicalize_simplified(root),
780 )
781 && let Ok(local) = resolved.strip_prefix(&canonical_root)
782 && !local.as_os_str().is_empty()
783 && !local.components().any(|part| {
784 matches!(
785 part.as_os_str().to_str(),
786 Some("node_modules" | ".supercov")
787 )
788 })
789 {
790 let copy = workspace.join(local);
791 if fs::symlink_metadata(©).is_ok() {
792 return create_link(&pathdiff(to, ©)?, to, true)
793 .map_err(|error| io_error(to, error));
794 }
795 }
796 create_link(target, to, false).map_err(|error| io_error(to, error))
797}
798
799fn link_tools<Operations: WorkspaceOperations>(
805 tools: &Path,
806 to: &Path,
807 operations: &mut Operations,
808) -> Result<(), WorkspaceError> {
809 #[cfg(unix)]
810 let _ = &operations;
811 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
812 let judges = crate::source_tools::source_tools();
813 let binary = crate::source_tools::launching_binary();
814 let mut entries = fs::read_dir(tools)
815 .map_err(|error| io_error(tools, error))?
816 .collect::<Result<Vec<_>, _>>()
817 .map_err(|error| io_error(tools, error))?;
818 entries.sort_by_key(fs::DirEntry::file_name);
819 for entry in entries {
820 let (tool, link) = (entry.path(), to.join(entry.file_name()));
821 let launcher = binary.as_deref().and_then(|binary| {
822 crate::source_tools::launcher_for(&judges, binary, tools, &entry.file_name())
823 });
824 if let Some(launcher) = launcher {
825 crate::source_tools::write_launcher(&link, &launcher)
826 .map_err(|error| io_error(&link, error))?;
827 continue;
828 }
829 #[cfg(unix)]
833 create_link(&tool, &link, false).map_err(|error| io_error(&link, error))?;
834 #[cfg(windows)]
835 if fs::metadata(&tool).is_ok_and(|metadata| metadata.is_file()) {
836 operations.copy_file(&tool, &link)?;
837 }
838 }
839 Ok(())
840}
841
842fn link_node_modules<Operations: WorkspaceOperations>(
843 root: &Path,
844 workspace: &Path,
845 operations: &mut Operations,
846) -> Result<(), WorkspaceError> {
847 #[cfg(unix)]
848 let _ = &operations;
849 let source = root.join("node_modules");
850 let source_metadata = match fs::symlink_metadata(&source) {
851 Ok(metadata) => metadata,
852 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
853 Err(error) => return Err(io_error(&source, error)),
854 };
855 let source = if source_metadata.file_type().is_symlink() {
860 let resolved =
861 canonicalize_simplified(&source).map_err(|error| io_error(&source, error))?;
862 if !fs::symlink_metadata(&resolved)
863 .map_err(|error| io_error(&resolved, error))?
864 .file_type()
865 .is_dir()
866 {
867 return Err(WorkspaceError::UnsafePath(source));
868 }
869 resolved
870 } else if source_metadata.file_type().is_dir() {
871 source
872 } else {
873 return Err(WorkspaceError::UnsafePath(source));
874 };
875 let destination = workspace.join("node_modules");
876 fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
877 let mut entries = fs::read_dir(&source)
878 .map_err(|error| io_error(&source, error))?
879 .collect::<Result<Vec<_>, _>>()
880 .map_err(|error| io_error(&source, error))?;
881 entries.sort_by_key(fs::DirEntry::file_name);
882 for entry in entries {
883 let target = entry.path();
884 let to = destination.join(entry.file_name());
885 if entry.file_name() == ".bin"
886 && fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir())
887 {
888 link_tools(&target, &to, operations)?;
889 continue;
890 }
891 #[cfg(unix)]
892 link_package(root, workspace, &target, &to)?;
893 #[cfg(windows)]
894 {
895 let file_type = entry
896 .file_type()
897 .map_err(|error| io_error(&target, error))?;
898 let resolved = if file_type.is_symlink() {
899 canonicalize_simplified(&target).map_err(|error| io_error(&target, error))?
900 } else {
901 target.clone()
902 };
903 let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
904 if metadata.is_dir() {
905 create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
906 } else if metadata.is_file() {
907 operations.copy_file(&resolved, &to)?;
910 } else {
911 return Err(WorkspaceError::UnsupportedEntry(target));
912 }
913 }
914 }
915 Ok(())
916}
917
918fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
919 if lock.protects(root) {
920 Ok(())
921 } else {
922 Err(WorkspaceError::MissingLock)
923 }
924}
925
926pub fn prepare_isolated_workspace(
927 root: &Path,
928 run_id: &str,
929 lock: &ProjectLock,
930) -> Result<PathBuf, WorkspaceError> {
931 require_lock(root, lock)?;
932 ensure_container(root)?;
933 let mut operations = SystemWorkspaceOperations;
934 let workspace = isolated_workspace_path(root, run_id)?;
935 remove_stored_tree_deferred(root, &workspace)?;
936 let canonical_root = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
937 copy_tree(
938 root,
939 &workspace,
940 CopyRoots {
941 source: root,
942 destination: &workspace,
943 final_destination: &workspace,
944 canonical_source: &canonical_root,
945 },
946 true,
947 &mut operations,
948 )?;
949 link_node_modules(root, &workspace, &mut operations)?;
950 Ok(workspace)
951}
952
953fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
954 Ok(format!(
955 ".{}.{}-",
956 project_name(root)?.to_string_lossy(),
957 kind
958 ))
959}
960
961fn transaction_path(
962 root: &Path,
963 lock: &ProjectLock,
964 kind: &str,
965) -> Result<PathBuf, WorkspaceError> {
966 let workspace = cached_workspace_path(root, lock)?;
967 Ok(workspace.parent().expect("workspace parent").join(format!(
968 "{}{}",
969 transaction_prefix(root, kind)?,
970 unique()
971 )))
972}
973
974#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
975#[serde(rename_all = "camelCase")]
976pub struct CacheRecoveryResult {
977 pub restored_previous: bool,
978 pub removed_staging: usize,
979 pub removed_previous: usize,
980}
981
982pub fn recover_cached_workspace(
983 root: &Path,
984 lock: &ProjectLock,
985) -> Result<CacheRecoveryResult, WorkspaceError> {
986 require_lock(root, lock)?;
987 let workspace = cached_workspace_path(root, lock)?;
988 let parent = workspace.parent().expect("workspace parent");
989 let entries = match fs::read_dir(parent) {
990 Ok(entries) => entries,
991 Err(error) if error.kind() == io::ErrorKind::NotFound => {
992 return Ok(CacheRecoveryResult {
993 restored_previous: false,
994 removed_staging: 0,
995 removed_previous: 0,
996 });
997 }
998 Err(error) => return Err(io_error(parent, error)),
999 };
1000 let staging_prefix = transaction_prefix(root, "staging")?;
1001 let previous_prefix = transaction_prefix(root, "previous")?;
1002 let mut staging = Vec::new();
1003 let mut previous = Vec::new();
1004 let mut invalid_previous = Vec::new();
1005 for entry in entries {
1006 let entry = entry.map_err(|error| io_error(parent, error))?;
1007 let name = entry.file_name().to_string_lossy().into_owned();
1008 if name.starts_with(&staging_prefix) {
1009 staging.push(entry.path());
1010 } else if name.starts_with(&previous_prefix) {
1011 let metadata = entry
1012 .file_type()
1013 .map_err(|error| io_error(&entry.path(), error))?;
1014 if metadata.is_dir() {
1015 let modified = entry
1016 .metadata()
1017 .and_then(|metadata| metadata.modified())
1018 .unwrap_or(UNIX_EPOCH);
1019 previous.push((modified, entry.path()));
1020 } else {
1021 invalid_previous.push(entry.path());
1022 }
1023 }
1024 }
1025 previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
1026 let mut restored = false;
1027 if fs::symlink_metadata(&workspace).is_err()
1028 && let Some((_, newest)) = previous.first()
1029 {
1030 atomic_rename(newest, &workspace)?;
1031 previous.remove(0);
1032 restored = true;
1033 }
1034 let removed_staging = staging.len();
1035 let removed_previous = previous.len() + invalid_previous.len();
1036 for path in staging
1037 .into_iter()
1038 .chain(previous.into_iter().map(|(_, path)| path))
1039 .chain(invalid_previous)
1040 {
1041 remove_stored_tree_deferred(root, &path)?;
1042 }
1043 Ok(CacheRecoveryResult {
1044 restored_previous: restored,
1045 removed_staging,
1046 removed_previous,
1047 })
1048}
1049
1050fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
1051 if requested.is_absolute()
1052 || requested
1053 .components()
1054 .any(|component| !matches!(component, Component::Normal(_)))
1055 {
1056 return Err(WorkspaceError::UnsafePath(requested.into()));
1057 }
1058 let path = workspace.join(requested);
1059 if path == workspace || fs::symlink_metadata(&path).is_err() {
1060 return Err(WorkspaceError::UnsafePath(requested.into()));
1061 }
1062 Ok(path)
1063}
1064
1065pub fn prepare_cached_workspace(
1066 root: &Path,
1067 lock: &ProjectLock,
1068 reuse_paths: &[PathBuf],
1069) -> Result<PathBuf, WorkspaceError> {
1070 let mut operations = SystemWorkspaceOperations;
1071 prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
1072}
1073
1074fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1075 root: &Path,
1076 lock: &ProjectLock,
1077 reuse_paths: &[PathBuf],
1078 operations: &mut Operations,
1079) -> Result<PathBuf, WorkspaceError> {
1080 require_lock(root, lock)?;
1081 ensure_container(root)?;
1082 recover_cached_workspace(root, lock)?;
1083 let workspace = cached_workspace_path(root, lock)?;
1084 let staging = transaction_path(root, lock, "staging")?;
1085 let previous = transaction_path(root, lock, "previous")?;
1086 let result = (|| {
1087 let canonical_root =
1088 canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1089 copy_tree(
1090 root,
1091 &staging,
1092 CopyRoots {
1093 source: root,
1094 destination: &staging,
1095 final_destination: &workspace,
1096 canonical_source: &canonical_root,
1097 },
1098 true,
1099 operations,
1100 )?;
1101 link_node_modules(root, &staging, operations)?;
1102 for requested in reuse_paths {
1103 let from = checked_reuse_path(&workspace, requested)?;
1104 let to = staging.join(requested);
1105 match fs::symlink_metadata(&to) {
1110 Ok(existing) if existing.file_type().is_dir() => {
1111 fs::remove_dir_all(&to).map_err(|error| io_error(&to, error))?;
1112 }
1113 Ok(_) => {
1114 fs::remove_file(&to).map_err(|error| io_error(&to, error))?;
1115 }
1116 Err(_) => {}
1117 }
1118 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
1119 if metadata.file_type().is_dir() {
1120 let canonical_workspace = canonicalize_simplified(&workspace)
1121 .map_err(|error| io_error(&workspace, error))?;
1122 copy_tree(
1123 &from,
1124 &to,
1125 CopyRoots {
1126 source: &workspace,
1127 destination: &staging,
1128 final_destination: &workspace,
1129 canonical_source: &canonical_workspace,
1130 },
1131 false,
1132 operations,
1133 )?;
1134 } else if metadata.file_type().is_file() {
1135 fs::create_dir_all(to.parent().expect("reuse parent"))
1136 .map_err(|error| io_error(&to, error))?;
1137 operations.copy_file(&from, &to)?;
1138 } else {
1139 return Err(WorkspaceError::UnsupportedEntry(from));
1140 }
1141 }
1142 let mut moved_previous = false;
1143 if fs::symlink_metadata(&workspace).is_ok() {
1144 operations.rename(&workspace, &previous)?;
1145 moved_previous = true;
1146 }
1147 if let Err(error) = operations.rename(&staging, &workspace) {
1148 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1149 let _ = operations.rename(&previous, &workspace);
1150 }
1151 return Err(error);
1152 }
1153 if fs::symlink_metadata(&previous).is_ok() {
1154 remove_stored_tree_deferred(root, &previous)?;
1155 }
1156 Ok(workspace.clone())
1157 })();
1158 if fs::symlink_metadata(&staging).is_ok() {
1159 remove_stored_tree_deferred(root, &staging)?;
1160 }
1161 result
1162}
1163
1164pub struct WorkspaceOutputBaseline {
1168 entries: BTreeMap<PathBuf, (u64, SystemTime)>,
1169}
1170
1171#[derive(Debug, Default, PartialEq, Eq)]
1173pub struct CommandOutputSync {
1174 pub synced: usize,
1175 pub synced_paths: Vec<PathBuf>,
1177 pub skipped_instrumented: Vec<PathBuf>,
1181 pub deleted_in_workspace: Vec<PathBuf>,
1184}
1185
1186fn walk_output_files(
1187 workspace: &Path,
1188 directory: &Path,
1189 entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1190) -> Result<(), WorkspaceError> {
1191 for entry in fs::read_dir(directory)
1192 .map_err(|error| io_error(directory, error))?
1193 .collect::<Result<Vec<_>, _>>()
1194 .map_err(|error| io_error(directory, error))?
1195 {
1196 let path = entry.path();
1197 let name = entry.file_name();
1198 let relative = path
1199 .strip_prefix(workspace)
1200 .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1201 .to_owned();
1202 if relative.components().count() == 1
1206 && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1207 {
1208 continue;
1209 }
1210 let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1211 if metadata.is_dir() && name.to_str() == Some("node_modules") {
1215 continue;
1216 }
1217 if fs::symlink_metadata(&path)
1218 .map_err(|error| io_error(&path, error))?
1219 .file_type()
1220 .is_symlink()
1221 {
1222 continue;
1223 }
1224 if metadata.is_dir() {
1225 walk_output_files(workspace, &path, entries)?;
1226 } else if metadata.is_file() {
1227 let modified = metadata
1228 .modified()
1229 .map_err(|error| io_error(&path, error))?;
1230 entries.insert(relative, (metadata.len(), modified));
1231 }
1232 }
1233 Ok(())
1234}
1235
1236pub fn workspace_output_baseline(
1237 workspace: &Path,
1238) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1239 let mut entries = BTreeMap::new();
1240 walk_output_files(workspace, workspace, &mut entries)?;
1241 Ok(WorkspaceOutputBaseline { entries })
1242}
1243
1244fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1247 if relative.as_os_str().is_empty()
1248 || relative
1249 .components()
1250 .any(|component| !matches!(component, Component::Normal(_)))
1251 {
1252 return Err(WorkspaceError::UnsafePath(relative.into()));
1253 }
1254 let mut current = root.to_owned();
1255 for component in relative.components() {
1256 current.push(component.as_os_str());
1257 match fs::symlink_metadata(¤t) {
1258 Ok(metadata) if metadata.file_type().is_symlink() => {
1259 return Err(WorkspaceError::UnsafePath(current));
1260 }
1261 Ok(_) => {}
1262 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1263 Err(error) => return Err(io_error(¤t, error)),
1264 }
1265 }
1266 Ok(())
1267}
1268
1269pub fn summarize_paths(paths: &[PathBuf]) -> String {
1274 const SHOWN: usize = 4;
1275 let mut places = BTreeMap::<String, usize>::new();
1276 for path in paths {
1277 let mut components = path.components();
1278 let first = components
1279 .next()
1280 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1281 .unwrap_or_default();
1282 let place = if components.next().is_some() {
1283 format!("{first}/")
1284 } else {
1285 first
1286 };
1287 *places.entry(place).or_default() += 1;
1288 }
1289 let mut places = places.into_iter().collect::<Vec<_>>();
1290 places.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1291 let mut shown = places
1292 .iter()
1293 .take(SHOWN)
1294 .map(|(place, count)| {
1295 if place.ends_with('/') {
1296 format!("{place} {count}")
1297 } else {
1298 place.clone()
1299 }
1300 })
1301 .collect::<Vec<_>>();
1302 if places.len() > SHOWN {
1303 let rest = places[SHOWN..]
1304 .iter()
1305 .map(|(_, count)| count)
1306 .sum::<usize>();
1307 shown.push(format!("{rest} elsewhere"));
1308 }
1309 shown.join(", ")
1310}
1311
1312struct BuiltDirectories<'a> {
1319 root: &'a Path,
1320 judged: BTreeMap<PathBuf, bool>,
1321 directories: BTreeSet<PathBuf>,
1322}
1323
1324impl<'a> BuiltDirectories<'a> {
1325 fn new(root: &'a Path) -> Self {
1326 Self {
1327 root,
1328 judged: BTreeMap::new(),
1329 directories: BTreeSet::new(),
1330 }
1331 }
1332
1333 fn add(&mut self, relative: &Path) {
1334 let mut directory = PathBuf::new();
1335 let Some(parent) = relative.parent() else {
1336 return;
1337 };
1338 for component in parent.components() {
1339 directory.push(component);
1340 let root = self.root;
1341 let output = *self
1342 .judged
1343 .entry(directory.clone())
1344 .or_insert_with(|| is_build_output(root, &directory));
1345 if output {
1346 self.directories.insert(directory);
1347 return;
1348 }
1349 }
1350 }
1351
1352 fn holds(&self, relative: &Path) -> bool {
1353 relative
1354 .ancestors()
1355 .skip(1)
1356 .any(|directory| self.directories.contains(directory))
1357 }
1358}
1359
1360fn compiled_javascript(path: &Path) -> bool {
1362 path.extension()
1363 .and_then(|extension| extension.to_str())
1364 .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "map"))
1365}
1366
1367fn is_build_output(root: &Path, directory: &Path) -> bool {
1368 if fs::symlink_metadata(root.join(directory)).is_err() {
1369 return true;
1370 }
1371 std::process::Command::new("git")
1372 .arg("-C")
1373 .arg(root)
1374 .args(["check-ignore", "-q", "--"])
1375 .arg(directory)
1376 .env("GIT_TERMINAL_PROMPT", "0")
1377 .stdin(std::process::Stdio::null())
1378 .stdout(std::process::Stdio::null())
1379 .stderr(std::process::Stdio::null())
1380 .status()
1381 .is_ok_and(|status| status.code() == Some(0))
1382}
1383
1384const NAMED_FILE_LIMIT: u64 = 16 << 20;
1386
1387fn copy_and_project(root: &Path, workspace: &Path) -> Vec<(String, String)> {
1391 let mut places = Vec::new();
1392 let mut add = |copy: &Path, project: &Path| {
1393 let (copy, project) = (copy.to_string_lossy(), project.to_string_lossy());
1394 for (from, to) in [("\\", "\\"), ("\\", "\\\\"), ("\\", "/")] {
1395 let pair = (copy.replace(from, to), project.replace(from, to));
1396 if !pair.0.is_empty() && pair.0 != pair.1 && !places.contains(&pair) {
1397 places.push(pair);
1398 }
1399 }
1400 };
1401 add(workspace, root);
1402 if let (Ok(copy), Ok(project)) = (
1403 canonicalize_simplified(workspace),
1404 canonicalize_simplified(root),
1405 ) {
1406 add(©, &project);
1407 }
1408 places.sort_by_key(|(copy, _)| std::cmp::Reverse(copy.len()));
1410 places
1411}
1412
1413fn naming_the_project(
1423 file: &Path,
1424 places: &[(String, String)],
1425) -> Result<Option<String>, WorkspaceError> {
1426 let metadata = fs::metadata(file).map_err(|error| io_error(file, error))?;
1427 if !metadata.is_file() || metadata.len() > NAMED_FILE_LIMIT {
1428 return Ok(None);
1429 }
1430 let bytes = fs::read(file).map_err(|error| io_error(file, error))?;
1431 if bytes.contains(&0) {
1432 return Ok(None);
1433 }
1434 let Ok(text) = String::from_utf8(bytes) else {
1435 return Ok(None);
1436 };
1437 Ok(project_named(&text, places))
1438}
1439
1440pub fn console_names(root: &Path, workspace: &Path) -> crate::process_supervision::ConsoleNames {
1446 let up = workspace
1447 .strip_prefix(root)
1448 .ok()
1449 .map(|inside| inside.components().count())
1450 .filter(|levels| *levels > 0)
1451 .map(|levels| "../".repeat(levels));
1452 crate::process_supervision::ConsoleNames {
1453 places: copy_and_project(root, workspace),
1454 up_to_the_project: up,
1455 }
1456}
1457
1458pub(crate) fn project_named(text: &str, places: &[(String, String)]) -> Option<String> {
1461 if !places.iter().any(|(copy, _)| text.contains(copy.as_str())) {
1462 return None;
1463 }
1464 let mut out = String::with_capacity(text.len());
1465 let mut rest = text;
1466 while let Some((at, copy, project)) = places
1469 .iter()
1470 .filter_map(|(copy, project)| Some((rest.find(copy.as_str())?, copy, project)))
1471 .min_by_key(|(at, _, _)| *at)
1472 {
1473 out.push_str(&rest[..at]);
1474 let after = &rest[at + copy.len()..];
1475 let continues = after
1478 .chars()
1479 .next()
1480 .is_some_and(|next| next.is_alphanumeric() || matches!(next, '-' | '_'));
1481 if continues {
1482 let first = rest[at..].chars().next().expect("a match is not empty");
1483 out.push(first);
1484 rest = &rest[at + first.len_utf8()..];
1485 } else {
1486 out.push_str(project);
1487 rest = after;
1488 }
1489 }
1490 out.push_str(rest);
1491 (out != text).then_some(out)
1492}
1493
1494pub fn sync_command_outputs(
1501 root: &Path,
1502 workspace: &Path,
1503 baseline: &WorkspaceOutputBaseline,
1504 protected: &BTreeSet<PathBuf>,
1505) -> Result<CommandOutputSync, WorkspaceError> {
1506 sync_outputs_of(
1507 CommandOutputs::JavaScript,
1508 root,
1509 workspace,
1510 baseline,
1511 protected,
1512 )
1513}
1514
1515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1524pub enum CommandOutputs {
1525 JavaScript,
1526 Go,
1527 Jvm,
1528 Rust,
1529}
1530
1531fn links_the_go_runtime(path: &Path) -> Result<bool, WorkspaceError> {
1534 const MARKER: &[u8] = b"_supercov_runtime";
1535 let bytes = fs::read(path).map_err(|error| io_error(path, error))?;
1536 Ok(bytes.contains(&0) && bytes.windows(MARKER.len()).any(|window| window == MARKER))
1537}
1538
1539const JVM_REPORTS: &[&str] = &[
1541 "surefire-reports",
1542 "failsafe-reports",
1543 "test-results",
1544 "reports",
1545 "site",
1546];
1547
1548fn jvm_build_state(workspace: &Path, relative: &Path) -> bool {
1554 let parts = relative
1555 .components()
1556 .map(|part| part.as_os_str().to_string_lossy().into_owned())
1557 .collect::<Vec<_>>();
1558 if parts.first().is_some_and(|first| first == ".gradle") {
1559 return true;
1560 }
1561 if parts.last().is_some_and(|name| {
1563 matches!(
1564 name.as_str(),
1565 "pom.xml"
1566 | "build.gradle"
1567 | "build.gradle.kts"
1568 | "settings.gradle"
1569 | "settings.gradle.kts"
1570 )
1571 }) {
1572 return true;
1573 }
1574 if relative
1575 .extension()
1576 .is_some_and(|extension| extension == "class" || extension == "jar")
1577 {
1578 return true;
1579 }
1580 let mut module = workspace.to_path_buf();
1581 for (index, part) in parts.iter().enumerate() {
1582 let output = match part.as_str() {
1583 "target" => module.join("pom.xml").is_file(),
1584 "build" => [
1585 "build.gradle",
1586 "build.gradle.kts",
1587 "settings.gradle",
1588 "settings.gradle.kts",
1589 ]
1590 .iter()
1591 .any(|file| module.join(file).is_file()),
1592 _ => false,
1593 };
1594 if output {
1595 return !parts[index + 1..]
1596 .iter()
1597 .any(|inside| JVM_REPORTS.contains(&inside.as_str()));
1598 }
1599 module.push(part);
1600 }
1601 false
1602}
1603
1604pub fn sync_outputs_of(
1607 kind: CommandOutputs,
1608 root: &Path,
1609 workspace: &Path,
1610 baseline: &WorkspaceOutputBaseline,
1611 protected: &BTreeSet<PathBuf>,
1612) -> Result<CommandOutputSync, WorkspaceError> {
1613 let mut current = BTreeMap::new();
1614 walk_output_files(workspace, workspace, &mut current)?;
1615 let mut sync = CommandOutputSync::default();
1616 let places = copy_and_project(root, workspace);
1628 let mut changed = Vec::new();
1629 let mut built = BTreeSet::new();
1630 let mut built_directories = BuiltDirectories::new(root);
1631 for (relative, state) in ¤t {
1632 if baseline.entries.get(relative) == Some(state) {
1633 continue;
1634 }
1635 if !protected.contains(relative) {
1636 match kind {
1637 CommandOutputs::JavaScript => {
1638 let compiled = compiled_javascript(relative);
1643 if built_from_instrumented_source(&workspace.join(relative), compiled)? {
1644 built.insert(relative.clone());
1645 if compiled {
1646 built_directories.add(relative);
1647 }
1648 }
1649 }
1650 CommandOutputs::Go => {
1651 if links_the_go_runtime(&workspace.join(relative))? {
1652 built.insert(relative.clone());
1653 }
1654 }
1655 CommandOutputs::Jvm => {
1656 if jvm_build_state(workspace, relative) {
1657 built.insert(relative.clone());
1658 }
1659 }
1660 CommandOutputs::Rust => {
1667 if relative
1668 .extension()
1669 .is_some_and(|extension| extension == "rs")
1670 || relative
1671 .file_name()
1672 .is_some_and(|name| name == "Cargo.lock")
1673 {
1674 built.insert(relative.clone());
1675 }
1676 }
1677 }
1678 }
1679 changed.push(relative);
1680 }
1681 for relative in changed {
1682 if protected.contains(relative)
1683 || built.contains(relative)
1684 || built_directories.holds(relative)
1685 {
1686 sync.skipped_instrumented.push(relative.clone());
1687 continue;
1688 }
1689 let from = workspace.join(relative);
1690 if unaccompanied_install_manifest(root, workspace, relative) {
1691 continue;
1692 }
1693 validate_writeback_destination(root, relative)?;
1694 let to = root.join(relative);
1695 if let Some(parent) = to.parent() {
1696 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1697 }
1698 match naming_the_project(&from, &places)? {
1699 Some(text) => {
1700 fs::write(&to, text).map_err(|error| io_error(&to, error))?;
1701 if let Ok(metadata) = fs::metadata(&from) {
1702 let _ = fs::set_permissions(&to, metadata.permissions());
1703 }
1704 }
1705 None => {
1706 fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1707 }
1708 }
1709 sync.synced += 1;
1710 sync.synced_paths.push(relative.clone());
1711 }
1712 for relative in baseline.entries.keys() {
1713 if !current.contains_key(relative) && fs::symlink_metadata(root.join(relative)).is_ok() {
1716 sync.deleted_in_workspace.push(relative.clone());
1717 }
1718 }
1719 Ok(sync)
1720}
1721
1722pub fn describe_command_outputs(outputs: &CommandOutputSync) -> Vec<String> {
1724 let mut lines = Vec::new();
1725 if outputs.synced > 0 {
1726 lines.push(format!(
1727 "[supercov] synced {} file(s) the command created or changed back to the project: {}",
1728 outputs.synced,
1729 summarize_paths(&outputs.synced_paths)
1730 ));
1731 }
1732 if !outputs.skipped_instrumented.is_empty() {
1733 lines.push(format!(
1734 "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
1735 outputs.skipped_instrumented.len(),
1736 summarize_paths(&outputs.skipped_instrumented)
1737 ));
1738 }
1739 if !outputs.deleted_in_workspace.is_empty() {
1740 lines.push(format!(
1741 "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project: {}",
1742 outputs.deleted_in_workspace.len(),
1743 summarize_paths(&outputs.deleted_in_workspace)
1744 ));
1745 }
1746 lines
1747}
1748
1749pub fn mirror_git_repository(root: &Path, workspace: &Path) -> Result<(), String> {
1760 if fs::symlink_metadata(root.join(".git")).is_err() {
1761 return Ok(());
1762 }
1763 let target = workspace.join(".git");
1764 let git = |directory: &Path, arguments: &[&str]| -> Result<String, String> {
1765 let output = std::process::Command::new("git")
1766 .arg("-C")
1767 .arg(directory)
1768 .args(arguments)
1769 .env("GIT_TERMINAL_PROMPT", "0")
1770 .stdin(std::process::Stdio::null())
1771 .output()
1772 .map_err(|error| format!("git {}: {error}", arguments.join(" ")))?;
1773 if output.status.success() {
1774 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1775 } else {
1776 Err(format!(
1777 "git {}: {}",
1778 arguments.join(" "),
1779 String::from_utf8_lossy(&output.stderr).trim()
1780 ))
1781 }
1782 };
1783 let result = (|| {
1784 if fs::symlink_metadata(&target).is_ok() {
1785 fs::remove_dir_all(&target).map_err(|error| error.to_string())?;
1786 }
1787 let source = root.to_string_lossy();
1788 let destination = target.to_string_lossy();
1789 git(
1790 workspace,
1791 &[
1792 "clone",
1793 "--quiet",
1794 "--shared",
1795 "--bare",
1796 &source,
1797 &destination,
1798 ],
1799 )?;
1800 git(workspace, &["config", "core.bare", "false"])?;
1801 match git(root, &["symbolic-ref", "-q", "HEAD"]) {
1802 Ok(branch) => git(workspace, &["symbolic-ref", "HEAD", &branch])?,
1803 Err(_) => {
1804 let commit = git(root, &["rev-parse", "HEAD"])?;
1805 git(workspace, &["update-ref", "--no-deref", "HEAD", &commit])?
1806 }
1807 };
1808 git(workspace, &["remote", "remove", "origin"])?;
1809 let remotes = git(root, &["remote"])?;
1810 for name in remotes.lines().filter(|name| !name.is_empty()) {
1811 let url = git(root, &["config", "--get", &format!("remote.{name}.url")])?;
1814 git(workspace, &["remote", "add", name, &url])?;
1815 }
1816 git(
1817 workspace,
1818 &[
1819 "fetch",
1820 "--quiet",
1821 "--no-tags",
1822 &source,
1823 "+refs/remotes/*:refs/remotes/*",
1824 ],
1825 )?;
1826 git(workspace, &["read-tree", "HEAD"])?;
1827 let exclude = target.join("info/exclude");
1829 fs::create_dir_all(target.join("info")).map_err(|error| error.to_string())?;
1830 let mut patterns = fs::read_to_string(&exclude).unwrap_or_default();
1831 patterns.push_str("\n.supercov/\n");
1832 fs::write(&exclude, patterns).map_err(|error| error.to_string())?;
1833 Ok(())
1834 })();
1835 if result.is_err() {
1836 let _ = fs::remove_dir_all(&target);
1837 }
1838 result
1839}
1840
1841pub fn hide_rewritten_files(workspace: &Path, files: &[String]) {
1845 if files.is_empty() || !workspace.join(".git").is_dir() {
1846 return;
1847 }
1848 let run = |arguments: &[&str], input: &str| {
1849 let mut child = std::process::Command::new("git")
1850 .arg("-C")
1851 .arg(workspace)
1852 .args(arguments)
1853 .stdin(std::process::Stdio::piped())
1854 .stdout(std::process::Stdio::piped())
1855 .stderr(std::process::Stdio::null())
1856 .spawn()
1857 .ok()?;
1858 child.stdin.take()?.write_all(input.as_bytes()).ok()?;
1859 let output = child.wait_with_output().ok()?;
1860 output
1861 .status
1862 .success()
1863 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1864 };
1865 let Some(listed) = run(&["ls-files", "-z"], "") else {
1867 return;
1868 };
1869 let tracked = listed.split('\0').collect::<BTreeSet<_>>();
1870 let hidden = files
1871 .iter()
1872 .filter(|file| tracked.contains(file.as_str()))
1873 .map(String::as_str)
1874 .collect::<Vec<_>>();
1875 if !hidden.is_empty() {
1876 let _ = run(
1877 &["update-index", "--skip-worktree", "--stdin"],
1878 &hidden.join("\n"),
1879 );
1880 }
1881}
1882
1883fn unaccompanied_install_manifest(root: &Path, workspace: &Path, relative: &Path) -> bool {
1890 let manifest = relative
1891 .file_name()
1892 .and_then(|name| name.to_str())
1893 .is_some_and(|name| {
1894 matches!(
1895 name,
1896 "package.json"
1897 | "package-lock.json"
1898 | "npm-shrinkwrap.json"
1899 | "yarn.lock"
1900 | "pnpm-lock.yaml"
1901 | "bun.lock"
1902 | "bun.lockb"
1903 )
1904 });
1905 let Some(directory) = relative.parent() else {
1906 return false;
1907 };
1908 manifest
1909 && workspace.join(directory).join("node_modules").is_dir()
1910 && !root.join(directory).join("node_modules").exists()
1911}
1912
1913fn built_from_instrumented_source(path: &Path, compiled: bool) -> Result<bool, WorkspaceError> {
1925 const RUNTIME: &[u8] = b".supercov/node_modules/";
1926 const PROBE: &[u8] = b"__supercov";
1927 const GLOBAL: &[u8] = b"__SUPERCOV_DIRECT_RUNTIME__";
1928 let contents = match fs::read(path) {
1929 Ok(contents) => contents,
1930 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1931 Err(error) => return Err(io_error(path, error)),
1932 };
1933 let holds = |marker: &[u8]| {
1934 contents
1935 .windows(marker.len())
1936 .any(|window| window == marker)
1937 };
1938 Ok(holds(RUNTIME) || (compiled && (holds(PROBE) || holds(GLOBAL))))
1939}
1940
1941pub fn prune_cached_workspace_sources(
1942 root: &Path,
1943 lock: &ProjectLock,
1944) -> Result<Vec<String>, WorkspaceError> {
1945 require_lock(root, lock)?;
1946 let workspace = cached_workspace_path(root, lock)?;
1947 if fs::symlink_metadata(&workspace).is_err() {
1948 return Ok(Vec::new());
1949 }
1950 let keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
1951 let mut removed = Vec::new();
1952 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
1953 let entry = entry.map_err(|error| io_error(&workspace, error))?;
1954 let name = entry
1955 .file_name()
1956 .into_string()
1957 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
1958 if keep.contains(&name) {
1959 continue;
1960 }
1961 remove_stored_tree_deferred(root, &entry.path())?;
1962 removed.push(name);
1963 }
1964 removed.sort();
1965 Ok(removed)
1966}
1967
1968#[cfg(test)]
1969mod tests {
1970 #[test]
1971 fn file_urls_match_what_node_derives_for_every_path_shape() {
1972 use super::file_url_from_slash_path;
1973 assert_eq!(
1974 file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
1975 "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
1976 );
1977 assert_eq!(
1978 file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
1979 "file:///C:/Users/a%20b/%231/register.mjs"
1980 );
1981 assert_eq!(
1982 file_url_from_slash_path("//server/share/dir/register.mjs"),
1983 "file://server/share/dir/register.mjs"
1984 );
1985 assert_eq!(
1986 file_url_from_slash_path("/w/p/register.mjs"),
1987 "file:///w/p/register.mjs"
1988 );
1989 assert_eq!(
1990 file_url_from_slash_path("/w/p x?q/ü.mjs"),
1991 "file:///w/p%20x%3Fq/%C3%BC.mjs"
1992 );
1993 }
1994
1995 #[test]
1996 fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
1997 use super::strip_verbatim;
1998 assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
1999 assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
2000 assert_eq!(
2001 strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
2002 Some(r"\\server\share\dir")
2003 );
2004 assert_eq!(strip_verbatim(r"C:\a\b"), None);
2005 assert_eq!(strip_verbatim("/w/project"), None);
2006 assert_eq!(strip_verbatim(r"\\server\share"), None);
2007 assert_eq!(strip_verbatim(r"\\?\"), None);
2008 }
2009
2010 use super::*;
2011
2012 fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
2013 let base =
2014 std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
2015 if base.exists() {
2016 fs::remove_dir_all(&base).unwrap();
2017 }
2018 let root = base.join("project");
2019 let workspace = base.join("workspace");
2020 fs::create_dir_all(root.join("src")).unwrap();
2021 fs::create_dir_all(workspace.join("src")).unwrap();
2022 fs::create_dir_all(workspace.join(".supercov")).unwrap();
2023 fs::write(root.join("src/app.ts"), "original\n").unwrap();
2024 fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
2025 fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
2026 (root, workspace)
2027 }
2028
2029 #[test]
2030 fn the_workspace_has_a_repository_of_its_own_that_reads_like_the_project() {
2031 let (root, workspace) = writeback_fixture("git-mirror");
2032 let git = |directory: &Path, arguments: &[&str]| {
2033 let output = std::process::Command::new("git")
2034 .arg("-C")
2035 .arg(directory)
2036 .args(arguments)
2037 .output()
2038 .unwrap();
2039 assert!(output.status.success(), "git {arguments:?}: {output:?}");
2040 String::from_utf8(output.stdout).unwrap().trim().to_owned()
2041 };
2042 git(&root, &["init", "--quiet", "--initial-branch=release/v2"]);
2043 git(&root, &["config", "user.email", "test@example.com"]);
2044 git(&root, &["config", "user.name", "Test"]);
2045 git(
2046 &root,
2047 &[
2048 "remote",
2049 "add",
2050 "origin",
2051 "https://github.com/example/app.git",
2052 ],
2053 );
2054 git(&root, &["add", "src/app.ts"]);
2055 git(&root, &["commit", "--quiet", "-m", "first"]);
2056 let head = git(&root, &["rev-parse", "HEAD"]);
2057
2058 mirror_git_repository(&root, &workspace).unwrap();
2059 assert_eq!(git(&workspace, &["rev-parse", "HEAD"]), head);
2060 assert_eq!(
2061 git(&workspace, &["symbolic-ref", "HEAD"]),
2062 "refs/heads/release/v2"
2063 );
2064 assert_eq!(
2065 git(&workspace, &["config", "--get", "remote.origin.url"]),
2066 "https://github.com/example/app.git"
2067 );
2068 hide_rewritten_files(&workspace, &["src/app.ts".to_owned()]);
2070 assert_eq!(git(&workspace, &["status", "--porcelain"]), "");
2071
2072 fs::write(workspace.join("new.txt"), "new\n").unwrap();
2074 git(&workspace, &["add", "new.txt"]);
2075 git(
2076 &workspace,
2077 &[
2078 "-c",
2079 "user.email=t@example.com",
2080 "-c",
2081 "user.name=T",
2082 "commit",
2083 "--quiet",
2084 "-m",
2085 "second",
2086 ],
2087 );
2088 assert_eq!(git(&root, &["rev-parse", "HEAD"]), head);
2089 assert_eq!(git(&root, &["status", "--porcelain"]), "");
2090 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2091 }
2092
2093 #[test]
2094 fn an_install_the_command_made_does_not_flow_back_without_its_dependencies() {
2095 let (root, workspace) = writeback_fixture("tool-install");
2096 fs::write(workspace.join("package.json"), "{}").unwrap();
2097 fs::write(root.join("package.json"), "{}").unwrap();
2098 fs::create_dir_all(workspace.join("node_modules")).unwrap();
2099 fs::create_dir_all(root.join("node_modules")).unwrap();
2100 let baseline = workspace_output_baseline(&workspace).unwrap();
2101 fs::create_dir_all(workspace.join(".tap/plugins/node_modules/@tapjs/clock")).unwrap();
2103 fs::write(workspace.join(".tap/plugins/package.json"), "{}").unwrap();
2104 fs::write(workspace.join(".tap/plugins/package-lock.json"), "{}").unwrap();
2105 fs::create_dir_all(workspace.join(".tap/test-results")).unwrap();
2106 fs::write(workspace.join(".tap/test-results/basic.tap"), "ok").unwrap();
2107 fs::write(workspace.join("package.json"), "{\"version\":\"2\"}").unwrap();
2109 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2110 assert!(!root.join(".tap/plugins/package.json").exists());
2111 assert!(!root.join(".tap/plugins/package-lock.json").exists());
2112 assert!(root.join(".tap/test-results/basic.tap").is_file());
2113 assert_eq!(
2114 fs::read_to_string(root.join("package.json")).unwrap(),
2115 "{\"version\":\"2\"}"
2116 );
2117 assert_eq!(sync.synced, 2);
2118 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2119 }
2120
2121 #[test]
2122 fn a_file_that_flows_back_names_the_project_and_not_the_copy() {
2123 let (root, workspace) = writeback_fixture("names");
2127 let baseline = workspace_output_baseline(&workspace).unwrap();
2128 let (copy, project) = (workspace.display().to_string(), root.display().to_string());
2129 fs::create_dir_all(workspace.join("coverage")).unwrap();
2130 fs::write(
2131 workspace.join("coverage/coverage-final.json"),
2132 format!("{{\"{copy}/src/fee.js\": {{\"path\": \"{copy}/src/fee.js\"}}}}"),
2133 )
2134 .unwrap();
2135 fs::write(
2136 workspace.join("coverage/cobertura.xml"),
2137 format!("<sources><source>{copy}</source></sources>"),
2138 )
2139 .unwrap();
2140 fs::write(
2142 workspace.join("coverage/neighbour.txt"),
2143 format!("{copy}-old/file and {copy}"),
2144 )
2145 .unwrap();
2146 let mut binary = copy.clone().into_bytes();
2148 binary.push(0);
2149 fs::write(workspace.join("coverage/cache.bin"), &binary).unwrap();
2150 fs::write(workspace.join("coverage/plain.txt"), "nothing to name").unwrap();
2151 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2152 assert_eq!(sync.synced, 5);
2153 assert_eq!(
2154 fs::read_to_string(root.join("coverage/coverage-final.json")).unwrap(),
2155 format!("{{\"{project}/src/fee.js\": {{\"path\": \"{project}/src/fee.js\"}}}}")
2156 );
2157 assert_eq!(
2158 fs::read_to_string(root.join("coverage/cobertura.xml")).unwrap(),
2159 format!("<sources><source>{project}</source></sources>")
2160 );
2161 assert_eq!(
2162 fs::read_to_string(root.join("coverage/neighbour.txt")).unwrap(),
2163 format!("{copy}-old/file and {project}")
2164 );
2165 assert_eq!(fs::read(root.join("coverage/cache.bin")).unwrap(), binary);
2166 assert_eq!(
2167 fs::read_to_string(root.join("coverage/plain.txt")).unwrap(),
2168 "nothing to name"
2169 );
2170 assert!(
2172 fs::read_to_string(workspace.join("coverage/cobertura.xml"))
2173 .unwrap()
2174 .contains(©)
2175 );
2176 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2177 }
2178
2179 #[test]
2180 fn what_a_go_jvm_or_rust_command_writes_flows_back_and_what_it_builds_stays() {
2181 let write = |workspace: &Path, file: &str, contents: &[u8]| {
2186 let path = workspace.join(file);
2187 fs::create_dir_all(path.parent().unwrap()).unwrap();
2188 fs::write(path, contents).unwrap();
2189 };
2190 let flows = |kind: CommandOutputs, name: &str, files: &[(&str, &[u8])]| {
2191 let (root, workspace) = writeback_fixture(name);
2192 write(&workspace, "pom.xml", b"<project/>");
2194 write(&workspace, "app/build.gradle.kts", b"plugins {}");
2195 let baseline = workspace_output_baseline(&workspace).unwrap();
2196 for (file, contents) in files {
2197 write(&workspace, file, contents);
2198 }
2199 let sync =
2200 sync_outputs_of(kind, &root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2201 let synced = files
2202 .iter()
2203 .filter(|(file, _)| root.join(file).is_file())
2204 .map(|(file, _)| (*file).to_owned())
2205 .collect::<Vec<_>>();
2206 assert_eq!(sync.synced, synced.len(), "{sync:?}");
2207 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2208 synced
2209 };
2210 let mut binary = b"\x7fELF\0\0example.com/shop/_supercov_runtime\0".to_vec();
2211 binary.extend([0u8; 16]);
2212 assert_eq!(
2213 flows(
2214 CommandOutputs::Go,
2215 "go",
2216 &[
2217 ("cover.out", b"mode: set\n"),
2218 ("testdata/golden.png", b"\x89PNG\0\0"),
2219 ("shop.test", &binary),
2220 ]
2221 ),
2222 ["cover.out", "testdata/golden.png"]
2223 );
2224 assert_eq!(
2225 flows(
2226 CommandOutputs::Jvm,
2227 "jvm",
2228 &[
2229 (
2230 "target/surefire-reports/TEST-shop.CryptoTest.xml",
2231 b"<testsuite/>"
2232 ),
2233 ("target/site/jacoco/index.html", b"<html/>"),
2234 ("app/build/test-results/test/TEST-a.xml", b"<testsuite/>"),
2235 ("app/build/reports/tests/test/index.html", b"<html/>"),
2236 ("src/test/resources/approved/output.txt", b"approved"),
2237 ("target/classes/shop/Crypto.class", b"\xca\xfe\xba\xbe"),
2239 (
2240 "target/maven-status/compile/createdFiles.lst",
2241 b"shop/Crypto.class"
2242 ),
2243 ("app/build/classes/kotlin/main/A.class", b"\xca\xfe\xba\xbe"),
2244 ("app/build/tmp/compileKotlin/cache.txt", b"state"),
2245 (".gradle/8.10/fileHashes/fileHashes.lock", b"lock"),
2246 ("pom.xml", b"<project><!-- changed --></project>"),
2247 ("src/build/notes.txt", b"kept"),
2249 ]
2250 ),
2251 [
2252 "target/surefire-reports/TEST-shop.CryptoTest.xml",
2253 "target/site/jacoco/index.html",
2254 "app/build/test-results/test/TEST-a.xml",
2255 "app/build/reports/tests/test/index.html",
2256 "src/test/resources/approved/output.txt",
2257 "src/build/notes.txt",
2258 ]
2259 );
2260 assert_eq!(
2261 flows(
2262 CommandOutputs::Rust,
2263 "rust",
2264 &[
2265 ("src/snapshots/sealed.snap", b"SNAP"),
2266 ("report.json", b"{}"),
2267 ("Cargo.lock", b"# lock"),
2268 ("src/generated.rs", b"pub fn made() {}"),
2269 ]
2270 ),
2271 ["src/snapshots/sealed.snap", "report.json"]
2272 );
2273 }
2274
2275 #[test]
2276 fn dependency_trees_never_flow_back() {
2277 let (root, workspace) = writeback_fixture("dependencies");
2280 let baseline = workspace_output_baseline(&workspace).unwrap();
2281 fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
2282 fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
2283 fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
2284 fs::write(
2285 workspace.join("packages/app/node_modules/dep/index.js"),
2286 "dep",
2287 )
2288 .unwrap();
2289 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2290 assert_eq!(sync.synced, 0);
2291 assert!(sync.deleted_in_workspace.is_empty());
2292 assert!(!root.join("node_modules").exists());
2293 assert!(!root.join("packages").exists());
2294 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2295 }
2296
2297 #[test]
2298 fn output_built_from_instrumented_sources_never_flows_back() {
2299 let (root, workspace) = writeback_fixture("built-output");
2305 let baseline = workspace_output_baseline(&workspace).unwrap();
2306 fs::create_dir_all(workspace.join("dist")).unwrap();
2307 fs::write(
2308 workspace.join("dist/app.js"),
2309 "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
2310 )
2311 .unwrap();
2312 fs::write(
2313 workspace.join("dist/app.d.ts"),
2314 "export declare const a: number;\n",
2315 )
2316 .unwrap();
2317
2318 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2319
2320 assert_eq!(
2323 sync.skipped_instrumented,
2324 vec![PathBuf::from("dist/app.d.ts"), PathBuf::from("dist/app.js")],
2325 "the instrumented build is reported, not copied"
2326 );
2327 assert_eq!(sync.synced, 0);
2328 assert!(!root.join("dist").exists());
2329 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2330 }
2331
2332 #[test]
2333 fn a_built_file_in_a_directory_the_project_keeps_withholds_only_itself() {
2334 let (root, workspace) = writeback_fixture("built-beside-outputs");
2337 let baseline = workspace_output_baseline(&workspace).unwrap();
2338 fs::write(
2339 workspace.join("src/bundle.js"),
2340 "import \"../.supercov/node_modules/runtime.mjs\";\n",
2341 )
2342 .unwrap();
2343 fs::write(workspace.join("src/app.snap"), "updated\n").unwrap();
2344
2345 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2346
2347 assert_eq!(
2348 sync.skipped_instrumented,
2349 vec![PathBuf::from("src/bundle.js")]
2350 );
2351 assert_eq!(sync.synced, 1);
2352 assert_eq!(
2353 fs::read_to_string(root.join("src/app.snap")).unwrap(),
2354 "updated\n"
2355 );
2356 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2357 }
2358
2359 #[test]
2360 fn paths_are_summarized_by_where_they_are() {
2361 let paths = [
2362 ".next/BUILD_ID",
2363 ".next/server/app.js",
2364 ".next/server/chunks/a.js",
2365 "tests/e2e/runs/1/report.json",
2366 "coverage/report.json",
2367 "playwright-report/index.html",
2368 "notes.txt",
2369 "out/a.txt",
2370 ]
2371 .map(PathBuf::from);
2372 assert_eq!(
2373 summarize_paths(&paths),
2374 ".next/ 3, coverage/ 1, notes.txt, out/ 1, 2 elsewhere"
2375 );
2376 assert_eq!(summarize_paths(&paths[..1]), ".next/ 1");
2377 }
2378
2379 #[test]
2380 fn a_report_that_quotes_the_runtime_does_not_make_its_directory_a_build() {
2381 let (root, workspace) = writeback_fixture("report-quotes-runtime");
2384 let baseline = workspace_output_baseline(&workspace).unwrap();
2385 fs::create_dir_all(workspace.join("test-results/adds-an-item")).unwrap();
2386 fs::write(
2387 workspace.join("test-results/adds-an-item/error-context.md"),
2388 "at launch (/w/.supercov/node_modules/playwright.mjs:907:44)\n",
2389 )
2390 .unwrap();
2391 fs::write(
2392 workspace.join("test-results/adds-an-item/trace.zip"),
2393 "trace",
2394 )
2395 .unwrap();
2396
2397 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2398
2399 assert_eq!(
2400 sync.skipped_instrumented,
2401 vec![PathBuf::from("test-results/adds-an-item/error-context.md")]
2402 );
2403 assert_eq!(sync.synced, 1);
2404 assert!(root.join("test-results/adds-an-item/trace.zip").exists());
2405 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2406 }
2407
2408 #[test]
2409 fn a_build_directory_git_ignores_stays_behind_even_when_the_project_has_one() {
2410 let (root, workspace) = writeback_fixture("built-ignored");
2411 let git = |arguments: &[&str]| {
2412 std::process::Command::new("git")
2413 .arg("-C")
2414 .arg(&root)
2415 .args(arguments)
2416 .output()
2417 .unwrap()
2418 };
2419 if !git(&["init", "-q"]).status.success() {
2420 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2421 return;
2422 }
2423 fs::write(root.join(".gitignore"), ".next\n").unwrap();
2424 fs::create_dir_all(root.join(".next")).unwrap();
2425 fs::write(root.join(".next/BUILD_ID"), "mine\n").unwrap();
2426 let baseline = workspace_output_baseline(&workspace).unwrap();
2427 fs::create_dir_all(workspace.join(".next/server")).unwrap();
2428 fs::write(
2429 workspace.join(".next/server/chunk.js"),
2430 "__supercovCoverageHitV3(__supercovProbeFileV2, 0);\n",
2433 )
2434 .unwrap();
2435 fs::write(workspace.join(".next/BUILD_ID"), "instrumented\n").unwrap();
2436
2437 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2438
2439 assert_eq!(sync.synced, 0);
2440 assert_eq!(sync.skipped_instrumented.len(), 2);
2441 assert_eq!(
2442 fs::read_to_string(root.join(".next/BUILD_ID")).unwrap(),
2443 "mine\n"
2444 );
2445 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2446 }
2447
2448 #[test]
2449 fn a_minified_build_of_the_instrumented_copy_stays_in_the_workspace() {
2450 let (root, workspace) = writeback_fixture("built-minified");
2451 let initialized = std::process::Command::new("git")
2452 .arg("-C")
2453 .arg(&root)
2454 .args(["init", "-q"])
2455 .output()
2456 .unwrap();
2457 if !initialized.status.success() {
2458 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2459 return;
2460 }
2461 fs::write(root.join(".gitignore"), "dist\n").unwrap();
2462 fs::create_dir_all(root.join("dist")).unwrap();
2463 fs::write(root.join("dist/index.html"), "mine\n").unwrap();
2464 let baseline = workspace_output_baseline(&workspace).unwrap();
2465 fs::create_dir_all(workspace.join("dist/assets")).unwrap();
2466 fs::write(
2467 workspace.join("dist/assets/index-DzZxFCVp.js"),
2468 "const e=globalThis.__SUPERCOV_DIRECT_RUNTIME__.coverageHitV2;e(t,0);\n",
2471 )
2472 .unwrap();
2473 fs::write(workspace.join("dist/index.html"), "instrumented\n").unwrap();
2474
2475 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2476
2477 assert_eq!(sync.synced, 0);
2478 assert_eq!(sync.skipped_instrumented.len(), 2);
2479 assert_eq!(
2480 fs::read_to_string(root.join("dist/index.html")).unwrap(),
2481 "mine\n"
2482 );
2483 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2484 }
2485
2486 #[test]
2487 fn command_outputs_flow_back_to_the_project() {
2488 let (root, workspace) = writeback_fixture("outputs");
2489 let baseline = workspace_output_baseline(&workspace).unwrap();
2490 fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
2491 fs::write(
2492 workspace.join("src/__snapshots__/app.snap"),
2493 "updated snapshot\n",
2494 )
2495 .unwrap();
2496 fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
2497 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2498 assert_eq!(sync.synced, 1);
2499 assert_eq!(
2500 fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
2501 "updated snapshot\n"
2502 );
2503 assert!(!root.join(".supercov/state.json").exists());
2504 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2505 }
2506
2507 #[test]
2508 fn instrumented_sources_never_flow_back() {
2509 let (root, workspace) = writeback_fixture("protected");
2510 let baseline = workspace_output_baseline(&workspace).unwrap();
2511 fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
2514 let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
2515 let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
2516 assert_eq!(sync.synced, 0);
2517 assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
2518 assert_eq!(
2519 fs::read_to_string(root.join("src/app.ts")).unwrap(),
2520 "original\n"
2521 );
2522 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2523 }
2524
2525 #[test]
2526 fn workspace_deletions_are_reported_but_never_propagated() {
2527 let (root, workspace) = writeback_fixture("deletions");
2528 fs::write(workspace.join("stale.txt"), "old\n").unwrap();
2529 fs::write(root.join("stale.txt"), "old\n").unwrap();
2530 fs::write(workspace.join("built.txt"), "old\n").unwrap();
2532 let baseline = workspace_output_baseline(&workspace).unwrap();
2533 fs::remove_file(workspace.join("stale.txt")).unwrap();
2534 fs::remove_file(workspace.join("built.txt")).unwrap();
2535 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2536 assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
2537 assert!(root.join("stale.txt").exists());
2538 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2539 }
2540
2541 #[cfg(unix)]
2542 #[test]
2543 fn writeback_refuses_a_symlinked_destination() {
2544 let (root, workspace) = writeback_fixture("symlink");
2545 let outside = root.parent().unwrap().join("outside");
2546 fs::create_dir_all(&outside).unwrap();
2547 std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
2548 let baseline = workspace_output_baseline(&workspace).unwrap();
2549 fs::create_dir_all(workspace.join("reports")).unwrap();
2550 fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
2551 let error =
2552 sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
2553 assert!(matches!(error, WorkspaceError::UnsafePath(_)));
2554 assert!(!outside.join("result.txt").exists());
2555 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2556 }
2557
2558 struct OrdinaryCopyOperations;
2559
2560 impl WorkspaceOperations for OrdinaryCopyOperations {
2561 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2562 fs::copy(source, destination)
2563 .map(|_| ())
2564 .map_err(|error| io_error(destination, error))
2565 }
2566
2567 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2568 atomic_rename(source, destination).map_err(WorkspaceError::from)
2569 }
2570 }
2571
2572 struct FaultOperations {
2573 copy_count: usize,
2574 fail_copy_at: Option<usize>,
2575 rename_count: usize,
2576 fail_rename_at: Option<usize>,
2577 }
2578
2579 impl WorkspaceOperations for FaultOperations {
2580 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2581 self.copy_count += 1;
2582 if self.fail_copy_at == Some(self.copy_count) {
2583 return Err(io_error(
2584 destination,
2585 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
2586 ));
2587 }
2588 SystemWorkspaceOperations.copy_file(source, destination)
2589 }
2590
2591 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2592 self.rename_count += 1;
2593 if self.fail_rename_at == Some(self.rename_count) {
2594 return Err(io_error(
2595 destination,
2596 io::Error::other("injected publication rename failure"),
2597 ));
2598 }
2599 SystemWorkspaceOperations.rename(source, destination)
2600 }
2601 }
2602
2603 fn transaction_debris(root: &Path) -> Vec<String> {
2604 let workspace = copy_path(root, 1).unwrap();
2605 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
2606 fs::read_dir(workspace.parent().unwrap())
2607 .unwrap()
2608 .filter_map(Result::ok)
2609 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2610 .filter(|name| name.starts_with(&prefix))
2611 .collect()
2612 }
2613
2614 fn project() -> PathBuf {
2615 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
2616 fs::create_dir_all(root.join("src")).unwrap();
2617 fs::write(root.join("src/index.js"), "one").unwrap();
2618 fs::write(root.join("package.json"), "{}").unwrap();
2619 root
2620 }
2621
2622 #[test]
2623 #[cfg(unix)]
2624 fn root_node_modules_symlink_is_followed() {
2625 let root = project();
2630 let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
2631 fs::create_dir_all(store.join("left-pad")).unwrap();
2632 fs::write(store.join("left-pad/package.json"), "{}").unwrap();
2633 std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
2634 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2635 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2636 assert!(
2637 fs::symlink_metadata(workspace.join("node_modules/left-pad"))
2638 .unwrap()
2639 .file_type()
2640 .is_symlink()
2641 );
2642 lock.release().unwrap();
2643 fs::remove_dir_all(root).unwrap();
2644 fs::remove_dir_all(store).unwrap();
2645 }
2646
2647 #[test]
2648 #[cfg(unix)]
2649 fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
2650 let root = project();
2657 let nested = root.join("examples/app/node_modules/@scope");
2658 fs::create_dir_all(&nested).unwrap();
2659 std::os::unix::fs::symlink(
2660 "../../../../packages/react/node_modules/@scope/pkg",
2661 nested.join("pkg"),
2662 )
2663 .unwrap();
2664 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2665 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2666 let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
2667 let metadata = fs::symlink_metadata(&mirrored).unwrap();
2668 assert!(metadata.file_type().is_symlink());
2669 assert_eq!(
2670 fs::read_link(&mirrored).unwrap(),
2671 PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
2672 );
2673 lock.release().unwrap();
2674 fs::remove_dir_all(root).unwrap();
2675 }
2676
2677 #[test]
2678 #[cfg(target_os = "macos")]
2679 fn nested_node_modules_are_cloned_not_linked() {
2680 let root = project();
2686 let nested = root.join("packages/app/node_modules");
2687 fs::create_dir_all(nested.join("dep")).unwrap();
2688 fs::create_dir_all(nested.join(".bin")).unwrap();
2689 fs::write(nested.join("dep/index.js"), "dep").unwrap();
2690 std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
2691 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2692 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2693 let mirrored = workspace.join("packages/app/node_modules");
2694 assert!(
2695 fs::symlink_metadata(mirrored.join("dep"))
2696 .unwrap()
2697 .file_type()
2698 .is_dir()
2699 );
2700 assert_eq!(
2701 fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
2702 "dep"
2703 );
2704 assert_eq!(
2705 fs::read_link(mirrored.join(".bin/dep")).unwrap(),
2706 PathBuf::from("../dep/index.js")
2707 );
2708 fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
2709 assert_eq!(
2710 fs::read_to_string(nested.join("dep/index.js")).unwrap(),
2711 "dep"
2712 );
2713 lock.release().unwrap();
2714 fs::remove_dir_all(root).unwrap();
2715 }
2716
2717 #[test]
2718 #[cfg(unix)]
2719 fn hard_linked_dependency_tree_is_real_and_replaceable() {
2720 use std::os::unix::fs::MetadataExt;
2724 let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
2725 let source = base.join("node_modules");
2726 fs::create_dir_all(source.join("dep")).unwrap();
2727 fs::create_dir_all(source.join(".bin")).unwrap();
2728 fs::write(source.join("dep/index.js"), "dep").unwrap();
2729 std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
2730 let destination = base.join("workspace/node_modules");
2731 fs::create_dir_all(destination.parent().unwrap()).unwrap();
2732 assert!(hard_link_tree(&source, &destination));
2733 assert_eq!(
2734 fs::metadata(destination.join("dep/index.js"))
2735 .unwrap()
2736 .ino(),
2737 fs::metadata(source.join("dep/index.js")).unwrap().ino()
2738 );
2739 assert_eq!(
2740 fs::read_link(destination.join(".bin/dep")).unwrap(),
2741 PathBuf::from("../dep/index.js")
2742 );
2743 fs::remove_dir_all(destination.join("dep")).unwrap();
2744 fs::create_dir_all(destination.join("dep")).unwrap();
2745 fs::write(destination.join("dep/index.js"), "replaced").unwrap();
2746 assert_eq!(
2747 fs::read_to_string(source.join("dep/index.js")).unwrap(),
2748 "dep"
2749 );
2750 fs::remove_dir_all(base).unwrap();
2751 }
2752
2753 #[test]
2754 #[cfg(unix)]
2755 fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
2756 let root = project();
2762 let nested = root.join("examples/app/lib");
2763 fs::create_dir_all(&nested).unwrap();
2764 std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
2765 .unwrap();
2766 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2767 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2768 assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
2769 assert!(workspace.join("src/index.js").exists());
2770 lock.release().unwrap();
2771 fs::remove_dir_all(root).unwrap();
2772 }
2773
2774 #[test]
2775 fn isolated_copy_never_changes_source_and_requires_the_lock() {
2776 let root = project();
2777 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2778 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2779 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
2780 assert_eq!(
2781 fs::read_to_string(root.join("src/index.js")).unwrap(),
2782 "one"
2783 );
2784 lock.release().unwrap();
2785 assert!(matches!(
2786 prepare_isolated_workspace(&root, "other", &lock),
2787 Err(WorkspaceError::MissingLock)
2788 ));
2789 fs::remove_dir_all(root).unwrap();
2790 }
2791
2792 #[test]
2793 fn everything_supercov_writes_lives_under_the_store() {
2794 let root = project();
2795 assert_eq!(
2796 workspace_container(&root),
2797 root.join(".supercov/workspaces")
2798 );
2799 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2800 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2801 assert!(workspace.starts_with(root.join(".supercov")));
2802 assert_eq!(
2803 fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
2804 "*\n"
2805 );
2806 lock.release().unwrap();
2807 fs::remove_dir_all(root).unwrap();
2808 }
2809
2810 #[test]
2811 fn an_unowned_container_gets_a_deterministic_fallback() {
2812 let root = project();
2813 fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
2814 fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
2815 let container = workspace_container(&root);
2816 assert_ne!(container, root.join(".supercov/workspaces"));
2817 let name = container
2818 .file_name()
2819 .unwrap()
2820 .to_string_lossy()
2821 .into_owned();
2822 assert!(name.starts_with("workspaces-"));
2823 fs::remove_dir_all(root).unwrap();
2824 }
2825
2826 #[test]
2827 fn user_supercov_directory_is_copied_and_never_adopted() {
2828 let root = project();
2829 fs::create_dir(root.join("supercov")).unwrap();
2830 fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
2831 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2832 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2833 assert_ne!(workspace_container(&root), root.join("supercov"));
2834 assert_eq!(
2835 fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
2836 "export default 1;\n"
2837 );
2838 assert_eq!(
2839 fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
2840 "export default 1;\n"
2841 );
2842 assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
2843 lock.release().unwrap();
2844 fs::remove_dir_all(root).unwrap();
2845 }
2846
2847 #[cfg(unix)]
2848 #[test]
2849 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
2850 let root = project();
2851 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2852 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2853 fs::create_dir_all(first.join("build")).unwrap();
2854 fs::write(first.join("build/output.js"), "instrumented").unwrap();
2855 fs::write(first.join("stale.txt"), "stale").unwrap();
2856 fs::write(root.join("src/index.js"), "two").unwrap();
2857 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
2858 assert_eq!(first, second);
2859 assert_eq!(
2860 fs::read_to_string(second.join("src/index.js")).unwrap(),
2861 "two"
2862 );
2863 assert_eq!(
2864 fs::read_to_string(second.join("build/output.js")).unwrap(),
2865 "instrumented"
2866 );
2867 assert!(!second.join("stale.txt").exists());
2868 lock.release().unwrap();
2869 fs::remove_dir_all(root).unwrap();
2870 }
2871
2872 #[test]
2873 fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
2874 let root = project();
2875 fs::create_dir_all(root.join("packages/app/dist")).unwrap();
2876 fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
2877 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2878 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2879 fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
2880 fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
2881 let second =
2882 prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
2883 assert_eq!(
2884 fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
2885 "instrumented"
2886 );
2887 assert!(!second.join("packages/app/dist/stale.js").exists());
2888 lock.release().unwrap();
2889 fs::remove_dir_all(root).unwrap();
2890 }
2891
2892 #[test]
2893 fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
2894 let root = project();
2895 fs::create_dir_all(root.join("tests/e2e")).unwrap();
2896 fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
2897 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2898 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2899 let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
2900 fs::create_dir_all(&artifacts).unwrap();
2901 fs::write(artifacts.join("digest"), "instrumented test").unwrap();
2902 fs::write(
2903 workspace.join(".supercov/frontend-cache.json"),
2904 "{\"schemaVersion\":2}",
2905 )
2906 .unwrap();
2907 prune_cached_workspace_sources(&root, &lock).unwrap();
2908 assert!(!workspace.join("tests/e2e/app.spec.js").exists());
2909 assert_eq!(
2910 fs::read_to_string(artifacts.join("digest")).unwrap(),
2911 "instrumented test"
2912 );
2913 lock.release().unwrap();
2914 fs::remove_dir_all(root).unwrap();
2915 }
2916
2917 #[test]
2918 fn ordinary_copy_fallback_preserves_workspace_semantics() {
2919 let root = project();
2920 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2921 let mut operations = OrdinaryCopyOperations;
2922 let workspace =
2923 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
2924 assert_eq!(
2925 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2926 "one"
2927 );
2928 assert_eq!(
2929 fs::read_to_string(root.join("src/index.js")).unwrap(),
2930 "one"
2931 );
2932 assert!(transaction_debris(&root).is_empty());
2933 lock.release().unwrap();
2934 fs::remove_dir_all(root).unwrap();
2935 }
2936
2937 #[test]
2938 fn enospc_during_copy_preserves_the_complete_generation() {
2939 let root = project();
2940 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2941 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2942 fs::write(workspace.join("generation"), "complete").unwrap();
2943 fs::write(root.join("src/index.js"), "new source").unwrap();
2944 let mut operations = FaultOperations {
2945 copy_count: 0,
2946 fail_copy_at: Some(1),
2947 rename_count: 0,
2948 fail_rename_at: None,
2949 };
2950 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2951 .unwrap_err();
2952 assert!(matches!(
2953 error,
2954 WorkspaceError::Io { ref source, .. }
2955 if source.kind() == io::ErrorKind::StorageFull
2956 ));
2957 assert_eq!(
2958 fs::read_to_string(workspace.join("generation")).unwrap(),
2959 "complete"
2960 );
2961 assert_eq!(
2962 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2963 "one"
2964 );
2965 assert!(transaction_debris(&root).is_empty());
2966 lock.release().unwrap();
2967 fs::remove_dir_all(root).unwrap();
2968 }
2969
2970 #[test]
2971 fn failed_publication_rename_restores_the_complete_generation() {
2972 let root = project();
2973 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2974 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2975 fs::write(workspace.join("generation"), "complete").unwrap();
2976 fs::write(root.join("src/index.js"), "new source").unwrap();
2977 let mut operations = FaultOperations {
2978 copy_count: 0,
2979 fail_copy_at: None,
2980 rename_count: 0,
2981 fail_rename_at: Some(2),
2982 };
2983 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2984 .unwrap_err();
2985 assert!(matches!(
2986 error,
2987 WorkspaceError::Io { ref source, .. }
2988 if source.kind() == io::ErrorKind::Other
2989 ));
2990 assert_eq!(
2991 operations.rename_count, 3,
2992 "prior generation was not restored"
2993 );
2994 assert_eq!(
2995 fs::read_to_string(workspace.join("generation")).unwrap(),
2996 "complete"
2997 );
2998 assert_eq!(
2999 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3000 "one"
3001 );
3002 assert!(transaction_debris(&root).is_empty());
3003 lock.release().unwrap();
3004 fs::remove_dir_all(root).unwrap();
3005 }
3006
3007 #[cfg(unix)]
3008 #[test]
3009 fn relocates_internal_links_and_rejects_external_links() {
3010 use std::os::unix::fs::symlink;
3011
3012 let root = project();
3013 fs::create_dir_all(root.join("shared")).unwrap();
3014 fs::write(root.join("shared/value"), "inside").unwrap();
3015 symlink("../shared/value", root.join("src/value-link")).unwrap();
3016 let external = project();
3017 fs::write(external.join("outside"), "outside").unwrap();
3018 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3019 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3020 assert_eq!(
3021 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
3022 "inside"
3023 );
3024 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
3025 let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3026 assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
3027 assert_eq!(
3028 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3029 "one"
3030 );
3031 lock.release().unwrap();
3032 fs::remove_dir_all(root).unwrap();
3033 fs::remove_dir_all(external).unwrap();
3034 }
3035
3036 #[cfg(windows)]
3037 #[test]
3038 fn relocates_internal_junctions_without_symlink_privileges() {
3039 let root = project();
3040 fs::create_dir_all(root.join("shared")).unwrap();
3041 fs::write(root.join("shared/value"), "inside").unwrap();
3042 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
3043 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3044 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3045 let isolated_link = workspace.join("linked-shared");
3046 assert!(junction::exists(&isolated_link).unwrap());
3047 assert_eq!(
3048 canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
3049 canonicalize_simplified(workspace.join("shared")).unwrap()
3050 );
3051 assert_eq!(
3052 fs::read_to_string(isolated_link.join("value")).unwrap(),
3053 "inside"
3054 );
3055 lock.release().unwrap();
3056 fs::remove_dir_all(root).unwrap();
3057 }
3058
3059 #[cfg(windows)]
3060 #[test]
3061 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
3062 let root = project();
3063 fs::create_dir_all(root.join("node_modules/example")).unwrap();
3064 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
3065 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
3066 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3067 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3068 let package = workspace.join("node_modules/example");
3069 assert!(junction::exists(&package).unwrap());
3070 assert_eq!(
3071 canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
3072 canonicalize_simplified(root.join("node_modules/example")).unwrap()
3073 );
3074 let metadata = workspace.join("node_modules/.package-lock.json");
3075 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
3076 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
3077 lock.release().unwrap();
3078 fs::remove_dir_all(root).unwrap();
3079 }
3080
3081 #[test]
3082 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
3083 let root = project();
3084 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3085 ensure_container(&root).unwrap();
3086 let workspace = cached_workspace_path(&root, &lock).unwrap();
3087 let parent = workspace.parent().unwrap();
3088 fs::create_dir_all(parent).unwrap();
3089 let previous = parent.join(format!(
3090 "{}old",
3091 transaction_prefix(&root, "previous").unwrap()
3092 ));
3093 let staging = parent.join(format!(
3094 "{}new",
3095 transaction_prefix(&root, "staging").unwrap()
3096 ));
3097 fs::create_dir_all(&previous).unwrap();
3098 fs::write(previous.join("complete"), "yes").unwrap();
3099 fs::create_dir_all(&staging).unwrap();
3100 let result = recover_cached_workspace(&root, &lock).unwrap();
3101 assert!(result.restored_previous);
3102 assert_eq!(result.removed_staging, 1);
3103 assert_eq!(
3104 fs::read_to_string(workspace.join("complete")).unwrap(),
3105 "yes"
3106 );
3107 lock.release().unwrap();
3108 fs::remove_dir_all(root).unwrap();
3109 }
3110}