1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fs::{self, OpenOptions},
6 io::{self, Write},
7 path::{Component, Path, PathBuf},
8 sync::atomic::{AtomicU64, Ordering},
9 time::{SystemTime, UNIX_EPOCH},
10};
11
12use serde::Serialize;
13use sha2::{Digest, Sha256};
14
15use crate::lifecycle::{LifecycleError, ProjectLock, atomic_rename, remove_stored_tree_deferred};
16
17const WORKSPACE_MARKER: &str = ".supercov-workspace-store";
18const WORKSPACE_MARKER_CONTENTS: &[u8] = b"Supercov instrumented workspace. Safe to delete.\n";
19const ROOT_EXCLUSIONS: &[&str] = &[
20 ".cache",
21 ".git",
22 ".supercov",
23 ".mcdc-pool",
24 "node_modules",
25 "build",
26 "dist",
27 ".next",
28 ".nuxt",
29 ".output",
30 "coverage",
31 "playwright-report",
32 "test-results",
33 "target",
34];
35const NESTED_EXCLUSIONS: &[&str] = &[".supercov", ".mcdc-pool"];
36static UNIQUE: AtomicU64 = AtomicU64::new(0);
37
38#[derive(Debug)]
39pub enum WorkspaceError {
40 Io { path: PathBuf, source: io::Error },
41 UnsafePath(PathBuf),
42 UnsupportedEntry(PathBuf),
43 MissingLock,
44 Lifecycle(LifecycleError),
45 UnsupportedPlatform(&'static str),
46}
47
48impl std::fmt::Display for WorkspaceError {
49 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
52 Self::UnsafePath(path) => {
53 write!(formatter, "unsafe workspace path: {}", path.display())
54 }
55 Self::UnsupportedEntry(path) => {
56 write!(
57 formatter,
58 "unsupported filesystem entry in isolated project: {}",
59 path.display()
60 )
61 }
62 Self::MissingLock => write!(
63 formatter,
64 "isolated workspace preparation requires the active project lock"
65 ),
66 Self::Lifecycle(error) => write!(formatter, "{error}"),
67 Self::UnsupportedPlatform(reason) => {
68 write!(formatter, "unsupported workspace platform: {reason}")
69 }
70 }
71 }
72}
73
74impl std::error::Error for WorkspaceError {}
75
76impl From<LifecycleError> for WorkspaceError {
77 fn from(value: LifecycleError) -> Self {
78 Self::Lifecycle(value)
79 }
80}
81
82fn omit_escaping_link(path: &Path, target: &Path) {
87 eprintln!(
88 "[supercov] omitting symlink outside the isolated project: {} -> {}",
89 path.display(),
90 target.display()
91 );
92}
93
94pub(crate) fn canonicalize_simplified<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
102 fs::canonicalize(path).map(simplified)
103}
104
105#[cfg(windows)]
106pub(crate) fn simplified(path: PathBuf) -> PathBuf {
107 match path.to_str().and_then(strip_verbatim) {
108 Some(plain) => PathBuf::from(plain),
109 None => path,
110 }
111}
112
113#[cfg(not(windows))]
114pub(crate) fn simplified(path: PathBuf) -> PathBuf {
115 path
116}
117
118#[cfg_attr(not(windows), allow(dead_code))]
122pub(crate) fn strip_verbatim(path: &str) -> Option<String> {
123 let rest = path.strip_prefix(r"\\?\")?;
124 if let Some(unc) = rest.strip_prefix(r"UNC\") {
125 return Some(format!(r"\\{unc}"));
126 }
127 let mut chars = rest.chars();
128 let drive = chars.next()?;
129 if drive.is_ascii_alphabetic() && chars.next() == Some(':') {
130 return Some(rest.to_owned());
131 }
132 None
133}
134
135pub(crate) fn file_url(path: &Path) -> String {
144 let text = path.to_string_lossy();
145 #[cfg(windows)]
146 let text = text.replace('\\', "/");
147 file_url_from_slash_path(&text)
148}
149
150pub(crate) fn file_url_from_slash_path(slash_path: &str) -> String {
152 let mut url = String::from("file://");
153 let mut rest = slash_path;
154 if let Some(unc) = rest.strip_prefix("//") {
155 let (host, tail) = unc.split_once('/').unwrap_or((unc, ""));
157 url.push_str(host);
158 rest = tail;
159 url.push('/');
160 } else if !rest.starts_with('/') {
161 url.push('/');
163 }
164 for byte in rest.bytes() {
165 let keep =
166 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':');
167 if keep {
168 url.push(byte as char);
169 } else {
170 url.push_str(&format!("%{byte:02X}"));
171 }
172 }
173 url
174}
175
176fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
177 WorkspaceError::Io {
178 path: path.to_owned(),
179 source,
180 }
181}
182
183fn unique() -> String {
184 let nanos = SystemTime::now()
185 .duration_since(UNIX_EPOCH)
186 .unwrap_or_default()
187 .as_nanos();
188 format!(
189 "{}-{nanos}-{}",
190 std::process::id(),
191 UNIQUE.fetch_add(1, Ordering::Relaxed)
192 )
193}
194
195fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
196 root.file_name()
197 .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
198}
199
200pub fn workspace_container(root: &Path) -> PathBuf {
201 let preferred = root.join(".supercov/workspaces");
206 if fs::symlink_metadata(&preferred).is_err() || owned_workspace_path(&preferred) {
207 return preferred;
208 }
209 let digest = format!("{:x}", Sha256::digest(root.as_os_str().as_encoded_bytes()));
210 for sequence in 0..1_024usize {
211 let name = if sequence == 0 {
212 format!(".supercov/workspaces-{}", &digest[..16])
213 } else {
214 format!(".supercov/workspaces-{}-{sequence}", &digest[..16])
215 };
216 let candidate = root.join(name);
217 if fs::symlink_metadata(&candidate).is_err() || owned_workspace_path(&candidate) {
218 return candidate;
219 }
220 }
221 root.join(format!(".supercov/workspaces-{}-overflow", &digest[..16]))
222}
223
224pub fn planted_cargo_configuration(root: &Path, workspace: &Path) -> Option<PathBuf> {
231 if !workspace.starts_with(root) {
232 return None;
233 }
234 workspace
235 .ancestors()
236 .skip(1)
237 .take_while(|directory| *directory != root)
238 .map(|directory| directory.join(".cargo"))
239 .find(|path| fs::symlink_metadata(path).is_ok())
240}
241
242pub fn cached_workspace_path(root: &Path) -> Result<PathBuf, WorkspaceError> {
243 Ok(workspace_container(root)
244 .join("workspace")
245 .join(project_name(root)?))
246}
247
248pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
249 if run_id.is_empty()
250 || run_id == "."
251 || run_id == ".."
252 || run_id
253 .chars()
254 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
255 {
256 return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
257 }
258 Ok(workspace_container(root)
259 .join("work")
260 .join(run_id)
261 .join(project_name(root)?))
262}
263
264pub(crate) fn owned_workspace_path(path: &Path) -> bool {
265 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
266 && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
267 .is_ok_and(|metadata| metadata.file_type().is_file())
268 && fs::read(path.join(WORKSPACE_MARKER))
269 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
270}
271
272fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
273 let container = workspace_container(root);
274 let mut created = false;
275 match fs::symlink_metadata(&container) {
276 Ok(metadata) if !metadata.file_type().is_dir() => {
277 return Err(WorkspaceError::UnsafePath(container));
278 }
279 Ok(_) => {}
280 Err(error) if error.kind() == io::ErrorKind::NotFound => {
281 fs::create_dir_all(&container).map_err(|source| io_error(&container, source))?;
282 let store_ignore = root.join(".supercov/.gitignore");
285 if fs::symlink_metadata(&store_ignore).is_err() {
286 let _ = fs::write(&store_ignore, b"*\n");
287 }
288 created = true;
289 }
290 Err(source) => return Err(io_error(&container, source)),
291 }
292 let result = (|| {
293 if !created && !owned_workspace_path(&container) {
294 return Err(WorkspaceError::UnsafePath(container.clone()));
295 }
296 for (name, contents) in [
297 (".gitignore", b"*\n".as_slice()),
298 (WORKSPACE_MARKER, WORKSPACE_MARKER_CONTENTS),
299 ] {
300 let path = container.join(name);
301 match OpenOptions::new().write(true).create_new(true).open(&path) {
302 Ok(mut file) => file
303 .write_all(contents)
304 .and_then(|_| file.sync_all())
305 .map_err(|source| io_error(&path, source))?,
306 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
307 if !fs::symlink_metadata(&path)
308 .is_ok_and(|metadata| metadata.file_type().is_file())
309 {
310 return Err(WorkspaceError::UnsafePath(path));
311 }
312 if name == WORKSPACE_MARKER
313 && !fs::read(&path)
314 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
315 {
316 return Err(WorkspaceError::UnsafePath(path));
317 }
318 }
319 Err(source) => return Err(io_error(&path, source)),
320 }
321 }
322 Ok(container.clone())
323 })();
324 if result.is_err() && created {
325 let _ = fs::remove_dir_all(&container);
326 }
327 result
328}
329
330fn lexical_normalize(path: &Path) -> Option<PathBuf> {
331 let mut normalized = PathBuf::new();
332 for component in path.components() {
333 match component {
334 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
335 Component::RootDir => normalized.push(component.as_os_str()),
336 Component::CurDir => {}
337 Component::ParentDir => {
338 if !normalized.pop() {
339 return None;
340 }
341 }
342 Component::Normal(value) => normalized.push(value),
343 }
344 }
345 Some(normalized)
346}
347
348fn inside(root: &Path, path: &Path) -> bool {
349 path == root
350 || path.strip_prefix(root).is_ok_and(|local| {
351 !local.as_os_str().is_empty()
352 && local
353 .components()
354 .all(|component| matches!(component, Component::Normal(_)))
355 })
356}
357
358trait WorkspaceOperations {
359 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
360 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
361}
362
363struct SystemWorkspaceOperations;
364
365impl WorkspaceOperations for SystemWorkspaceOperations {
366 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
367 const REFLINK_MINIMUM_BYTES: u64 = 1024 * 1024;
372 let bytes = fs::symlink_metadata(source)
373 .map_err(|source_error| io_error(source, source_error))?
374 .len();
375 if bytes < REFLINK_MINIMUM_BYTES {
376 fs::copy(source, destination)
377 .map(|_| ())
378 .map_err(|source_error| io_error(destination, source_error))
379 } else {
380 reflink_copy::reflink_or_copy(source, destination)
381 .map(|_| ())
382 .map_err(|source_error| io_error(destination, source_error))
383 }
384 }
385
386 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
387 atomic_rename(source, destination).map_err(WorkspaceError::from)
388 }
389}
390
391#[cfg(unix)]
392pub(crate) fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
393 std::os::unix::fs::symlink(target, destination)
394}
395
396#[cfg(windows)]
397pub(crate) fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
398 if directory {
399 match junction::create(target, destination) {
406 Ok(()) => Ok(()),
407 Err(junction_error) => {
408 let _ = fs::remove_dir(destination);
409 std::os::windows::fs::symlink_dir(target, destination).map_err(
410 |symlink_error| {
411 io::Error::new(
412 symlink_error.kind(),
413 format!(
414 "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
415 ),
416 )
417 },
418 )
419 }
420 }
421 } else {
422 std::os::windows::fs::symlink_file(target, destination)
426 }
427}
428
429#[cfg(target_os = "macos")]
435fn clone_directory(source: &Path, destination: &Path) -> bool {
436 use std::{ffi::CString, os::unix::ffi::OsStrExt};
437
438 let (Ok(source_c), Ok(destination_c)) = (
439 CString::new(source.as_os_str().as_bytes()),
440 CString::new(destination.as_os_str().as_bytes()),
441 ) else {
442 return false;
443 };
444 let status = unsafe { libc::clonefile(source_c.as_ptr(), destination_c.as_ptr(), 0) };
447 if status == 0 {
448 return true;
449 }
450 let _ = fs::remove_dir_all(destination);
451 false
452}
453
454#[cfg(all(unix, not(target_os = "macos")))]
455fn clone_directory(_source: &Path, _destination: &Path) -> bool {
456 false
457}
458
459#[cfg(unix)]
471fn hard_link_tree(source: &Path, destination: &Path) -> bool {
472 fn link(source: &Path, destination: &Path) -> io::Result<()> {
473 fs::create_dir(destination)?;
474 for entry in fs::read_dir(source)? {
475 let entry = entry?;
476 let from = entry.path();
477 let to = destination.join(entry.file_name());
478 let file_type = entry.file_type()?;
479 if file_type.is_dir() {
480 link(&from, &to)?;
481 } else if file_type.is_symlink() {
482 std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?;
483 } else if file_type.is_file() {
484 fs::hard_link(&from, &to)?;
485 } else {
486 return Err(io::Error::new(
487 io::ErrorKind::Unsupported,
488 "entry is neither a file, a directory nor a symlink",
489 ));
490 }
491 }
492 Ok(())
493 }
494 if link(source, destination).is_ok() {
495 return true;
496 }
497 let _ = fs::remove_dir_all(destination);
498 false
499}
500
501#[derive(Clone, Copy)]
502struct CopyRoots<'a> {
503 source: &'a Path,
504 destination: &'a Path,
505 final_destination: &'a Path,
506 canonical_source: &'a Path,
507}
508
509fn copy_tree<Operations: WorkspaceOperations>(
510 source: &Path,
511 destination: &Path,
512 roots: CopyRoots<'_>,
513 root_level: bool,
514 operations: &mut Operations,
515) -> Result<(), WorkspaceError> {
516 fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
517 let mut entries = fs::read_dir(source)
518 .map_err(|error| io_error(source, error))?
519 .collect::<Result<Vec<_>, _>>()
520 .map_err(|error| io_error(source, error))?;
521 entries.sort_by_key(fs::DirEntry::file_name);
522 for entry in entries {
523 let name = entry.file_name();
524 let name_text = name
525 .to_str()
526 .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
527 if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
528 || NESTED_EXCLUSIONS.contains(&name_text)
529 {
530 continue;
531 }
532 let from = entry.path();
533 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
534 if metadata.file_type().is_dir() && owned_workspace_path(&from) {
535 continue;
536 }
537 let to = destination.join(&name);
538 if metadata.file_type().is_dir() {
539 #[cfg(unix)]
562 if name_text == "node_modules" && !root_level {
563 if clone_directory(&from, &to) || hard_link_tree(&from, &to) {
564 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
567 .map_err(|error| io_error(&to, error))?;
568 continue;
569 }
570 fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
571 let mut packages = fs::read_dir(&from)
572 .map_err(|error| io_error(&from, error))?
573 .collect::<Result<Vec<_>, _>>()
574 .map_err(|error| io_error(&from, error))?;
575 packages.sort_by_key(fs::DirEntry::file_name);
576 for package in packages {
577 let link_destination = to.join(package.file_name());
578 if package.file_name() == ".bin" && package.path().is_dir() {
579 link_tools(&package.path(), &link_destination, operations)?;
580 continue;
581 }
582 create_link(&package.path(), &link_destination, false)
583 .map_err(|error| io_error(&link_destination, error))?;
584 }
585 continue;
586 }
587 copy_tree(&from, &to, roots, false, operations)?;
588 if name_text == "node_modules" && !root_level {
589 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
590 .map_err(|error| io_error(&to, error))?;
591 }
592 } else if metadata.file_type().is_symlink() {
593 let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
594 let unresolved_target = if link.is_absolute() {
595 link.clone()
596 } else {
597 from.parent().expect("entry parent").join(&link)
598 };
599 let Some(lexical_target) = lexical_normalize(&unresolved_target) else {
600 omit_escaping_link(&from, &link);
601 continue;
602 };
603 let canonical_target = match canonicalize_simplified(&from) {
611 Ok(target) => Some(target),
612 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
613 Err(error) => return Err(io_error(&from, error)),
614 };
615 let escapes = !inside(roots.source, &lexical_target)
616 || canonical_target
617 .as_deref()
618 .is_some_and(|target| !inside(roots.canonical_source, target));
619 if escapes {
620 omit_escaping_link(&from, &link);
621 continue;
622 }
623 let local_target = lexical_target
624 .strip_prefix(roots.source)
625 .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
626 let relocated = roots.destination.join(local_target);
627 let Some(canonical_target) = canonical_target else {
628 if cfg!(windows) {
629 continue;
633 }
634 let isolated_link = if link.is_absolute() {
635 pathdiff(&to, &relocated)?
636 } else {
637 link
638 };
639 create_link(&isolated_link, &to, false).map_err(|error| io_error(&to, error))?;
640 continue;
641 };
642 let target_metadata = fs::metadata(&canonical_target)
643 .map_err(|error| io_error(&canonical_target, error))?;
644 let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
645 roots.final_destination.join(local_target)
646 } else if link.is_absolute() {
647 pathdiff(&to, &relocated)?
648 } else {
649 link
650 };
651 create_link(&isolated_link, &to, target_metadata.is_dir())
652 .map_err(|error| io_error(&to, error))?;
653 } else if metadata.file_type().is_file() {
654 operations.copy_file(&from, &to)?;
655 }
656 }
661 Ok(())
662}
663
664fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
665 let from = from
666 .parent()
667 .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
668 let from_components = from.components().collect::<Vec<_>>();
669 let to_components = to.components().collect::<Vec<_>>();
670 let common = from_components
671 .iter()
672 .zip(&to_components)
673 .take_while(|(left, right)| left == right)
674 .count();
675 if common == 0 {
676 return Err(WorkspaceError::UnsafePath(to.into()));
677 }
678 let mut relative = PathBuf::new();
679 for _ in common..from_components.len() {
680 relative.push("..");
681 }
682 for component in &to_components[common..] {
683 relative.push(component.as_os_str());
684 }
685 Ok(relative)
686}
687
688#[cfg(unix)]
698fn link_package(
699 root: &Path,
700 workspace: &Path,
701 target: &Path,
702 to: &Path,
703) -> Result<(), WorkspaceError> {
704 let metadata = fs::symlink_metadata(target).map_err(|error| io_error(target, error))?;
705 let scope = target
706 .file_name()
707 .and_then(|name| name.to_str())
708 .is_some_and(|name| name.starts_with('@'));
709 if scope && metadata.file_type().is_dir() {
710 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
711 let mut packages = fs::read_dir(target)
712 .map_err(|error| io_error(target, error))?
713 .collect::<Result<Vec<_>, _>>()
714 .map_err(|error| io_error(target, error))?;
715 packages.sort_by_key(fs::DirEntry::file_name);
716 for package in packages {
717 link_package(
718 root,
719 workspace,
720 &package.path(),
721 &to.join(package.file_name()),
722 )?;
723 }
724 return Ok(());
725 }
726 if metadata.file_type().is_symlink()
727 && let (Ok(resolved), Ok(canonical_root)) = (
728 canonicalize_simplified(target),
729 canonicalize_simplified(root),
730 )
731 && let Ok(local) = resolved.strip_prefix(&canonical_root)
732 && !local.as_os_str().is_empty()
733 && !local.components().any(|part| {
734 matches!(
735 part.as_os_str().to_str(),
736 Some("node_modules" | ".supercov")
737 )
738 })
739 {
740 let copy = workspace.join(local);
741 if fs::symlink_metadata(©).is_ok() {
742 return create_link(&pathdiff(to, ©)?, to, true)
743 .map_err(|error| io_error(to, error));
744 }
745 }
746 create_link(target, to, false).map_err(|error| io_error(to, error))
747}
748
749fn link_tools<Operations: WorkspaceOperations>(
755 tools: &Path,
756 to: &Path,
757 operations: &mut Operations,
758) -> Result<(), WorkspaceError> {
759 #[cfg(unix)]
760 let _ = &operations;
761 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
762 let judges = crate::source_tools::source_tools();
763 let binary = crate::source_tools::launching_binary();
764 let mut entries = fs::read_dir(tools)
765 .map_err(|error| io_error(tools, error))?
766 .collect::<Result<Vec<_>, _>>()
767 .map_err(|error| io_error(tools, error))?;
768 entries.sort_by_key(fs::DirEntry::file_name);
769 for entry in entries {
770 let (tool, link) = (entry.path(), to.join(entry.file_name()));
771 let launcher = binary.as_deref().and_then(|binary| {
772 crate::source_tools::launcher_for(&judges, binary, tools, &entry.file_name())
773 });
774 if let Some(launcher) = launcher {
775 crate::source_tools::write_launcher(&link, &launcher)
776 .map_err(|error| io_error(&link, error))?;
777 continue;
778 }
779 #[cfg(unix)]
783 create_link(&tool, &link, false).map_err(|error| io_error(&link, error))?;
784 #[cfg(windows)]
785 if fs::metadata(&tool).is_ok_and(|metadata| metadata.is_file()) {
786 operations.copy_file(&tool, &link)?;
787 }
788 }
789 Ok(())
790}
791
792fn link_node_modules<Operations: WorkspaceOperations>(
793 root: &Path,
794 workspace: &Path,
795 operations: &mut Operations,
796) -> Result<(), WorkspaceError> {
797 #[cfg(unix)]
798 let _ = &operations;
799 let source = root.join("node_modules");
800 let source_metadata = match fs::symlink_metadata(&source) {
801 Ok(metadata) => metadata,
802 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
803 Err(error) => return Err(io_error(&source, error)),
804 };
805 let source = if source_metadata.file_type().is_symlink() {
810 let resolved =
811 canonicalize_simplified(&source).map_err(|error| io_error(&source, error))?;
812 if !fs::symlink_metadata(&resolved)
813 .map_err(|error| io_error(&resolved, error))?
814 .file_type()
815 .is_dir()
816 {
817 return Err(WorkspaceError::UnsafePath(source));
818 }
819 resolved
820 } else if source_metadata.file_type().is_dir() {
821 source
822 } else {
823 return Err(WorkspaceError::UnsafePath(source));
824 };
825 let destination = workspace.join("node_modules");
826 fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
827 let mut entries = fs::read_dir(&source)
828 .map_err(|error| io_error(&source, error))?
829 .collect::<Result<Vec<_>, _>>()
830 .map_err(|error| io_error(&source, error))?;
831 entries.sort_by_key(fs::DirEntry::file_name);
832 for entry in entries {
833 let target = entry.path();
834 let to = destination.join(entry.file_name());
835 if entry.file_name() == ".bin"
836 && fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir())
837 {
838 link_tools(&target, &to, operations)?;
839 continue;
840 }
841 #[cfg(unix)]
842 link_package(root, workspace, &target, &to)?;
843 #[cfg(windows)]
844 {
845 let file_type = entry
846 .file_type()
847 .map_err(|error| io_error(&target, error))?;
848 let resolved = if file_type.is_symlink() {
849 canonicalize_simplified(&target).map_err(|error| io_error(&target, error))?
850 } else {
851 target.clone()
852 };
853 let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
854 if metadata.is_dir() {
855 create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
856 } else if metadata.is_file() {
857 operations.copy_file(&resolved, &to)?;
860 } else {
861 return Err(WorkspaceError::UnsupportedEntry(target));
862 }
863 }
864 }
865 Ok(())
866}
867
868fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
869 if lock.protects(root) {
870 Ok(())
871 } else {
872 Err(WorkspaceError::MissingLock)
873 }
874}
875
876pub fn prepare_isolated_workspace(
877 root: &Path,
878 run_id: &str,
879 lock: &ProjectLock,
880) -> Result<PathBuf, WorkspaceError> {
881 require_lock(root, lock)?;
882 ensure_container(root)?;
883 let mut operations = SystemWorkspaceOperations;
884 let workspace = isolated_workspace_path(root, run_id)?;
885 remove_stored_tree_deferred(root, &workspace)?;
886 let canonical_root = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
887 copy_tree(
888 root,
889 &workspace,
890 CopyRoots {
891 source: root,
892 destination: &workspace,
893 final_destination: &workspace,
894 canonical_source: &canonical_root,
895 },
896 true,
897 &mut operations,
898 )?;
899 link_node_modules(root, &workspace, &mut operations)?;
900 Ok(workspace)
901}
902
903fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
904 Ok(format!(
905 ".{}.{}-",
906 project_name(root)?.to_string_lossy(),
907 kind
908 ))
909}
910
911fn transaction_path(root: &Path, kind: &str) -> Result<PathBuf, WorkspaceError> {
912 let workspace = cached_workspace_path(root)?;
913 Ok(workspace.parent().expect("workspace parent").join(format!(
914 "{}{}",
915 transaction_prefix(root, kind)?,
916 unique()
917 )))
918}
919
920#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
921#[serde(rename_all = "camelCase")]
922pub struct CacheRecoveryResult {
923 pub restored_previous: bool,
924 pub removed_staging: usize,
925 pub removed_previous: usize,
926}
927
928pub fn recover_cached_workspace(
929 root: &Path,
930 lock: &ProjectLock,
931) -> Result<CacheRecoveryResult, WorkspaceError> {
932 require_lock(root, lock)?;
933 let workspace = cached_workspace_path(root)?;
934 let parent = workspace.parent().expect("workspace parent");
935 let entries = match fs::read_dir(parent) {
936 Ok(entries) => entries,
937 Err(error) if error.kind() == io::ErrorKind::NotFound => {
938 return Ok(CacheRecoveryResult {
939 restored_previous: false,
940 removed_staging: 0,
941 removed_previous: 0,
942 });
943 }
944 Err(error) => return Err(io_error(parent, error)),
945 };
946 let staging_prefix = transaction_prefix(root, "staging")?;
947 let previous_prefix = transaction_prefix(root, "previous")?;
948 let mut staging = Vec::new();
949 let mut previous = Vec::new();
950 let mut invalid_previous = Vec::new();
951 for entry in entries {
952 let entry = entry.map_err(|error| io_error(parent, error))?;
953 let name = entry.file_name().to_string_lossy().into_owned();
954 if name.starts_with(&staging_prefix) {
955 staging.push(entry.path());
956 } else if name.starts_with(&previous_prefix) {
957 let metadata = entry
958 .file_type()
959 .map_err(|error| io_error(&entry.path(), error))?;
960 if metadata.is_dir() {
961 let modified = entry
962 .metadata()
963 .and_then(|metadata| metadata.modified())
964 .unwrap_or(UNIX_EPOCH);
965 previous.push((modified, entry.path()));
966 } else {
967 invalid_previous.push(entry.path());
968 }
969 }
970 }
971 previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
972 let mut restored = false;
973 if fs::symlink_metadata(&workspace).is_err()
974 && let Some((_, newest)) = previous.first()
975 {
976 atomic_rename(newest, &workspace)?;
977 previous.remove(0);
978 restored = true;
979 }
980 let removed_staging = staging.len();
981 let removed_previous = previous.len() + invalid_previous.len();
982 for path in staging
983 .into_iter()
984 .chain(previous.into_iter().map(|(_, path)| path))
985 .chain(invalid_previous)
986 {
987 remove_stored_tree_deferred(root, &path)?;
988 }
989 Ok(CacheRecoveryResult {
990 restored_previous: restored,
991 removed_staging,
992 removed_previous,
993 })
994}
995
996fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
997 if requested.is_absolute()
998 || requested
999 .components()
1000 .any(|component| !matches!(component, Component::Normal(_)))
1001 {
1002 return Err(WorkspaceError::UnsafePath(requested.into()));
1003 }
1004 let path = workspace.join(requested);
1005 if path == workspace || fs::symlink_metadata(&path).is_err() {
1006 return Err(WorkspaceError::UnsafePath(requested.into()));
1007 }
1008 Ok(path)
1009}
1010
1011pub fn prepare_cached_workspace(
1012 root: &Path,
1013 lock: &ProjectLock,
1014 reuse_paths: &[PathBuf],
1015) -> Result<PathBuf, WorkspaceError> {
1016 let mut operations = SystemWorkspaceOperations;
1017 prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
1018}
1019
1020fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1021 root: &Path,
1022 lock: &ProjectLock,
1023 reuse_paths: &[PathBuf],
1024 operations: &mut Operations,
1025) -> Result<PathBuf, WorkspaceError> {
1026 require_lock(root, lock)?;
1027 ensure_container(root)?;
1028 recover_cached_workspace(root, lock)?;
1029 let workspace = cached_workspace_path(root)?;
1030 let staging = transaction_path(root, "staging")?;
1031 let previous = transaction_path(root, "previous")?;
1032 let result = (|| {
1033 let canonical_root =
1034 canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1035 copy_tree(
1036 root,
1037 &staging,
1038 CopyRoots {
1039 source: root,
1040 destination: &staging,
1041 final_destination: &workspace,
1042 canonical_source: &canonical_root,
1043 },
1044 true,
1045 operations,
1046 )?;
1047 link_node_modules(root, &staging, operations)?;
1048 for requested in reuse_paths {
1049 let from = checked_reuse_path(&workspace, requested)?;
1050 let to = staging.join(requested);
1051 match fs::symlink_metadata(&to) {
1056 Ok(existing) if existing.file_type().is_dir() => {
1057 fs::remove_dir_all(&to).map_err(|error| io_error(&to, error))?;
1058 }
1059 Ok(_) => {
1060 fs::remove_file(&to).map_err(|error| io_error(&to, error))?;
1061 }
1062 Err(_) => {}
1063 }
1064 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
1065 if metadata.file_type().is_dir() {
1066 let canonical_workspace = canonicalize_simplified(&workspace)
1067 .map_err(|error| io_error(&workspace, error))?;
1068 copy_tree(
1069 &from,
1070 &to,
1071 CopyRoots {
1072 source: &workspace,
1073 destination: &staging,
1074 final_destination: &workspace,
1075 canonical_source: &canonical_workspace,
1076 },
1077 false,
1078 operations,
1079 )?;
1080 } else if metadata.file_type().is_file() {
1081 fs::create_dir_all(to.parent().expect("reuse parent"))
1082 .map_err(|error| io_error(&to, error))?;
1083 operations.copy_file(&from, &to)?;
1084 } else {
1085 return Err(WorkspaceError::UnsupportedEntry(from));
1086 }
1087 }
1088 let mut moved_previous = false;
1089 if fs::symlink_metadata(&workspace).is_ok() {
1090 operations.rename(&workspace, &previous)?;
1091 moved_previous = true;
1092 }
1093 if let Err(error) = operations.rename(&staging, &workspace) {
1094 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1095 let _ = operations.rename(&previous, &workspace);
1096 }
1097 return Err(error);
1098 }
1099 if fs::symlink_metadata(&previous).is_ok() {
1100 remove_stored_tree_deferred(root, &previous)?;
1101 }
1102 Ok(workspace.clone())
1103 })();
1104 if fs::symlink_metadata(&staging).is_ok() {
1105 remove_stored_tree_deferred(root, &staging)?;
1106 }
1107 result
1108}
1109
1110pub struct WorkspaceOutputBaseline {
1114 entries: BTreeMap<PathBuf, (u64, SystemTime)>,
1115}
1116
1117#[derive(Debug, Default, PartialEq, Eq)]
1119pub struct CommandOutputSync {
1120 pub synced: usize,
1121 pub synced_paths: Vec<PathBuf>,
1123 pub skipped_instrumented: Vec<PathBuf>,
1127 pub deleted_in_workspace: Vec<PathBuf>,
1130}
1131
1132fn walk_output_files(
1133 workspace: &Path,
1134 directory: &Path,
1135 entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1136) -> Result<(), WorkspaceError> {
1137 for entry in fs::read_dir(directory)
1138 .map_err(|error| io_error(directory, error))?
1139 .collect::<Result<Vec<_>, _>>()
1140 .map_err(|error| io_error(directory, error))?
1141 {
1142 let path = entry.path();
1143 let name = entry.file_name();
1144 let relative = path
1145 .strip_prefix(workspace)
1146 .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1147 .to_owned();
1148 if relative.components().count() == 1
1152 && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1153 {
1154 continue;
1155 }
1156 let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1157 if metadata.is_dir() && name.to_str() == Some("node_modules") {
1161 continue;
1162 }
1163 if fs::symlink_metadata(&path)
1164 .map_err(|error| io_error(&path, error))?
1165 .file_type()
1166 .is_symlink()
1167 {
1168 continue;
1169 }
1170 if metadata.is_dir() {
1171 walk_output_files(workspace, &path, entries)?;
1172 } else if metadata.is_file() {
1173 let modified = metadata
1174 .modified()
1175 .map_err(|error| io_error(&path, error))?;
1176 entries.insert(relative, (metadata.len(), modified));
1177 }
1178 }
1179 Ok(())
1180}
1181
1182pub fn workspace_output_baseline(
1183 workspace: &Path,
1184) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1185 let mut entries = BTreeMap::new();
1186 walk_output_files(workspace, workspace, &mut entries)?;
1187 Ok(WorkspaceOutputBaseline { entries })
1188}
1189
1190fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1193 if relative.as_os_str().is_empty()
1194 || relative
1195 .components()
1196 .any(|component| !matches!(component, Component::Normal(_)))
1197 {
1198 return Err(WorkspaceError::UnsafePath(relative.into()));
1199 }
1200 let mut current = root.to_owned();
1201 for component in relative.components() {
1202 current.push(component.as_os_str());
1203 match fs::symlink_metadata(¤t) {
1204 Ok(metadata) if metadata.file_type().is_symlink() => {
1205 return Err(WorkspaceError::UnsafePath(current));
1206 }
1207 Ok(_) => {}
1208 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1209 Err(error) => return Err(io_error(¤t, error)),
1210 }
1211 }
1212 Ok(())
1213}
1214
1215pub fn summarize_paths(paths: &[PathBuf]) -> String {
1220 const SHOWN: usize = 4;
1221 let mut places = BTreeMap::<String, usize>::new();
1222 for path in paths {
1223 let mut components = path.components();
1224 let first = components
1225 .next()
1226 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1227 .unwrap_or_default();
1228 let place = if components.next().is_some() {
1229 format!("{first}/")
1230 } else {
1231 first
1232 };
1233 *places.entry(place).or_default() += 1;
1234 }
1235 let mut places = places.into_iter().collect::<Vec<_>>();
1236 places.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1237 let mut shown = places
1238 .iter()
1239 .take(SHOWN)
1240 .map(|(place, count)| {
1241 if place.ends_with('/') {
1242 format!("{place} {count}")
1243 } else {
1244 place.clone()
1245 }
1246 })
1247 .collect::<Vec<_>>();
1248 if places.len() > SHOWN {
1249 let rest = places[SHOWN..]
1250 .iter()
1251 .map(|(_, count)| count)
1252 .sum::<usize>();
1253 shown.push(format!("{rest} elsewhere"));
1254 }
1255 shown.join(", ")
1256}
1257
1258struct BuiltDirectories<'a> {
1265 root: &'a Path,
1266 judged: BTreeMap<PathBuf, bool>,
1267 directories: BTreeSet<PathBuf>,
1268}
1269
1270impl<'a> BuiltDirectories<'a> {
1271 fn new(root: &'a Path) -> Self {
1272 Self {
1273 root,
1274 judged: BTreeMap::new(),
1275 directories: BTreeSet::new(),
1276 }
1277 }
1278
1279 fn add(&mut self, relative: &Path) {
1280 let mut directory = PathBuf::new();
1281 let Some(parent) = relative.parent() else {
1282 return;
1283 };
1284 for component in parent.components() {
1285 directory.push(component);
1286 let root = self.root;
1287 let output = *self
1288 .judged
1289 .entry(directory.clone())
1290 .or_insert_with(|| is_build_output(root, &directory));
1291 if output {
1292 self.directories.insert(directory);
1293 return;
1294 }
1295 }
1296 }
1297
1298 fn holds(&self, relative: &Path) -> bool {
1299 relative
1300 .ancestors()
1301 .skip(1)
1302 .any(|directory| self.directories.contains(directory))
1303 }
1304}
1305
1306fn compiled_javascript(path: &Path) -> bool {
1308 path.extension()
1309 .and_then(|extension| extension.to_str())
1310 .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "map"))
1311}
1312
1313fn is_build_output(root: &Path, directory: &Path) -> bool {
1314 if fs::symlink_metadata(root.join(directory)).is_err() {
1315 return true;
1316 }
1317 std::process::Command::new("git")
1318 .arg("-C")
1319 .arg(root)
1320 .args(["check-ignore", "-q", "--"])
1321 .arg(directory)
1322 .env("GIT_TERMINAL_PROMPT", "0")
1323 .stdin(std::process::Stdio::null())
1324 .stdout(std::process::Stdio::null())
1325 .stderr(std::process::Stdio::null())
1326 .status()
1327 .is_ok_and(|status| status.code() == Some(0))
1328}
1329
1330const NAMED_FILE_LIMIT: u64 = 16 << 20;
1332
1333fn copy_and_project(root: &Path, workspace: &Path) -> Vec<(String, String)> {
1337 let mut places = Vec::new();
1338 let mut add = |copy: &Path, project: &Path| {
1339 let (copy, project) = (copy.to_string_lossy(), project.to_string_lossy());
1340 for (from, to) in [("\\", "\\"), ("\\", "\\\\"), ("\\", "/")] {
1341 let pair = (copy.replace(from, to), project.replace(from, to));
1342 if !pair.0.is_empty() && pair.0 != pair.1 && !places.contains(&pair) {
1343 places.push(pair);
1344 }
1345 }
1346 };
1347 add(workspace, root);
1348 if let (Ok(copy), Ok(project)) = (
1349 canonicalize_simplified(workspace),
1350 canonicalize_simplified(root),
1351 ) {
1352 add(©, &project);
1353 }
1354 places.sort_by_key(|(copy, _)| std::cmp::Reverse(copy.len()));
1356 places
1357}
1358
1359fn naming_the_project(
1369 file: &Path,
1370 places: &[(String, String)],
1371) -> Result<Option<String>, WorkspaceError> {
1372 let metadata = fs::metadata(file).map_err(|error| io_error(file, error))?;
1373 if !metadata.is_file() || metadata.len() > NAMED_FILE_LIMIT {
1374 return Ok(None);
1375 }
1376 let bytes = fs::read(file).map_err(|error| io_error(file, error))?;
1377 if bytes.contains(&0) {
1378 return Ok(None);
1379 }
1380 let Ok(text) = String::from_utf8(bytes) else {
1381 return Ok(None);
1382 };
1383 Ok(project_named(&text, places))
1384}
1385
1386pub fn console_names(root: &Path, workspace: &Path) -> crate::process_supervision::ConsoleNames {
1392 let up = workspace
1393 .strip_prefix(root)
1394 .ok()
1395 .map(|inside| inside.components().count())
1396 .filter(|levels| *levels > 0)
1397 .map(|levels| "../".repeat(levels));
1398 crate::process_supervision::ConsoleNames {
1399 places: copy_and_project(root, workspace),
1400 up_to_the_project: up,
1401 }
1402}
1403
1404pub(crate) fn project_named(text: &str, places: &[(String, String)]) -> Option<String> {
1407 if !places.iter().any(|(copy, _)| text.contains(copy.as_str())) {
1408 return None;
1409 }
1410 let mut out = String::with_capacity(text.len());
1411 let mut rest = text;
1412 while let Some((at, copy, project)) = places
1415 .iter()
1416 .filter_map(|(copy, project)| Some((rest.find(copy.as_str())?, copy, project)))
1417 .min_by_key(|(at, _, _)| *at)
1418 {
1419 out.push_str(&rest[..at]);
1420 let after = &rest[at + copy.len()..];
1421 let continues = after
1424 .chars()
1425 .next()
1426 .is_some_and(|next| next.is_alphanumeric() || matches!(next, '-' | '_'));
1427 if continues {
1428 let first = rest[at..].chars().next().expect("a match is not empty");
1429 out.push(first);
1430 rest = &rest[at + first.len_utf8()..];
1431 } else {
1432 out.push_str(project);
1433 rest = after;
1434 }
1435 }
1436 out.push_str(rest);
1437 (out != text).then_some(out)
1438}
1439
1440pub fn sync_command_outputs(
1447 root: &Path,
1448 workspace: &Path,
1449 baseline: &WorkspaceOutputBaseline,
1450 protected: &BTreeSet<PathBuf>,
1451) -> Result<CommandOutputSync, WorkspaceError> {
1452 sync_outputs_of(
1453 CommandOutputs::JavaScript,
1454 root,
1455 workspace,
1456 baseline,
1457 protected,
1458 )
1459}
1460
1461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1470pub enum CommandOutputs {
1471 JavaScript,
1472 Go,
1473 Jvm,
1474 Rust,
1475}
1476
1477fn links_the_go_runtime(path: &Path) -> Result<bool, WorkspaceError> {
1480 const MARKER: &[u8] = b"_supercov_runtime";
1481 let bytes = fs::read(path).map_err(|error| io_error(path, error))?;
1482 Ok(bytes.contains(&0) && bytes.windows(MARKER.len()).any(|window| window == MARKER))
1483}
1484
1485const JVM_REPORTS: &[&str] = &[
1487 "surefire-reports",
1488 "failsafe-reports",
1489 "test-results",
1490 "reports",
1491 "site",
1492];
1493
1494fn jvm_build_state(workspace: &Path, relative: &Path) -> bool {
1500 let parts = relative
1501 .components()
1502 .map(|part| part.as_os_str().to_string_lossy().into_owned())
1503 .collect::<Vec<_>>();
1504 if parts.first().is_some_and(|first| first == ".gradle") {
1505 return true;
1506 }
1507 if parts.last().is_some_and(|name| {
1509 matches!(
1510 name.as_str(),
1511 "pom.xml"
1512 | "build.gradle"
1513 | "build.gradle.kts"
1514 | "settings.gradle"
1515 | "settings.gradle.kts"
1516 )
1517 }) {
1518 return true;
1519 }
1520 if relative
1521 .extension()
1522 .is_some_and(|extension| extension == "class" || extension == "jar")
1523 {
1524 return true;
1525 }
1526 let mut module = workspace.to_path_buf();
1527 for (index, part) in parts.iter().enumerate() {
1528 let output = match part.as_str() {
1529 "target" => module.join("pom.xml").is_file(),
1530 "build" => [
1531 "build.gradle",
1532 "build.gradle.kts",
1533 "settings.gradle",
1534 "settings.gradle.kts",
1535 ]
1536 .iter()
1537 .any(|file| module.join(file).is_file()),
1538 _ => false,
1539 };
1540 if output {
1541 return !parts[index + 1..]
1542 .iter()
1543 .any(|inside| JVM_REPORTS.contains(&inside.as_str()));
1544 }
1545 module.push(part);
1546 }
1547 false
1548}
1549
1550pub fn sync_outputs_of(
1553 kind: CommandOutputs,
1554 root: &Path,
1555 workspace: &Path,
1556 baseline: &WorkspaceOutputBaseline,
1557 protected: &BTreeSet<PathBuf>,
1558) -> Result<CommandOutputSync, WorkspaceError> {
1559 let mut current = BTreeMap::new();
1560 walk_output_files(workspace, workspace, &mut current)?;
1561 let mut sync = CommandOutputSync::default();
1562 let places = copy_and_project(root, workspace);
1574 let mut changed = Vec::new();
1575 let mut built = BTreeSet::new();
1576 let mut built_directories = BuiltDirectories::new(root);
1577 for (relative, state) in ¤t {
1578 if baseline.entries.get(relative) == Some(state) {
1579 continue;
1580 }
1581 if !protected.contains(relative) {
1582 match kind {
1583 CommandOutputs::JavaScript => {
1584 let compiled = compiled_javascript(relative);
1589 if built_from_instrumented_source(&workspace.join(relative), compiled)? {
1590 built.insert(relative.clone());
1591 if compiled {
1592 built_directories.add(relative);
1593 }
1594 }
1595 }
1596 CommandOutputs::Go => {
1597 if links_the_go_runtime(&workspace.join(relative))? {
1598 built.insert(relative.clone());
1599 }
1600 }
1601 CommandOutputs::Jvm => {
1602 if jvm_build_state(workspace, relative) {
1603 built.insert(relative.clone());
1604 }
1605 }
1606 CommandOutputs::Rust => {
1613 if relative
1614 .extension()
1615 .is_some_and(|extension| extension == "rs")
1616 || relative
1617 .file_name()
1618 .is_some_and(|name| name == "Cargo.lock")
1619 {
1620 built.insert(relative.clone());
1621 }
1622 }
1623 }
1624 }
1625 changed.push(relative);
1626 }
1627 for relative in changed {
1628 if protected.contains(relative)
1629 || built.contains(relative)
1630 || built_directories.holds(relative)
1631 {
1632 sync.skipped_instrumented.push(relative.clone());
1633 continue;
1634 }
1635 let from = workspace.join(relative);
1636 if unaccompanied_install_manifest(root, workspace, relative) {
1637 continue;
1638 }
1639 validate_writeback_destination(root, relative)?;
1640 let to = root.join(relative);
1641 if let Some(parent) = to.parent() {
1642 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1643 }
1644 match naming_the_project(&from, &places)? {
1645 Some(text) => {
1646 fs::write(&to, text).map_err(|error| io_error(&to, error))?;
1647 if let Ok(metadata) = fs::metadata(&from) {
1648 let _ = fs::set_permissions(&to, metadata.permissions());
1649 }
1650 }
1651 None => {
1652 fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1653 }
1654 }
1655 sync.synced += 1;
1656 sync.synced_paths.push(relative.clone());
1657 }
1658 for relative in baseline.entries.keys() {
1659 if !current.contains_key(relative) && fs::symlink_metadata(root.join(relative)).is_ok() {
1662 sync.deleted_in_workspace.push(relative.clone());
1663 }
1664 }
1665 Ok(sync)
1666}
1667
1668pub fn describe_command_outputs(outputs: &CommandOutputSync) -> Vec<String> {
1670 let mut lines = Vec::new();
1671 if outputs.synced > 0 {
1672 lines.push(format!(
1673 "[supercov] synced {} file(s) the command created or changed back to the project: {}",
1674 outputs.synced,
1675 summarize_paths(&outputs.synced_paths)
1676 ));
1677 }
1678 if !outputs.skipped_instrumented.is_empty() {
1679 lines.push(format!(
1680 "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
1681 outputs.skipped_instrumented.len(),
1682 summarize_paths(&outputs.skipped_instrumented)
1683 ));
1684 }
1685 if !outputs.deleted_in_workspace.is_empty() {
1686 lines.push(format!(
1687 "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project: {}",
1688 outputs.deleted_in_workspace.len(),
1689 summarize_paths(&outputs.deleted_in_workspace)
1690 ));
1691 }
1692 lines
1693}
1694
1695pub fn mirror_git_repository(root: &Path, workspace: &Path) -> Result<(), String> {
1706 if fs::symlink_metadata(root.join(".git")).is_err() {
1707 return Ok(());
1708 }
1709 let target = workspace.join(".git");
1710 let git = |directory: &Path, arguments: &[&str]| -> Result<String, String> {
1711 let output = std::process::Command::new("git")
1712 .arg("-C")
1713 .arg(directory)
1714 .args(arguments)
1715 .env("GIT_TERMINAL_PROMPT", "0")
1716 .stdin(std::process::Stdio::null())
1717 .output()
1718 .map_err(|error| format!("git {}: {error}", arguments.join(" ")))?;
1719 if output.status.success() {
1720 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1721 } else {
1722 Err(format!(
1723 "git {}: {}",
1724 arguments.join(" "),
1725 String::from_utf8_lossy(&output.stderr).trim()
1726 ))
1727 }
1728 };
1729 let result = (|| {
1730 if fs::symlink_metadata(&target).is_ok() {
1731 fs::remove_dir_all(&target).map_err(|error| error.to_string())?;
1732 }
1733 let source = root.to_string_lossy();
1734 let destination = target.to_string_lossy();
1735 git(
1736 workspace,
1737 &[
1738 "clone",
1739 "--quiet",
1740 "--shared",
1741 "--bare",
1742 &source,
1743 &destination,
1744 ],
1745 )?;
1746 git(workspace, &["config", "core.bare", "false"])?;
1747 match git(root, &["symbolic-ref", "-q", "HEAD"]) {
1748 Ok(branch) => git(workspace, &["symbolic-ref", "HEAD", &branch])?,
1749 Err(_) => {
1750 let commit = git(root, &["rev-parse", "HEAD"])?;
1751 git(workspace, &["update-ref", "--no-deref", "HEAD", &commit])?
1752 }
1753 };
1754 git(workspace, &["remote", "remove", "origin"])?;
1755 let remotes = git(root, &["remote"])?;
1756 for name in remotes.lines().filter(|name| !name.is_empty()) {
1757 let url = git(root, &["config", "--get", &format!("remote.{name}.url")])?;
1760 git(workspace, &["remote", "add", name, &url])?;
1761 }
1762 git(
1763 workspace,
1764 &[
1765 "fetch",
1766 "--quiet",
1767 "--no-tags",
1768 &source,
1769 "+refs/remotes/*:refs/remotes/*",
1770 ],
1771 )?;
1772 git(workspace, &["read-tree", "HEAD"])?;
1773 let exclude = target.join("info/exclude");
1775 fs::create_dir_all(target.join("info")).map_err(|error| error.to_string())?;
1776 let mut patterns = fs::read_to_string(&exclude).unwrap_or_default();
1777 patterns.push_str("\n.supercov/\n");
1778 fs::write(&exclude, patterns).map_err(|error| error.to_string())?;
1779 Ok(())
1780 })();
1781 if result.is_err() {
1782 let _ = fs::remove_dir_all(&target);
1783 }
1784 result
1785}
1786
1787pub fn hide_rewritten_files(workspace: &Path, files: &[String]) {
1791 if files.is_empty() || !workspace.join(".git").is_dir() {
1792 return;
1793 }
1794 let run = |arguments: &[&str], input: &str| {
1795 let mut child = std::process::Command::new("git")
1796 .arg("-C")
1797 .arg(workspace)
1798 .args(arguments)
1799 .stdin(std::process::Stdio::piped())
1800 .stdout(std::process::Stdio::piped())
1801 .stderr(std::process::Stdio::null())
1802 .spawn()
1803 .ok()?;
1804 child.stdin.take()?.write_all(input.as_bytes()).ok()?;
1805 let output = child.wait_with_output().ok()?;
1806 output
1807 .status
1808 .success()
1809 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1810 };
1811 let Some(listed) = run(&["ls-files", "-z"], "") else {
1813 return;
1814 };
1815 let tracked = listed.split('\0').collect::<BTreeSet<_>>();
1816 let hidden = files
1817 .iter()
1818 .filter(|file| tracked.contains(file.as_str()))
1819 .map(String::as_str)
1820 .collect::<Vec<_>>();
1821 if !hidden.is_empty() {
1822 let _ = run(
1823 &["update-index", "--skip-worktree", "--stdin"],
1824 &hidden.join("\n"),
1825 );
1826 }
1827}
1828
1829fn unaccompanied_install_manifest(root: &Path, workspace: &Path, relative: &Path) -> bool {
1836 let manifest = relative
1837 .file_name()
1838 .and_then(|name| name.to_str())
1839 .is_some_and(|name| {
1840 matches!(
1841 name,
1842 "package.json"
1843 | "package-lock.json"
1844 | "npm-shrinkwrap.json"
1845 | "yarn.lock"
1846 | "pnpm-lock.yaml"
1847 | "bun.lock"
1848 | "bun.lockb"
1849 )
1850 });
1851 let Some(directory) = relative.parent() else {
1852 return false;
1853 };
1854 manifest
1855 && workspace.join(directory).join("node_modules").is_dir()
1856 && !root.join(directory).join("node_modules").exists()
1857}
1858
1859fn built_from_instrumented_source(path: &Path, compiled: bool) -> Result<bool, WorkspaceError> {
1871 const RUNTIME: &[u8] = b".supercov/node_modules/";
1872 const PROBE: &[u8] = b"__supercov";
1873 const GLOBAL: &[u8] = b"__SUPERCOV_DIRECT_RUNTIME__";
1874 let contents = match fs::read(path) {
1875 Ok(contents) => contents,
1876 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1877 Err(error) => return Err(io_error(path, error)),
1878 };
1879 let holds = |marker: &[u8]| {
1880 contents
1881 .windows(marker.len())
1882 .any(|window| window == marker)
1883 };
1884 Ok(holds(RUNTIME) || (compiled && (holds(PROBE) || holds(GLOBAL))))
1885}
1886
1887pub fn prune_cached_workspace_sources(
1888 root: &Path,
1889 lock: &ProjectLock,
1890) -> Result<Vec<String>, WorkspaceError> {
1891 require_lock(root, lock)?;
1892 let workspace = cached_workspace_path(root)?;
1893 if fs::symlink_metadata(&workspace).is_err() {
1894 return Ok(Vec::new());
1895 }
1896 let keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
1897 let mut removed = Vec::new();
1898 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
1899 let entry = entry.map_err(|error| io_error(&workspace, error))?;
1900 let name = entry
1901 .file_name()
1902 .into_string()
1903 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
1904 if keep.contains(&name) {
1905 continue;
1906 }
1907 remove_stored_tree_deferred(root, &entry.path())?;
1908 removed.push(name);
1909 }
1910 removed.sort();
1911 Ok(removed)
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916 #[test]
1917 fn file_urls_match_what_node_derives_for_every_path_shape() {
1918 use super::file_url_from_slash_path;
1919 assert_eq!(
1920 file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
1921 "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
1922 );
1923 assert_eq!(
1924 file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
1925 "file:///C:/Users/a%20b/%231/register.mjs"
1926 );
1927 assert_eq!(
1928 file_url_from_slash_path("//server/share/dir/register.mjs"),
1929 "file://server/share/dir/register.mjs"
1930 );
1931 assert_eq!(
1932 file_url_from_slash_path("/w/p/register.mjs"),
1933 "file:///w/p/register.mjs"
1934 );
1935 assert_eq!(
1936 file_url_from_slash_path("/w/p x?q/ü.mjs"),
1937 "file:///w/p%20x%3Fq/%C3%BC.mjs"
1938 );
1939 }
1940
1941 #[test]
1942 fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
1943 use super::strip_verbatim;
1944 assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
1945 assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
1946 assert_eq!(
1947 strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
1948 Some(r"\\server\share\dir")
1949 );
1950 assert_eq!(strip_verbatim(r"C:\a\b"), None);
1951 assert_eq!(strip_verbatim("/w/project"), None);
1952 assert_eq!(strip_verbatim(r"\\server\share"), None);
1953 assert_eq!(strip_verbatim(r"\\?\"), None);
1954 }
1955
1956 use super::*;
1957
1958 fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
1959 let base =
1960 std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
1961 if base.exists() {
1962 fs::remove_dir_all(&base).unwrap();
1963 }
1964 let root = base.join("project");
1965 let workspace = base.join("workspace");
1966 fs::create_dir_all(root.join("src")).unwrap();
1967 fs::create_dir_all(workspace.join("src")).unwrap();
1968 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1969 fs::write(root.join("src/app.ts"), "original\n").unwrap();
1970 fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
1971 fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
1972 (root, workspace)
1973 }
1974
1975 #[test]
1976 fn the_workspace_has_a_repository_of_its_own_that_reads_like_the_project() {
1977 let (root, workspace) = writeback_fixture("git-mirror");
1978 let git = |directory: &Path, arguments: &[&str]| {
1979 let output = std::process::Command::new("git")
1980 .arg("-C")
1981 .arg(directory)
1982 .args(arguments)
1983 .output()
1984 .unwrap();
1985 assert!(output.status.success(), "git {arguments:?}: {output:?}");
1986 String::from_utf8(output.stdout).unwrap().trim().to_owned()
1987 };
1988 git(&root, &["init", "--quiet", "--initial-branch=release/v2"]);
1989 git(&root, &["config", "user.email", "test@example.com"]);
1990 git(&root, &["config", "user.name", "Test"]);
1991 git(
1992 &root,
1993 &[
1994 "remote",
1995 "add",
1996 "origin",
1997 "https://github.com/example/app.git",
1998 ],
1999 );
2000 git(&root, &["add", "src/app.ts"]);
2001 git(&root, &["commit", "--quiet", "-m", "first"]);
2002 let head = git(&root, &["rev-parse", "HEAD"]);
2003
2004 mirror_git_repository(&root, &workspace).unwrap();
2005 assert_eq!(git(&workspace, &["rev-parse", "HEAD"]), head);
2006 assert_eq!(
2007 git(&workspace, &["symbolic-ref", "HEAD"]),
2008 "refs/heads/release/v2"
2009 );
2010 assert_eq!(
2011 git(&workspace, &["config", "--get", "remote.origin.url"]),
2012 "https://github.com/example/app.git"
2013 );
2014 hide_rewritten_files(&workspace, &["src/app.ts".to_owned()]);
2016 assert_eq!(git(&workspace, &["status", "--porcelain"]), "");
2017
2018 fs::write(workspace.join("new.txt"), "new\n").unwrap();
2020 git(&workspace, &["add", "new.txt"]);
2021 git(
2022 &workspace,
2023 &[
2024 "-c",
2025 "user.email=t@example.com",
2026 "-c",
2027 "user.name=T",
2028 "commit",
2029 "--quiet",
2030 "-m",
2031 "second",
2032 ],
2033 );
2034 assert_eq!(git(&root, &["rev-parse", "HEAD"]), head);
2035 assert_eq!(git(&root, &["status", "--porcelain"]), "");
2036 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2037 }
2038
2039 #[test]
2040 fn an_install_the_command_made_does_not_flow_back_without_its_dependencies() {
2041 let (root, workspace) = writeback_fixture("tool-install");
2042 fs::write(workspace.join("package.json"), "{}").unwrap();
2043 fs::write(root.join("package.json"), "{}").unwrap();
2044 fs::create_dir_all(workspace.join("node_modules")).unwrap();
2045 fs::create_dir_all(root.join("node_modules")).unwrap();
2046 let baseline = workspace_output_baseline(&workspace).unwrap();
2047 fs::create_dir_all(workspace.join(".tap/plugins/node_modules/@tapjs/clock")).unwrap();
2049 fs::write(workspace.join(".tap/plugins/package.json"), "{}").unwrap();
2050 fs::write(workspace.join(".tap/plugins/package-lock.json"), "{}").unwrap();
2051 fs::create_dir_all(workspace.join(".tap/test-results")).unwrap();
2052 fs::write(workspace.join(".tap/test-results/basic.tap"), "ok").unwrap();
2053 fs::write(workspace.join("package.json"), "{\"version\":\"2\"}").unwrap();
2055 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2056 assert!(!root.join(".tap/plugins/package.json").exists());
2057 assert!(!root.join(".tap/plugins/package-lock.json").exists());
2058 assert!(root.join(".tap/test-results/basic.tap").is_file());
2059 assert_eq!(
2060 fs::read_to_string(root.join("package.json")).unwrap(),
2061 "{\"version\":\"2\"}"
2062 );
2063 assert_eq!(sync.synced, 2);
2064 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2065 }
2066
2067 #[test]
2068 fn a_file_that_flows_back_names_the_project_and_not_the_copy() {
2069 let (root, workspace) = writeback_fixture("names");
2073 let baseline = workspace_output_baseline(&workspace).unwrap();
2074 let (copy, project) = (workspace.display().to_string(), root.display().to_string());
2075 fs::create_dir_all(workspace.join("coverage")).unwrap();
2076 fs::write(
2077 workspace.join("coverage/coverage-final.json"),
2078 format!("{{\"{copy}/src/fee.js\": {{\"path\": \"{copy}/src/fee.js\"}}}}"),
2079 )
2080 .unwrap();
2081 fs::write(
2082 workspace.join("coverage/cobertura.xml"),
2083 format!("<sources><source>{copy}</source></sources>"),
2084 )
2085 .unwrap();
2086 fs::write(
2088 workspace.join("coverage/neighbour.txt"),
2089 format!("{copy}-old/file and {copy}"),
2090 )
2091 .unwrap();
2092 let mut binary = copy.clone().into_bytes();
2094 binary.push(0);
2095 fs::write(workspace.join("coverage/cache.bin"), &binary).unwrap();
2096 fs::write(workspace.join("coverage/plain.txt"), "nothing to name").unwrap();
2097 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2098 assert_eq!(sync.synced, 5);
2099 assert_eq!(
2100 fs::read_to_string(root.join("coverage/coverage-final.json")).unwrap(),
2101 format!("{{\"{project}/src/fee.js\": {{\"path\": \"{project}/src/fee.js\"}}}}")
2102 );
2103 assert_eq!(
2104 fs::read_to_string(root.join("coverage/cobertura.xml")).unwrap(),
2105 format!("<sources><source>{project}</source></sources>")
2106 );
2107 assert_eq!(
2108 fs::read_to_string(root.join("coverage/neighbour.txt")).unwrap(),
2109 format!("{copy}-old/file and {project}")
2110 );
2111 assert_eq!(fs::read(root.join("coverage/cache.bin")).unwrap(), binary);
2112 assert_eq!(
2113 fs::read_to_string(root.join("coverage/plain.txt")).unwrap(),
2114 "nothing to name"
2115 );
2116 assert!(
2118 fs::read_to_string(workspace.join("coverage/cobertura.xml"))
2119 .unwrap()
2120 .contains(©)
2121 );
2122 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2123 }
2124
2125 #[test]
2126 fn what_a_go_jvm_or_rust_command_writes_flows_back_and_what_it_builds_stays() {
2127 let write = |workspace: &Path, file: &str, contents: &[u8]| {
2132 let path = workspace.join(file);
2133 fs::create_dir_all(path.parent().unwrap()).unwrap();
2134 fs::write(path, contents).unwrap();
2135 };
2136 let flows = |kind: CommandOutputs, name: &str, files: &[(&str, &[u8])]| {
2137 let (root, workspace) = writeback_fixture(name);
2138 write(&workspace, "pom.xml", b"<project/>");
2140 write(&workspace, "app/build.gradle.kts", b"plugins {}");
2141 let baseline = workspace_output_baseline(&workspace).unwrap();
2142 for (file, contents) in files {
2143 write(&workspace, file, contents);
2144 }
2145 let sync =
2146 sync_outputs_of(kind, &root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2147 let synced = files
2148 .iter()
2149 .filter(|(file, _)| root.join(file).is_file())
2150 .map(|(file, _)| (*file).to_owned())
2151 .collect::<Vec<_>>();
2152 assert_eq!(sync.synced, synced.len(), "{sync:?}");
2153 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2154 synced
2155 };
2156 let mut binary = b"\x7fELF\0\0example.com/shop/_supercov_runtime\0".to_vec();
2157 binary.extend([0u8; 16]);
2158 assert_eq!(
2159 flows(
2160 CommandOutputs::Go,
2161 "go",
2162 &[
2163 ("cover.out", b"mode: set\n"),
2164 ("testdata/golden.png", b"\x89PNG\0\0"),
2165 ("shop.test", &binary),
2166 ]
2167 ),
2168 ["cover.out", "testdata/golden.png"]
2169 );
2170 assert_eq!(
2171 flows(
2172 CommandOutputs::Jvm,
2173 "jvm",
2174 &[
2175 (
2176 "target/surefire-reports/TEST-shop.CryptoTest.xml",
2177 b"<testsuite/>"
2178 ),
2179 ("target/site/jacoco/index.html", b"<html/>"),
2180 ("app/build/test-results/test/TEST-a.xml", b"<testsuite/>"),
2181 ("app/build/reports/tests/test/index.html", b"<html/>"),
2182 ("src/test/resources/approved/output.txt", b"approved"),
2183 ("target/classes/shop/Crypto.class", b"\xca\xfe\xba\xbe"),
2185 (
2186 "target/maven-status/compile/createdFiles.lst",
2187 b"shop/Crypto.class"
2188 ),
2189 ("app/build/classes/kotlin/main/A.class", b"\xca\xfe\xba\xbe"),
2190 ("app/build/tmp/compileKotlin/cache.txt", b"state"),
2191 (".gradle/8.10/fileHashes/fileHashes.lock", b"lock"),
2192 ("pom.xml", b"<project><!-- changed --></project>"),
2193 ("src/build/notes.txt", b"kept"),
2195 ]
2196 ),
2197 [
2198 "target/surefire-reports/TEST-shop.CryptoTest.xml",
2199 "target/site/jacoco/index.html",
2200 "app/build/test-results/test/TEST-a.xml",
2201 "app/build/reports/tests/test/index.html",
2202 "src/test/resources/approved/output.txt",
2203 "src/build/notes.txt",
2204 ]
2205 );
2206 assert_eq!(
2207 flows(
2208 CommandOutputs::Rust,
2209 "rust",
2210 &[
2211 ("src/snapshots/sealed.snap", b"SNAP"),
2212 ("report.json", b"{}"),
2213 ("Cargo.lock", b"# lock"),
2214 ("src/generated.rs", b"pub fn made() {}"),
2215 ]
2216 ),
2217 ["src/snapshots/sealed.snap", "report.json"]
2218 );
2219 }
2220
2221 #[test]
2222 fn dependency_trees_never_flow_back() {
2223 let (root, workspace) = writeback_fixture("dependencies");
2226 let baseline = workspace_output_baseline(&workspace).unwrap();
2227 fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
2228 fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
2229 fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
2230 fs::write(
2231 workspace.join("packages/app/node_modules/dep/index.js"),
2232 "dep",
2233 )
2234 .unwrap();
2235 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2236 assert_eq!(sync.synced, 0);
2237 assert!(sync.deleted_in_workspace.is_empty());
2238 assert!(!root.join("node_modules").exists());
2239 assert!(!root.join("packages").exists());
2240 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2241 }
2242
2243 #[test]
2244 fn output_built_from_instrumented_sources_never_flows_back() {
2245 let (root, workspace) = writeback_fixture("built-output");
2251 let baseline = workspace_output_baseline(&workspace).unwrap();
2252 fs::create_dir_all(workspace.join("dist")).unwrap();
2253 fs::write(
2254 workspace.join("dist/app.js"),
2255 "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
2256 )
2257 .unwrap();
2258 fs::write(
2259 workspace.join("dist/app.d.ts"),
2260 "export declare const a: number;\n",
2261 )
2262 .unwrap();
2263
2264 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2265
2266 assert_eq!(
2269 sync.skipped_instrumented,
2270 vec![PathBuf::from("dist/app.d.ts"), PathBuf::from("dist/app.js")],
2271 "the instrumented build is reported, not copied"
2272 );
2273 assert_eq!(sync.synced, 0);
2274 assert!(!root.join("dist").exists());
2275 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2276 }
2277
2278 #[test]
2279 fn a_built_file_in_a_directory_the_project_keeps_withholds_only_itself() {
2280 let (root, workspace) = writeback_fixture("built-beside-outputs");
2283 let baseline = workspace_output_baseline(&workspace).unwrap();
2284 fs::write(
2285 workspace.join("src/bundle.js"),
2286 "import \"../.supercov/node_modules/runtime.mjs\";\n",
2287 )
2288 .unwrap();
2289 fs::write(workspace.join("src/app.snap"), "updated\n").unwrap();
2290
2291 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2292
2293 assert_eq!(
2294 sync.skipped_instrumented,
2295 vec![PathBuf::from("src/bundle.js")]
2296 );
2297 assert_eq!(sync.synced, 1);
2298 assert_eq!(
2299 fs::read_to_string(root.join("src/app.snap")).unwrap(),
2300 "updated\n"
2301 );
2302 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2303 }
2304
2305 #[test]
2306 fn paths_are_summarized_by_where_they_are() {
2307 let paths = [
2308 ".next/BUILD_ID",
2309 ".next/server/app.js",
2310 ".next/server/chunks/a.js",
2311 "tests/e2e/runs/1/report.json",
2312 "coverage/report.json",
2313 "playwright-report/index.html",
2314 "notes.txt",
2315 "out/a.txt",
2316 ]
2317 .map(PathBuf::from);
2318 assert_eq!(
2319 summarize_paths(&paths),
2320 ".next/ 3, coverage/ 1, notes.txt, out/ 1, 2 elsewhere"
2321 );
2322 assert_eq!(summarize_paths(&paths[..1]), ".next/ 1");
2323 }
2324
2325 #[test]
2326 fn a_report_that_quotes_the_runtime_does_not_make_its_directory_a_build() {
2327 let (root, workspace) = writeback_fixture("report-quotes-runtime");
2330 let baseline = workspace_output_baseline(&workspace).unwrap();
2331 fs::create_dir_all(workspace.join("test-results/adds-an-item")).unwrap();
2332 fs::write(
2333 workspace.join("test-results/adds-an-item/error-context.md"),
2334 "at launch (/w/.supercov/node_modules/playwright.mjs:907:44)\n",
2335 )
2336 .unwrap();
2337 fs::write(
2338 workspace.join("test-results/adds-an-item/trace.zip"),
2339 "trace",
2340 )
2341 .unwrap();
2342
2343 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2344
2345 assert_eq!(
2346 sync.skipped_instrumented,
2347 vec![PathBuf::from("test-results/adds-an-item/error-context.md")]
2348 );
2349 assert_eq!(sync.synced, 1);
2350 assert!(root.join("test-results/adds-an-item/trace.zip").exists());
2351 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2352 }
2353
2354 #[test]
2355 fn a_build_directory_git_ignores_stays_behind_even_when_the_project_has_one() {
2356 let (root, workspace) = writeback_fixture("built-ignored");
2357 let git = |arguments: &[&str]| {
2358 std::process::Command::new("git")
2359 .arg("-C")
2360 .arg(&root)
2361 .args(arguments)
2362 .output()
2363 .unwrap()
2364 };
2365 if !git(&["init", "-q"]).status.success() {
2366 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2367 return;
2368 }
2369 fs::write(root.join(".gitignore"), ".next\n").unwrap();
2370 fs::create_dir_all(root.join(".next")).unwrap();
2371 fs::write(root.join(".next/BUILD_ID"), "mine\n").unwrap();
2372 let baseline = workspace_output_baseline(&workspace).unwrap();
2373 fs::create_dir_all(workspace.join(".next/server")).unwrap();
2374 fs::write(
2375 workspace.join(".next/server/chunk.js"),
2376 "__supercovCoverageHitV3(__supercovProbeFileV2, 0);\n",
2379 )
2380 .unwrap();
2381 fs::write(workspace.join(".next/BUILD_ID"), "instrumented\n").unwrap();
2382
2383 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2384
2385 assert_eq!(sync.synced, 0);
2386 assert_eq!(sync.skipped_instrumented.len(), 2);
2387 assert_eq!(
2388 fs::read_to_string(root.join(".next/BUILD_ID")).unwrap(),
2389 "mine\n"
2390 );
2391 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2392 }
2393
2394 #[test]
2395 fn a_minified_build_of_the_instrumented_copy_stays_in_the_workspace() {
2396 let (root, workspace) = writeback_fixture("built-minified");
2397 let initialized = std::process::Command::new("git")
2398 .arg("-C")
2399 .arg(&root)
2400 .args(["init", "-q"])
2401 .output()
2402 .unwrap();
2403 if !initialized.status.success() {
2404 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2405 return;
2406 }
2407 fs::write(root.join(".gitignore"), "dist\n").unwrap();
2408 fs::create_dir_all(root.join("dist")).unwrap();
2409 fs::write(root.join("dist/index.html"), "mine\n").unwrap();
2410 let baseline = workspace_output_baseline(&workspace).unwrap();
2411 fs::create_dir_all(workspace.join("dist/assets")).unwrap();
2412 fs::write(
2413 workspace.join("dist/assets/index-DzZxFCVp.js"),
2414 "const e=globalThis.__SUPERCOV_DIRECT_RUNTIME__.coverageHitV2;e(t,0);\n",
2417 )
2418 .unwrap();
2419 fs::write(workspace.join("dist/index.html"), "instrumented\n").unwrap();
2420
2421 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2422
2423 assert_eq!(sync.synced, 0);
2424 assert_eq!(sync.skipped_instrumented.len(), 2);
2425 assert_eq!(
2426 fs::read_to_string(root.join("dist/index.html")).unwrap(),
2427 "mine\n"
2428 );
2429 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2430 }
2431
2432 #[test]
2433 fn command_outputs_flow_back_to_the_project() {
2434 let (root, workspace) = writeback_fixture("outputs");
2435 let baseline = workspace_output_baseline(&workspace).unwrap();
2436 fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
2437 fs::write(
2438 workspace.join("src/__snapshots__/app.snap"),
2439 "updated snapshot\n",
2440 )
2441 .unwrap();
2442 fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
2443 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2444 assert_eq!(sync.synced, 1);
2445 assert_eq!(
2446 fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
2447 "updated snapshot\n"
2448 );
2449 assert!(!root.join(".supercov/state.json").exists());
2450 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2451 }
2452
2453 #[test]
2454 fn instrumented_sources_never_flow_back() {
2455 let (root, workspace) = writeback_fixture("protected");
2456 let baseline = workspace_output_baseline(&workspace).unwrap();
2457 fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
2460 let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
2461 let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
2462 assert_eq!(sync.synced, 0);
2463 assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
2464 assert_eq!(
2465 fs::read_to_string(root.join("src/app.ts")).unwrap(),
2466 "original\n"
2467 );
2468 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2469 }
2470
2471 #[test]
2472 fn workspace_deletions_are_reported_but_never_propagated() {
2473 let (root, workspace) = writeback_fixture("deletions");
2474 fs::write(workspace.join("stale.txt"), "old\n").unwrap();
2475 fs::write(root.join("stale.txt"), "old\n").unwrap();
2476 fs::write(workspace.join("built.txt"), "old\n").unwrap();
2478 let baseline = workspace_output_baseline(&workspace).unwrap();
2479 fs::remove_file(workspace.join("stale.txt")).unwrap();
2480 fs::remove_file(workspace.join("built.txt")).unwrap();
2481 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2482 assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
2483 assert!(root.join("stale.txt").exists());
2484 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2485 }
2486
2487 #[cfg(unix)]
2488 #[test]
2489 fn writeback_refuses_a_symlinked_destination() {
2490 let (root, workspace) = writeback_fixture("symlink");
2491 let outside = root.parent().unwrap().join("outside");
2492 fs::create_dir_all(&outside).unwrap();
2493 std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
2494 let baseline = workspace_output_baseline(&workspace).unwrap();
2495 fs::create_dir_all(workspace.join("reports")).unwrap();
2496 fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
2497 let error =
2498 sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
2499 assert!(matches!(error, WorkspaceError::UnsafePath(_)));
2500 assert!(!outside.join("result.txt").exists());
2501 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2502 }
2503
2504 struct OrdinaryCopyOperations;
2505
2506 impl WorkspaceOperations for OrdinaryCopyOperations {
2507 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2508 fs::copy(source, destination)
2509 .map(|_| ())
2510 .map_err(|error| io_error(destination, error))
2511 }
2512
2513 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2514 atomic_rename(source, destination).map_err(WorkspaceError::from)
2515 }
2516 }
2517
2518 struct FaultOperations {
2519 copy_count: usize,
2520 fail_copy_at: Option<usize>,
2521 rename_count: usize,
2522 fail_rename_at: Option<usize>,
2523 }
2524
2525 impl WorkspaceOperations for FaultOperations {
2526 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2527 self.copy_count += 1;
2528 if self.fail_copy_at == Some(self.copy_count) {
2529 return Err(io_error(
2530 destination,
2531 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
2532 ));
2533 }
2534 SystemWorkspaceOperations.copy_file(source, destination)
2535 }
2536
2537 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2538 self.rename_count += 1;
2539 if self.fail_rename_at == Some(self.rename_count) {
2540 return Err(io_error(
2541 destination,
2542 io::Error::other("injected publication rename failure"),
2543 ));
2544 }
2545 SystemWorkspaceOperations.rename(source, destination)
2546 }
2547 }
2548
2549 fn transaction_debris(root: &Path) -> Vec<String> {
2550 let workspace = cached_workspace_path(root).unwrap();
2551 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
2552 fs::read_dir(workspace.parent().unwrap())
2553 .unwrap()
2554 .filter_map(Result::ok)
2555 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2556 .filter(|name| name.starts_with(&prefix))
2557 .collect()
2558 }
2559
2560 fn project() -> PathBuf {
2561 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
2562 fs::create_dir_all(root.join("src")).unwrap();
2563 fs::write(root.join("src/index.js"), "one").unwrap();
2564 fs::write(root.join("package.json"), "{}").unwrap();
2565 root
2566 }
2567
2568 #[test]
2569 #[cfg(unix)]
2570 fn root_node_modules_symlink_is_followed() {
2571 let root = project();
2576 let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
2577 fs::create_dir_all(store.join("left-pad")).unwrap();
2578 fs::write(store.join("left-pad/package.json"), "{}").unwrap();
2579 std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
2580 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2581 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2582 assert!(
2583 fs::symlink_metadata(workspace.join("node_modules/left-pad"))
2584 .unwrap()
2585 .file_type()
2586 .is_symlink()
2587 );
2588 lock.release().unwrap();
2589 fs::remove_dir_all(root).unwrap();
2590 fs::remove_dir_all(store).unwrap();
2591 }
2592
2593 #[test]
2594 #[cfg(unix)]
2595 fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
2596 let root = project();
2603 let nested = root.join("examples/app/node_modules/@scope");
2604 fs::create_dir_all(&nested).unwrap();
2605 std::os::unix::fs::symlink(
2606 "../../../../packages/react/node_modules/@scope/pkg",
2607 nested.join("pkg"),
2608 )
2609 .unwrap();
2610 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2611 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2612 let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
2613 let metadata = fs::symlink_metadata(&mirrored).unwrap();
2614 assert!(metadata.file_type().is_symlink());
2615 assert_eq!(
2616 fs::read_link(&mirrored).unwrap(),
2617 PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
2618 );
2619 lock.release().unwrap();
2620 fs::remove_dir_all(root).unwrap();
2621 }
2622
2623 #[test]
2624 #[cfg(target_os = "macos")]
2625 fn nested_node_modules_are_cloned_not_linked() {
2626 let root = project();
2632 let nested = root.join("packages/app/node_modules");
2633 fs::create_dir_all(nested.join("dep")).unwrap();
2634 fs::create_dir_all(nested.join(".bin")).unwrap();
2635 fs::write(nested.join("dep/index.js"), "dep").unwrap();
2636 std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
2637 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2638 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2639 let mirrored = workspace.join("packages/app/node_modules");
2640 assert!(
2641 fs::symlink_metadata(mirrored.join("dep"))
2642 .unwrap()
2643 .file_type()
2644 .is_dir()
2645 );
2646 assert_eq!(
2647 fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
2648 "dep"
2649 );
2650 assert_eq!(
2651 fs::read_link(mirrored.join(".bin/dep")).unwrap(),
2652 PathBuf::from("../dep/index.js")
2653 );
2654 fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
2655 assert_eq!(
2656 fs::read_to_string(nested.join("dep/index.js")).unwrap(),
2657 "dep"
2658 );
2659 lock.release().unwrap();
2660 fs::remove_dir_all(root).unwrap();
2661 }
2662
2663 #[test]
2664 #[cfg(unix)]
2665 fn hard_linked_dependency_tree_is_real_and_replaceable() {
2666 use std::os::unix::fs::MetadataExt;
2670 let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
2671 let source = base.join("node_modules");
2672 fs::create_dir_all(source.join("dep")).unwrap();
2673 fs::create_dir_all(source.join(".bin")).unwrap();
2674 fs::write(source.join("dep/index.js"), "dep").unwrap();
2675 std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
2676 let destination = base.join("workspace/node_modules");
2677 fs::create_dir_all(destination.parent().unwrap()).unwrap();
2678 assert!(hard_link_tree(&source, &destination));
2679 assert_eq!(
2680 fs::metadata(destination.join("dep/index.js"))
2681 .unwrap()
2682 .ino(),
2683 fs::metadata(source.join("dep/index.js")).unwrap().ino()
2684 );
2685 assert_eq!(
2686 fs::read_link(destination.join(".bin/dep")).unwrap(),
2687 PathBuf::from("../dep/index.js")
2688 );
2689 fs::remove_dir_all(destination.join("dep")).unwrap();
2690 fs::create_dir_all(destination.join("dep")).unwrap();
2691 fs::write(destination.join("dep/index.js"), "replaced").unwrap();
2692 assert_eq!(
2693 fs::read_to_string(source.join("dep/index.js")).unwrap(),
2694 "dep"
2695 );
2696 fs::remove_dir_all(base).unwrap();
2697 }
2698
2699 #[test]
2700 #[cfg(unix)]
2701 fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
2702 let root = project();
2708 let nested = root.join("examples/app/lib");
2709 fs::create_dir_all(&nested).unwrap();
2710 std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
2711 .unwrap();
2712 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2713 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2714 assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
2715 assert!(workspace.join("src/index.js").exists());
2716 lock.release().unwrap();
2717 fs::remove_dir_all(root).unwrap();
2718 }
2719
2720 #[test]
2721 fn isolated_copy_never_changes_source_and_requires_the_lock() {
2722 let root = project();
2723 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2724 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2725 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
2726 assert_eq!(
2727 fs::read_to_string(root.join("src/index.js")).unwrap(),
2728 "one"
2729 );
2730 lock.release().unwrap();
2731 assert!(matches!(
2732 prepare_isolated_workspace(&root, "other", &lock),
2733 Err(WorkspaceError::MissingLock)
2734 ));
2735 fs::remove_dir_all(root).unwrap();
2736 }
2737
2738 #[test]
2739 fn everything_supercov_writes_lives_under_the_store() {
2740 let root = project();
2741 assert_eq!(
2742 workspace_container(&root),
2743 root.join(".supercov/workspaces")
2744 );
2745 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2746 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2747 assert!(workspace.starts_with(root.join(".supercov")));
2748 assert_eq!(
2749 fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
2750 "*\n"
2751 );
2752 lock.release().unwrap();
2753 fs::remove_dir_all(root).unwrap();
2754 }
2755
2756 #[test]
2757 fn an_unowned_container_gets_a_deterministic_fallback() {
2758 let root = project();
2759 fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
2760 fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
2761 let container = workspace_container(&root);
2762 assert_ne!(container, root.join(".supercov/workspaces"));
2763 let name = container
2764 .file_name()
2765 .unwrap()
2766 .to_string_lossy()
2767 .into_owned();
2768 assert!(name.starts_with("workspaces-"));
2769 fs::remove_dir_all(root).unwrap();
2770 }
2771
2772 #[test]
2773 fn user_supercov_directory_is_copied_and_never_adopted() {
2774 let root = project();
2775 fs::create_dir(root.join("supercov")).unwrap();
2776 fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
2777 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2778 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2779 assert_ne!(workspace_container(&root), root.join("supercov"));
2780 assert_eq!(
2781 fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
2782 "export default 1;\n"
2783 );
2784 assert_eq!(
2785 fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
2786 "export default 1;\n"
2787 );
2788 assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
2789 lock.release().unwrap();
2790 fs::remove_dir_all(root).unwrap();
2791 }
2792
2793 #[cfg(unix)]
2794 #[test]
2795 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
2796 let root = project();
2797 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2798 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2799 fs::create_dir_all(first.join("build")).unwrap();
2800 fs::write(first.join("build/output.js"), "instrumented").unwrap();
2801 fs::write(first.join("stale.txt"), "stale").unwrap();
2802 fs::write(root.join("src/index.js"), "two").unwrap();
2803 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
2804 assert_eq!(first, second);
2805 assert_eq!(
2806 fs::read_to_string(second.join("src/index.js")).unwrap(),
2807 "two"
2808 );
2809 assert_eq!(
2810 fs::read_to_string(second.join("build/output.js")).unwrap(),
2811 "instrumented"
2812 );
2813 assert!(!second.join("stale.txt").exists());
2814 lock.release().unwrap();
2815 fs::remove_dir_all(root).unwrap();
2816 }
2817
2818 #[test]
2819 fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
2820 let root = project();
2821 fs::create_dir_all(root.join("packages/app/dist")).unwrap();
2822 fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
2823 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2824 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2825 fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
2826 fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
2827 let second =
2828 prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
2829 assert_eq!(
2830 fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
2831 "instrumented"
2832 );
2833 assert!(!second.join("packages/app/dist/stale.js").exists());
2834 lock.release().unwrap();
2835 fs::remove_dir_all(root).unwrap();
2836 }
2837
2838 #[test]
2839 fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
2840 let root = project();
2841 fs::create_dir_all(root.join("tests/e2e")).unwrap();
2842 fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
2843 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2844 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2845 let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
2846 fs::create_dir_all(&artifacts).unwrap();
2847 fs::write(artifacts.join("digest"), "instrumented test").unwrap();
2848 fs::write(
2849 workspace.join(".supercov/frontend-cache.json"),
2850 "{\"schemaVersion\":2}",
2851 )
2852 .unwrap();
2853 prune_cached_workspace_sources(&root, &lock).unwrap();
2854 assert!(!workspace.join("tests/e2e/app.spec.js").exists());
2855 assert_eq!(
2856 fs::read_to_string(artifacts.join("digest")).unwrap(),
2857 "instrumented test"
2858 );
2859 lock.release().unwrap();
2860 fs::remove_dir_all(root).unwrap();
2861 }
2862
2863 #[test]
2864 fn ordinary_copy_fallback_preserves_workspace_semantics() {
2865 let root = project();
2866 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2867 let mut operations = OrdinaryCopyOperations;
2868 let workspace =
2869 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
2870 assert_eq!(
2871 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2872 "one"
2873 );
2874 assert_eq!(
2875 fs::read_to_string(root.join("src/index.js")).unwrap(),
2876 "one"
2877 );
2878 assert!(transaction_debris(&root).is_empty());
2879 lock.release().unwrap();
2880 fs::remove_dir_all(root).unwrap();
2881 }
2882
2883 #[test]
2884 fn enospc_during_copy_preserves_the_complete_generation() {
2885 let root = project();
2886 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2887 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2888 fs::write(workspace.join("generation"), "complete").unwrap();
2889 fs::write(root.join("src/index.js"), "new source").unwrap();
2890 let mut operations = FaultOperations {
2891 copy_count: 0,
2892 fail_copy_at: Some(1),
2893 rename_count: 0,
2894 fail_rename_at: None,
2895 };
2896 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2897 .unwrap_err();
2898 assert!(matches!(
2899 error,
2900 WorkspaceError::Io { ref source, .. }
2901 if source.kind() == io::ErrorKind::StorageFull
2902 ));
2903 assert_eq!(
2904 fs::read_to_string(workspace.join("generation")).unwrap(),
2905 "complete"
2906 );
2907 assert_eq!(
2908 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2909 "one"
2910 );
2911 assert!(transaction_debris(&root).is_empty());
2912 lock.release().unwrap();
2913 fs::remove_dir_all(root).unwrap();
2914 }
2915
2916 #[test]
2917 fn failed_publication_rename_restores_the_complete_generation() {
2918 let root = project();
2919 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2920 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2921 fs::write(workspace.join("generation"), "complete").unwrap();
2922 fs::write(root.join("src/index.js"), "new source").unwrap();
2923 let mut operations = FaultOperations {
2924 copy_count: 0,
2925 fail_copy_at: None,
2926 rename_count: 0,
2927 fail_rename_at: Some(2),
2928 };
2929 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2930 .unwrap_err();
2931 assert!(matches!(
2932 error,
2933 WorkspaceError::Io { ref source, .. }
2934 if source.kind() == io::ErrorKind::Other
2935 ));
2936 assert_eq!(
2937 operations.rename_count, 3,
2938 "prior generation was not restored"
2939 );
2940 assert_eq!(
2941 fs::read_to_string(workspace.join("generation")).unwrap(),
2942 "complete"
2943 );
2944 assert_eq!(
2945 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2946 "one"
2947 );
2948 assert!(transaction_debris(&root).is_empty());
2949 lock.release().unwrap();
2950 fs::remove_dir_all(root).unwrap();
2951 }
2952
2953 #[cfg(unix)]
2954 #[test]
2955 fn relocates_internal_links_and_rejects_external_links() {
2956 use std::os::unix::fs::symlink;
2957
2958 let root = project();
2959 fs::create_dir_all(root.join("shared")).unwrap();
2960 fs::write(root.join("shared/value"), "inside").unwrap();
2961 symlink("../shared/value", root.join("src/value-link")).unwrap();
2962 let external = project();
2963 fs::write(external.join("outside"), "outside").unwrap();
2964 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2965 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2966 assert_eq!(
2967 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
2968 "inside"
2969 );
2970 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
2971 let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2972 assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
2973 assert_eq!(
2974 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2975 "one"
2976 );
2977 lock.release().unwrap();
2978 fs::remove_dir_all(root).unwrap();
2979 fs::remove_dir_all(external).unwrap();
2980 }
2981
2982 #[cfg(windows)]
2983 #[test]
2984 fn relocates_internal_junctions_without_symlink_privileges() {
2985 let root = project();
2986 fs::create_dir_all(root.join("shared")).unwrap();
2987 fs::write(root.join("shared/value"), "inside").unwrap();
2988 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
2989 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2990 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2991 let isolated_link = workspace.join("linked-shared");
2992 assert!(junction::exists(&isolated_link).unwrap());
2993 assert_eq!(
2994 canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
2995 canonicalize_simplified(workspace.join("shared")).unwrap()
2996 );
2997 assert_eq!(
2998 fs::read_to_string(isolated_link.join("value")).unwrap(),
2999 "inside"
3000 );
3001 lock.release().unwrap();
3002 fs::remove_dir_all(root).unwrap();
3003 }
3004
3005 #[cfg(windows)]
3006 #[test]
3007 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
3008 let root = project();
3009 fs::create_dir_all(root.join("node_modules/example")).unwrap();
3010 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
3011 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
3012 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3013 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3014 let package = workspace.join("node_modules/example");
3015 assert!(junction::exists(&package).unwrap());
3016 assert_eq!(
3017 canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
3018 canonicalize_simplified(root.join("node_modules/example")).unwrap()
3019 );
3020 let metadata = workspace.join("node_modules/.package-lock.json");
3021 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
3022 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
3023 lock.release().unwrap();
3024 fs::remove_dir_all(root).unwrap();
3025 }
3026
3027 #[test]
3028 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
3029 let root = project();
3030 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3031 ensure_container(&root).unwrap();
3032 let workspace = cached_workspace_path(&root).unwrap();
3033 let parent = workspace.parent().unwrap();
3034 fs::create_dir_all(parent).unwrap();
3035 let previous = parent.join(format!(
3036 "{}old",
3037 transaction_prefix(&root, "previous").unwrap()
3038 ));
3039 let staging = parent.join(format!(
3040 "{}new",
3041 transaction_prefix(&root, "staging").unwrap()
3042 ));
3043 fs::create_dir_all(&previous).unwrap();
3044 fs::write(previous.join("complete"), "yes").unwrap();
3045 fs::create_dir_all(&staging).unwrap();
3046 let result = recover_cached_workspace(&root, &lock).unwrap();
3047 assert!(result.restored_previous);
3048 assert_eq!(result.removed_staging, 1);
3049 assert_eq!(
3050 fs::read_to_string(workspace.join("complete")).unwrap(),
3051 "yes"
3052 );
3053 lock.release().unwrap();
3054 fs::remove_dir_all(root).unwrap();
3055 }
3056}