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"];
36
37pub fn links_a_virtual_environment(workspace: &Path) -> bool {
42 fn search(directory: &Path, depth: usize) -> bool {
43 let Ok(entries) = fs::read_dir(directory) else {
44 return false;
45 };
46 entries.filter_map(Result::ok).any(|entry| {
47 let path = entry.path();
48 let name = entry.file_name();
49 let Ok(metadata) = fs::symlink_metadata(&path) else {
50 return false;
51 };
52 if metadata.file_type().is_symlink() {
53 return path.join("pyvenv.cfg").is_file();
54 }
55 depth > 0
56 && metadata.is_dir()
57 && name != "node_modules"
58 && name != ".supercov"
59 && name != ".git"
60 && search(&path, depth - 1)
61 })
62 }
63 search(workspace, 2)
64}
65static UNIQUE: AtomicU64 = AtomicU64::new(0);
66
67#[derive(Debug)]
68pub enum WorkspaceError {
69 Io { path: PathBuf, source: io::Error },
70 UnsafePath(PathBuf),
71 UnsupportedEntry(PathBuf),
72 MissingLock,
73 Lifecycle(LifecycleError),
74 UnsupportedPlatform(&'static str),
75}
76
77impl std::fmt::Display for WorkspaceError {
78 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
81 Self::UnsafePath(path) => {
82 write!(formatter, "unsafe workspace path: {}", path.display())
83 }
84 Self::UnsupportedEntry(path) => {
85 write!(
86 formatter,
87 "unsupported filesystem entry in isolated project: {}",
88 path.display()
89 )
90 }
91 Self::MissingLock => write!(
92 formatter,
93 "isolated workspace preparation requires the active project lock"
94 ),
95 Self::Lifecycle(error) => write!(formatter, "{error}"),
96 Self::UnsupportedPlatform(reason) => {
97 write!(formatter, "unsupported workspace platform: {reason}")
98 }
99 }
100 }
101}
102
103impl std::error::Error for WorkspaceError {}
104
105impl From<LifecycleError> for WorkspaceError {
106 fn from(value: LifecycleError) -> Self {
107 Self::Lifecycle(value)
108 }
109}
110
111fn omit_escaping_link(path: &Path, target: &Path) {
116 eprintln!(
117 "[supercov] omitting symlink outside the isolated project: {} -> {}",
118 path.display(),
119 target.display()
120 );
121}
122
123pub(crate) fn canonicalize_simplified<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
131 fs::canonicalize(path).map(simplified)
132}
133
134#[cfg(windows)]
135pub(crate) fn simplified(path: PathBuf) -> PathBuf {
136 match path.to_str().and_then(strip_verbatim) {
137 Some(plain) => PathBuf::from(plain),
138 None => path,
139 }
140}
141
142#[cfg(not(windows))]
143pub(crate) fn simplified(path: PathBuf) -> PathBuf {
144 path
145}
146
147#[cfg_attr(not(windows), allow(dead_code))]
151pub(crate) fn strip_verbatim(path: &str) -> Option<String> {
152 let rest = path.strip_prefix(r"\\?\")?;
153 if let Some(unc) = rest.strip_prefix(r"UNC\") {
154 return Some(format!(r"\\{unc}"));
155 }
156 let mut chars = rest.chars();
157 let drive = chars.next()?;
158 if drive.is_ascii_alphabetic() && chars.next() == Some(':') {
159 return Some(rest.to_owned());
160 }
161 None
162}
163
164pub(crate) fn file_url(path: &Path) -> String {
173 let text = path.to_string_lossy();
174 #[cfg(windows)]
175 let text = text.replace('\\', "/");
176 file_url_from_slash_path(&text)
177}
178
179pub(crate) fn file_url_from_slash_path(slash_path: &str) -> String {
181 let mut url = String::from("file://");
182 let mut rest = slash_path;
183 if let Some(unc) = rest.strip_prefix("//") {
184 let (host, tail) = unc.split_once('/').unwrap_or((unc, ""));
186 url.push_str(host);
187 rest = tail;
188 url.push('/');
189 } else if !rest.starts_with('/') {
190 url.push('/');
192 }
193 for byte in rest.bytes() {
194 let keep =
195 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':');
196 if keep {
197 url.push(byte as char);
198 } else {
199 url.push_str(&format!("%{byte:02X}"));
200 }
201 }
202 url
203}
204
205fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
206 WorkspaceError::Io {
207 path: path.to_owned(),
208 source,
209 }
210}
211
212fn unique() -> String {
213 let nanos = SystemTime::now()
214 .duration_since(UNIX_EPOCH)
215 .unwrap_or_default()
216 .as_nanos();
217 format!(
218 "{}-{nanos}-{}",
219 std::process::id(),
220 UNIQUE.fetch_add(1, Ordering::Relaxed)
221 )
222}
223
224fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
225 root.file_name()
226 .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
227}
228
229fn enclosing_repository(root: &Path) -> Option<&Path> {
233 let repository = root
234 .ancestors()
235 .skip(1)
236 .find(|directory| directory.join(".git").exists())?;
237 let relative = root.strip_prefix(repository).ok()?;
238 if relative
239 .components()
240 .any(|component| component.as_os_str() == ".supercov")
241 {
242 return None;
243 }
244 Some(repository)
245}
246
247fn copy_layout(root: &Path) -> Result<PathBuf, WorkspaceError> {
251 let Some(repository) = enclosing_repository(root) else {
252 return Ok(PathBuf::from(project_name(root)?));
253 };
254 let name = repository
255 .file_name()
256 .ok_or_else(|| WorkspaceError::UnsafePath(repository.into()))?;
257 let relative = root
258 .strip_prefix(repository)
259 .map_err(|_| WorkspaceError::UnsafePath(root.into()))?;
260 Ok(Path::new(name).join(relative))
261}
262
263fn link_repository_around(root: &Path, workspace: &Path) {
270 let Some(repository) = enclosing_repository(root) else {
271 return;
272 };
273 let (mut real, mut copy) = (root, workspace);
274 while real != repository {
275 let (Some(real_parent), Some(copy_parent)) = (real.parent(), copy.parent()) else {
276 return;
277 };
278 let Ok(entries) = fs::read_dir(real_parent) else {
279 return;
280 };
281 for entry in entries.filter_map(Result::ok) {
282 let name = entry.file_name();
283 if Some(name.as_os_str()) == real.file_name()
288 || matches!(
289 name.to_str(),
290 Some(
291 ".git"
292 | ".supercov"
293 | "package-lock.json"
294 | "npm-shrinkwrap.json"
295 | "yarn.lock"
296 | "pnpm-lock.yaml"
297 | "bun.lock"
298 | "bun.lockb"
299 )
300 )
301 {
302 continue;
303 }
304 let destination = copy_parent.join(&name);
305 if fs::symlink_metadata(&destination).is_ok() {
306 continue;
307 }
308 let target = entry.path();
309 let directory = fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir());
310 if create_link(&target, &destination, directory).is_err() && !directory {
312 let _ = fs::copy(&target, &destination);
313 }
314 }
315 (real, copy) = (real_parent, copy_parent);
316 }
317}
318
319pub fn workspace_container(root: &Path) -> PathBuf {
320 let preferred = root.join(".supercov/workspaces");
325 if fs::symlink_metadata(&preferred).is_err() || owned_workspace_path(&preferred) {
326 return preferred;
327 }
328 let digest = format!("{:x}", Sha256::digest(root.as_os_str().as_encoded_bytes()));
329 for sequence in 0..1_024usize {
330 let name = if sequence == 0 {
331 format!(".supercov/workspaces-{}", &digest[..16])
332 } else {
333 format!(".supercov/workspaces-{}-{sequence}", &digest[..16])
334 };
335 let candidate = root.join(name);
336 if fs::symlink_metadata(&candidate).is_err() || owned_workspace_path(&candidate) {
337 return candidate;
338 }
339 }
340 root.join(format!(".supercov/workspaces-{}-overflow", &digest[..16]))
341}
342
343pub fn planted_cargo_configuration(root: &Path, workspace: &Path) -> Option<PathBuf> {
350 if !workspace.starts_with(root) {
351 return None;
352 }
353 let repository = enclosing_repository(root).and_then(|path| canonicalize_simplified(path).ok());
356 workspace
357 .ancestors()
358 .skip(1)
359 .take_while(|directory| *directory != root)
360 .map(|directory| directory.join(".cargo"))
361 .filter(|path| fs::symlink_metadata(path).is_ok())
362 .find(|path| {
363 repository.as_ref().is_none_or(|repository| {
364 !canonicalize_simplified(path).is_ok_and(|real| real.starts_with(repository))
365 })
366 })
367}
368
369fn copy_directory_name(copy: usize) -> String {
373 if copy <= 1 {
374 "workspace".into()
375 } else {
376 format!("workspace-{copy}")
377 }
378}
379
380pub(crate) fn copy_directories(container: &Path, copy: usize) -> [PathBuf; 2] {
383 let name = copy_directory_name(copy);
384 [
385 container.join(&name),
386 container.join(name.replacen("workspace", "source", 1)),
387 ]
388}
389
390pub fn cached_workspace_path(root: &Path, lock: &ProjectLock) -> Result<PathBuf, WorkspaceError> {
392 copy_path(root, lock.copy())
393}
394
395pub fn copy_path(root: &Path, copy: usize) -> Result<PathBuf, WorkspaceError> {
397 Ok(workspace_container(root)
398 .join(copy_directory_name(copy))
399 .join(copy_layout(root)?))
400}
401
402pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
403 if run_id.is_empty()
404 || run_id == "."
405 || run_id == ".."
406 || run_id
407 .chars()
408 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
409 {
410 return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
411 }
412 Ok(workspace_container(root)
413 .join("work")
414 .join(run_id)
415 .join(copy_layout(root)?))
416}
417
418pub(crate) fn owned_workspace_path(path: &Path) -> bool {
419 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
420 && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
421 .is_ok_and(|metadata| metadata.file_type().is_file())
422 && fs::read(path.join(WORKSPACE_MARKER))
423 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
424}
425
426fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
427 let container = workspace_container(root);
428 let mut created = false;
429 match fs::symlink_metadata(&container) {
430 Ok(metadata) if !metadata.file_type().is_dir() => {
431 return Err(WorkspaceError::UnsafePath(container));
432 }
433 Ok(_) => {}
434 Err(error) if error.kind() == io::ErrorKind::NotFound => {
435 let store = container.parent().expect("container parent");
436 fs::create_dir_all(store).map_err(|source| io_error(store, source))?;
437 let store_ignore = root.join(".supercov/.gitignore");
440 if fs::symlink_metadata(&store_ignore).is_err() {
441 let _ = fs::write(&store_ignore, b"*\n");
442 }
443 let made = store.join(format!(".workspaces-{}", unique()));
448 fs::create_dir(&made).map_err(|source| io_error(&made, source))?;
449 fs::write(made.join(WORKSPACE_MARKER), WORKSPACE_MARKER_CONTENTS)
450 .map_err(|source| io_error(&made, source))?;
451 match fs::rename(&made, &container) {
452 Ok(()) => created = true,
453 Err(_) => {
455 let _ = fs::remove_dir_all(&made);
456 }
457 }
458 }
459 Err(source) => return Err(io_error(&container, source)),
460 }
461 let result = (|| {
462 if !created && !owned_workspace_path(&container) {
463 return Err(WorkspaceError::UnsafePath(container.clone()));
464 }
465 for (name, contents) in [
466 (".gitignore", b"*\n".as_slice()),
467 (WORKSPACE_MARKER, WORKSPACE_MARKER_CONTENTS),
468 ] {
469 let path = container.join(name);
470 match OpenOptions::new().write(true).create_new(true).open(&path) {
471 Ok(mut file) => file
472 .write_all(contents)
473 .and_then(|_| file.sync_all())
474 .map_err(|source| io_error(&path, source))?,
475 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
476 if !fs::symlink_metadata(&path)
477 .is_ok_and(|metadata| metadata.file_type().is_file())
478 {
479 return Err(WorkspaceError::UnsafePath(path));
480 }
481 if name == WORKSPACE_MARKER
482 && !fs::read(&path)
483 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
484 {
485 return Err(WorkspaceError::UnsafePath(path));
486 }
487 }
488 Err(source) => return Err(io_error(&path, source)),
489 }
490 }
491 Ok(container.clone())
492 })();
493 if result.is_err() && created {
494 let _ = fs::remove_dir_all(&container);
495 }
496 result
497}
498
499fn lexical_normalize(path: &Path) -> Option<PathBuf> {
500 let mut normalized = PathBuf::new();
501 for component in path.components() {
502 match component {
503 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
504 Component::RootDir => normalized.push(component.as_os_str()),
505 Component::CurDir => {}
506 Component::ParentDir => {
507 if !normalized.pop() {
508 return None;
509 }
510 }
511 Component::Normal(value) => normalized.push(value),
512 }
513 }
514 Some(normalized)
515}
516
517fn inside(root: &Path, path: &Path) -> bool {
518 path == root
519 || path.strip_prefix(root).is_ok_and(|local| {
520 !local.as_os_str().is_empty()
521 && local
522 .components()
523 .all(|component| matches!(component, Component::Normal(_)))
524 })
525}
526
527trait WorkspaceOperations {
528 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
529 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
530}
531
532struct SystemWorkspaceOperations;
533
534impl WorkspaceOperations for SystemWorkspaceOperations {
535 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
536 const REFLINK_MINIMUM_BYTES: u64 = 1024 * 1024;
541 let bytes = fs::symlink_metadata(source)
542 .map_err(|source_error| io_error(source, source_error))?
543 .len();
544 if bytes < REFLINK_MINIMUM_BYTES {
545 fs::copy(source, destination)
546 .map(|_| ())
547 .map_err(|source_error| io_error(destination, source_error))
548 } else {
549 reflink_copy::reflink_or_copy(source, destination)
550 .map(|_| ())
551 .map_err(|source_error| io_error(destination, source_error))
552 }
553 }
554
555 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
556 atomic_rename(source, destination).map_err(WorkspaceError::from)
557 }
558}
559
560#[cfg(unix)]
561pub(crate) fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
562 std::os::unix::fs::symlink(target, destination)
563}
564
565#[cfg(windows)]
566pub(crate) fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
567 if directory {
568 match junction::create(target, destination) {
575 Ok(()) => Ok(()),
576 Err(junction_error) => {
577 let _ = fs::remove_dir(destination);
578 std::os::windows::fs::symlink_dir(target, destination).map_err(
579 |symlink_error| {
580 io::Error::new(
581 symlink_error.kind(),
582 format!(
583 "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
584 ),
585 )
586 },
587 )
588 }
589 }
590 } else {
591 std::os::windows::fs::symlink_file(target, destination)
595 }
596}
597
598#[cfg(target_os = "macos")]
604fn clone_directory(source: &Path, destination: &Path) -> bool {
605 use std::{ffi::CString, os::unix::ffi::OsStrExt};
606
607 let (Ok(source_c), Ok(destination_c)) = (
608 CString::new(source.as_os_str().as_bytes()),
609 CString::new(destination.as_os_str().as_bytes()),
610 ) else {
611 return false;
612 };
613 let status = unsafe { libc::clonefile(source_c.as_ptr(), destination_c.as_ptr(), 0) };
616 if status == 0 {
617 return true;
618 }
619 let _ = fs::remove_dir_all(destination);
620 false
621}
622
623#[cfg(all(unix, not(target_os = "macos")))]
624fn clone_directory(_source: &Path, _destination: &Path) -> bool {
625 false
626}
627
628#[cfg(unix)]
640fn hard_link_tree(source: &Path, destination: &Path) -> bool {
641 fn link(source: &Path, destination: &Path) -> io::Result<()> {
642 fs::create_dir(destination)?;
643 for entry in fs::read_dir(source)? {
644 let entry = entry?;
645 let from = entry.path();
646 let to = destination.join(entry.file_name());
647 let file_type = entry.file_type()?;
648 if file_type.is_dir() {
649 link(&from, &to)?;
650 } else if file_type.is_symlink() {
651 std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?;
652 } else if file_type.is_file() {
653 fs::hard_link(&from, &to)?;
654 } else {
655 return Err(io::Error::new(
656 io::ErrorKind::Unsupported,
657 "entry is neither a file, a directory nor a symlink",
658 ));
659 }
660 }
661 Ok(())
662 }
663 if link(source, destination).is_ok() {
664 return true;
665 }
666 let _ = fs::remove_dir_all(destination);
667 false
668}
669
670#[derive(Clone, Copy)]
671struct CopyRoots<'a> {
672 source: &'a Path,
673 destination: &'a Path,
674 final_destination: &'a Path,
675 canonical_source: &'a Path,
676}
677
678fn copy_tree<Operations: WorkspaceOperations>(
679 source: &Path,
680 destination: &Path,
681 roots: CopyRoots<'_>,
682 root_level: bool,
683 operations: &mut Operations,
684) -> Result<(), WorkspaceError> {
685 fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
686 let mut entries = fs::read_dir(source)
687 .map_err(|error| io_error(source, error))?
688 .collect::<Result<Vec<_>, _>>()
689 .map_err(|error| io_error(source, error))?;
690 entries.sort_by_key(fs::DirEntry::file_name);
691 for entry in entries {
692 let name = entry.file_name();
693 let name_text = name
694 .to_str()
695 .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
696 if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
697 || NESTED_EXCLUSIONS.contains(&name_text)
698 {
699 continue;
700 }
701 let from = entry.path();
702 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
703 if metadata.file_type().is_dir() && owned_workspace_path(&from) {
704 continue;
705 }
706 let to = destination.join(&name);
707 if metadata.file_type().is_dir() && from.join("pyvenv.cfg").is_file() {
712 create_link(&from, &to, true).map_err(|error| io_error(&to, error))?;
713 continue;
714 }
715 if metadata.file_type().is_dir() {
716 #[cfg(unix)]
739 if name_text == "node_modules" && !root_level {
740 if clone_directory(&from, &to) || hard_link_tree(&from, &to) {
741 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
744 .map_err(|error| io_error(&to, error))?;
745 continue;
746 }
747 fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
748 let mut packages = fs::read_dir(&from)
749 .map_err(|error| io_error(&from, error))?
750 .collect::<Result<Vec<_>, _>>()
751 .map_err(|error| io_error(&from, error))?;
752 packages.sort_by_key(fs::DirEntry::file_name);
753 for package in packages {
754 let link_destination = to.join(package.file_name());
755 if package.file_name() == ".bin" && package.path().is_dir() {
756 link_tools(&package.path(), &link_destination, operations)?;
757 continue;
758 }
759 create_link(&package.path(), &link_destination, false)
760 .map_err(|error| io_error(&link_destination, error))?;
761 }
762 continue;
763 }
764 copy_tree(&from, &to, roots, false, operations)?;
765 if name_text == "node_modules" && !root_level {
766 crate::source_tools::place_launchers(&from.join(".bin"), &to.join(".bin"))
767 .map_err(|error| io_error(&to, error))?;
768 }
769 } else if metadata.file_type().is_symlink() {
770 let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
771 let unresolved_target = if link.is_absolute() {
772 link.clone()
773 } else {
774 from.parent().expect("entry parent").join(&link)
775 };
776 let Some(lexical_target) = lexical_normalize(&unresolved_target) else {
777 omit_escaping_link(&from, &link);
778 continue;
779 };
780 let canonical_target = match canonicalize_simplified(&from) {
788 Ok(target) => Some(target),
789 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
790 Err(error) => return Err(io_error(&from, error)),
791 };
792 let escapes = !inside(roots.source, &lexical_target)
793 || canonical_target
794 .as_deref()
795 .is_some_and(|target| !inside(roots.canonical_source, target));
796 if escapes {
797 omit_escaping_link(&from, &link);
798 continue;
799 }
800 let local_target = lexical_target
801 .strip_prefix(roots.source)
802 .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
803 let relocated = roots.destination.join(local_target);
804 let Some(canonical_target) = canonical_target else {
805 if cfg!(windows) {
806 continue;
810 }
811 let isolated_link = if link.is_absolute() {
812 pathdiff(&to, &relocated)?
813 } else {
814 link
815 };
816 create_link(&isolated_link, &to, false).map_err(|error| io_error(&to, error))?;
817 continue;
818 };
819 let target_metadata = fs::metadata(&canonical_target)
820 .map_err(|error| io_error(&canonical_target, error))?;
821 let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
822 roots.final_destination.join(local_target)
823 } else if link.is_absolute() {
824 pathdiff(&to, &relocated)?
825 } else {
826 link
827 };
828 create_link(&isolated_link, &to, target_metadata.is_dir())
829 .map_err(|error| io_error(&to, error))?;
830 } else if metadata.file_type().is_file() {
831 operations.copy_file(&from, &to)?;
832 }
833 }
838 Ok(())
839}
840
841fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
842 let from = from
843 .parent()
844 .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
845 let from_components = from.components().collect::<Vec<_>>();
846 let to_components = to.components().collect::<Vec<_>>();
847 let common = from_components
848 .iter()
849 .zip(&to_components)
850 .take_while(|(left, right)| left == right)
851 .count();
852 if common == 0 {
853 return Err(WorkspaceError::UnsafePath(to.into()));
854 }
855 let mut relative = PathBuf::new();
856 for _ in common..from_components.len() {
857 relative.push("..");
858 }
859 for component in &to_components[common..] {
860 relative.push(component.as_os_str());
861 }
862 Ok(relative)
863}
864
865#[cfg(unix)]
875fn link_package(
876 root: &Path,
877 workspace: &Path,
878 target: &Path,
879 to: &Path,
880) -> Result<(), WorkspaceError> {
881 let metadata = fs::symlink_metadata(target).map_err(|error| io_error(target, error))?;
882 let scope = target
883 .file_name()
884 .and_then(|name| name.to_str())
885 .is_some_and(|name| name.starts_with('@'));
886 if scope && metadata.file_type().is_dir() {
887 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
888 let mut packages = fs::read_dir(target)
889 .map_err(|error| io_error(target, error))?
890 .collect::<Result<Vec<_>, _>>()
891 .map_err(|error| io_error(target, error))?;
892 packages.sort_by_key(fs::DirEntry::file_name);
893 for package in packages {
894 link_package(
895 root,
896 workspace,
897 &package.path(),
898 &to.join(package.file_name()),
899 )?;
900 }
901 return Ok(());
902 }
903 if metadata.file_type().is_symlink()
904 && let (Ok(resolved), Ok(canonical_root)) = (
905 canonicalize_simplified(target),
906 canonicalize_simplified(root),
907 )
908 && let Ok(local) = resolved.strip_prefix(&canonical_root)
909 && !local.as_os_str().is_empty()
910 && !local.components().any(|part| {
911 matches!(
912 part.as_os_str().to_str(),
913 Some("node_modules" | ".supercov")
914 )
915 })
916 {
917 let copy = workspace.join(local);
918 if fs::symlink_metadata(©).is_ok() {
919 return create_link(&pathdiff(to, ©)?, to, true)
920 .map_err(|error| io_error(to, error));
921 }
922 }
923 create_link(target, to, false).map_err(|error| io_error(to, error))
924}
925
926fn link_tools<Operations: WorkspaceOperations>(
932 tools: &Path,
933 to: &Path,
934 operations: &mut Operations,
935) -> Result<(), WorkspaceError> {
936 #[cfg(unix)]
937 let _ = &operations;
938 fs::create_dir_all(to).map_err(|error| io_error(to, error))?;
939 let judges = crate::source_tools::source_tools();
940 let binary = crate::source_tools::launching_binary();
941 let mut entries = fs::read_dir(tools)
942 .map_err(|error| io_error(tools, error))?
943 .collect::<Result<Vec<_>, _>>()
944 .map_err(|error| io_error(tools, error))?;
945 entries.sort_by_key(fs::DirEntry::file_name);
946 for entry in entries {
947 let (tool, link) = (entry.path(), to.join(entry.file_name()));
948 let launcher = binary.as_deref().and_then(|binary| {
949 crate::source_tools::launcher_for(&judges, binary, tools, &entry.file_name())
950 });
951 if let Some(launcher) = launcher {
952 crate::source_tools::write_launcher(&link, &launcher)
953 .map_err(|error| io_error(&link, error))?;
954 continue;
955 }
956 #[cfg(unix)]
960 create_link(&tool, &link, false).map_err(|error| io_error(&link, error))?;
961 #[cfg(windows)]
962 if fs::metadata(&tool).is_ok_and(|metadata| metadata.is_file()) {
963 operations.copy_file(&tool, &link)?;
964 }
965 }
966 Ok(())
967}
968
969fn link_node_modules<Operations: WorkspaceOperations>(
970 root: &Path,
971 workspace: &Path,
972 operations: &mut Operations,
973) -> Result<(), WorkspaceError> {
974 #[cfg(unix)]
975 let _ = &operations;
976 let source = root.join("node_modules");
977 let source_metadata = match fs::symlink_metadata(&source) {
978 Ok(metadata) => metadata,
979 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
980 Err(error) => return Err(io_error(&source, error)),
981 };
982 let source = if source_metadata.file_type().is_symlink() {
987 let resolved =
988 canonicalize_simplified(&source).map_err(|error| io_error(&source, error))?;
989 if !fs::symlink_metadata(&resolved)
990 .map_err(|error| io_error(&resolved, error))?
991 .file_type()
992 .is_dir()
993 {
994 return Err(WorkspaceError::UnsafePath(source));
995 }
996 resolved
997 } else if source_metadata.file_type().is_dir() {
998 source
999 } else {
1000 return Err(WorkspaceError::UnsafePath(source));
1001 };
1002 let destination = workspace.join("node_modules");
1003 fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
1004 let mut entries = fs::read_dir(&source)
1005 .map_err(|error| io_error(&source, error))?
1006 .collect::<Result<Vec<_>, _>>()
1007 .map_err(|error| io_error(&source, error))?;
1008 entries.sort_by_key(fs::DirEntry::file_name);
1009 for entry in entries {
1010 let target = entry.path();
1011 let to = destination.join(entry.file_name());
1012 if entry.file_name() == ".bin"
1013 && fs::metadata(&target).is_ok_and(|metadata| metadata.is_dir())
1014 {
1015 link_tools(&target, &to, operations)?;
1016 continue;
1017 }
1018 #[cfg(unix)]
1019 link_package(root, workspace, &target, &to)?;
1020 #[cfg(windows)]
1021 {
1022 let file_type = entry
1023 .file_type()
1024 .map_err(|error| io_error(&target, error))?;
1025 let resolved = if file_type.is_symlink() {
1026 canonicalize_simplified(&target).map_err(|error| io_error(&target, error))?
1027 } else {
1028 target.clone()
1029 };
1030 let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
1031 if metadata.is_dir() {
1032 create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
1033 } else if metadata.is_file() {
1034 operations.copy_file(&resolved, &to)?;
1037 } else {
1038 return Err(WorkspaceError::UnsupportedEntry(target));
1039 }
1040 }
1041 }
1042 Ok(())
1043}
1044
1045fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
1046 if lock.protects(root) {
1047 Ok(())
1048 } else {
1049 Err(WorkspaceError::MissingLock)
1050 }
1051}
1052
1053pub fn prepare_isolated_workspace(
1054 root: &Path,
1055 run_id: &str,
1056 lock: &ProjectLock,
1057) -> Result<PathBuf, WorkspaceError> {
1058 require_lock(root, lock)?;
1059 ensure_container(root)?;
1060 let mut operations = SystemWorkspaceOperations;
1061 let workspace = isolated_workspace_path(root, run_id)?;
1062 remove_stored_tree_deferred(root, &workspace)?;
1063 let canonical_root = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1064 copy_tree(
1065 root,
1066 &workspace,
1067 CopyRoots {
1068 source: root,
1069 destination: &workspace,
1070 final_destination: &workspace,
1071 canonical_source: &canonical_root,
1072 },
1073 true,
1074 &mut operations,
1075 )?;
1076 link_node_modules(root, &workspace, &mut operations)?;
1077 link_repository_around(root, &workspace);
1078 Ok(workspace)
1079}
1080
1081fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
1082 Ok(format!(
1083 ".{}.{}-",
1084 project_name(root)?.to_string_lossy(),
1085 kind
1086 ))
1087}
1088
1089fn transaction_path(
1090 root: &Path,
1091 lock: &ProjectLock,
1092 kind: &str,
1093) -> Result<PathBuf, WorkspaceError> {
1094 let workspace = cached_workspace_path(root, lock)?;
1095 Ok(workspace.parent().expect("workspace parent").join(format!(
1096 "{}{}",
1097 transaction_prefix(root, kind)?,
1098 unique()
1099 )))
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1103#[serde(rename_all = "camelCase")]
1104pub struct CacheRecoveryResult {
1105 pub restored_previous: bool,
1106 pub removed_staging: usize,
1107 pub removed_previous: usize,
1108}
1109
1110pub fn recover_cached_workspace(
1111 root: &Path,
1112 lock: &ProjectLock,
1113) -> Result<CacheRecoveryResult, WorkspaceError> {
1114 require_lock(root, lock)?;
1115 let workspace = cached_workspace_path(root, lock)?;
1116 let parent = workspace.parent().expect("workspace parent");
1117 let entries = match fs::read_dir(parent) {
1118 Ok(entries) => entries,
1119 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1120 return Ok(CacheRecoveryResult {
1121 restored_previous: false,
1122 removed_staging: 0,
1123 removed_previous: 0,
1124 });
1125 }
1126 Err(error) => return Err(io_error(parent, error)),
1127 };
1128 let staging_prefix = transaction_prefix(root, "staging")?;
1129 let previous_prefix = transaction_prefix(root, "previous")?;
1130 let mut staging = Vec::new();
1131 let mut previous = Vec::new();
1132 let mut invalid_previous = Vec::new();
1133 for entry in entries {
1134 let entry = entry.map_err(|error| io_error(parent, error))?;
1135 let name = entry.file_name().to_string_lossy().into_owned();
1136 if name.starts_with(&staging_prefix) {
1137 staging.push(entry.path());
1138 } else if name.starts_with(&previous_prefix) {
1139 let metadata = entry
1140 .file_type()
1141 .map_err(|error| io_error(&entry.path(), error))?;
1142 if metadata.is_dir() {
1143 let modified = entry
1144 .metadata()
1145 .and_then(|metadata| metadata.modified())
1146 .unwrap_or(UNIX_EPOCH);
1147 previous.push((modified, entry.path()));
1148 } else {
1149 invalid_previous.push(entry.path());
1150 }
1151 }
1152 }
1153 previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
1154 let mut restored = false;
1155 if fs::symlink_metadata(&workspace).is_err()
1156 && let Some((_, newest)) = previous.first()
1157 {
1158 atomic_rename(newest, &workspace)?;
1159 previous.remove(0);
1160 restored = true;
1161 }
1162 let removed_staging = staging.len();
1163 let removed_previous = previous.len() + invalid_previous.len();
1164 for path in staging
1165 .into_iter()
1166 .chain(previous.into_iter().map(|(_, path)| path))
1167 .chain(invalid_previous)
1168 {
1169 remove_stored_tree_deferred(root, &path)?;
1170 }
1171 Ok(CacheRecoveryResult {
1172 restored_previous: restored,
1173 removed_staging,
1174 removed_previous,
1175 })
1176}
1177
1178fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
1179 if requested.is_absolute()
1180 || requested
1181 .components()
1182 .any(|component| !matches!(component, Component::Normal(_)))
1183 {
1184 return Err(WorkspaceError::UnsafePath(requested.into()));
1185 }
1186 let path = workspace.join(requested);
1187 if path == workspace || fs::symlink_metadata(&path).is_err() {
1188 return Err(WorkspaceError::UnsafePath(requested.into()));
1189 }
1190 Ok(path)
1191}
1192
1193pub fn prepare_cached_workspace(
1194 root: &Path,
1195 lock: &ProjectLock,
1196 reuse_paths: &[PathBuf],
1197) -> Result<PathBuf, WorkspaceError> {
1198 let mut operations = SystemWorkspaceOperations;
1199 prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
1200}
1201
1202fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1203 root: &Path,
1204 lock: &ProjectLock,
1205 reuse_paths: &[PathBuf],
1206 operations: &mut Operations,
1207) -> Result<PathBuf, WorkspaceError> {
1208 require_lock(root, lock)?;
1209 ensure_container(root)?;
1210 recover_cached_workspace(root, lock)?;
1211 let workspace = cached_workspace_path(root, lock)?;
1212 let staging = transaction_path(root, lock, "staging")?;
1213 let previous = transaction_path(root, lock, "previous")?;
1214 let result = (|| {
1215 let canonical_root =
1216 canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1217 copy_tree(
1218 root,
1219 &staging,
1220 CopyRoots {
1221 source: root,
1222 destination: &staging,
1223 final_destination: &workspace,
1224 canonical_source: &canonical_root,
1225 },
1226 true,
1227 operations,
1228 )?;
1229 link_node_modules(root, &staging, operations)?;
1230 for requested in reuse_paths {
1231 let from = checked_reuse_path(&workspace, requested)?;
1232 let to = staging.join(requested);
1233 match fs::symlink_metadata(&to) {
1238 Ok(existing) if existing.file_type().is_dir() => {
1239 fs::remove_dir_all(&to).map_err(|error| io_error(&to, error))?;
1240 }
1241 Ok(_) => {
1242 fs::remove_file(&to).map_err(|error| io_error(&to, error))?;
1243 }
1244 Err(_) => {}
1245 }
1246 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
1247 if metadata.file_type().is_dir() {
1248 let canonical_workspace = canonicalize_simplified(&workspace)
1249 .map_err(|error| io_error(&workspace, error))?;
1250 copy_tree(
1251 &from,
1252 &to,
1253 CopyRoots {
1254 source: &workspace,
1255 destination: &staging,
1256 final_destination: &workspace,
1257 canonical_source: &canonical_workspace,
1258 },
1259 false,
1260 operations,
1261 )?;
1262 } else if metadata.file_type().is_file() {
1263 fs::create_dir_all(to.parent().expect("reuse parent"))
1264 .map_err(|error| io_error(&to, error))?;
1265 operations.copy_file(&from, &to)?;
1266 } else {
1267 return Err(WorkspaceError::UnsupportedEntry(from));
1268 }
1269 }
1270 let mut moved_previous = false;
1271 if fs::symlink_metadata(&workspace).is_ok() {
1272 operations.rename(&workspace, &previous)?;
1273 moved_previous = true;
1274 }
1275 if let Err(error) = operations.rename(&staging, &workspace) {
1276 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1277 let _ = operations.rename(&previous, &workspace);
1278 }
1279 return Err(error);
1280 }
1281 if fs::symlink_metadata(&previous).is_ok() {
1282 remove_stored_tree_deferred(root, &previous)?;
1283 }
1284 link_repository_around(root, &workspace);
1285 Ok(workspace.clone())
1286 })();
1287 if fs::symlink_metadata(&staging).is_ok() {
1288 remove_stored_tree_deferred(root, &staging)?;
1289 }
1290 result
1291}
1292
1293pub struct WorkspaceOutputBaseline {
1297 entries: BTreeMap<PathBuf, (u64, SystemTime)>,
1298}
1299
1300#[derive(Debug, Default, PartialEq, Eq)]
1302pub struct CommandOutputSync {
1303 pub synced: usize,
1304 pub synced_paths: Vec<PathBuf>,
1306 pub skipped_instrumented: Vec<PathBuf>,
1310 pub deleted_in_workspace: Vec<PathBuf>,
1313}
1314
1315fn walk_output_files(
1316 workspace: &Path,
1317 directory: &Path,
1318 entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1319) -> Result<(), WorkspaceError> {
1320 for entry in fs::read_dir(directory)
1321 .map_err(|error| io_error(directory, error))?
1322 .collect::<Result<Vec<_>, _>>()
1323 .map_err(|error| io_error(directory, error))?
1324 {
1325 let path = entry.path();
1326 let name = entry.file_name();
1327 let relative = path
1328 .strip_prefix(workspace)
1329 .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1330 .to_owned();
1331 if relative.components().count() == 1
1335 && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1336 {
1337 continue;
1338 }
1339 let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1340 if metadata.is_dir() && name.to_str() == Some("node_modules") {
1344 continue;
1345 }
1346 if fs::symlink_metadata(&path)
1347 .map_err(|error| io_error(&path, error))?
1348 .file_type()
1349 .is_symlink()
1350 {
1351 continue;
1352 }
1353 if metadata.is_dir() {
1354 walk_output_files(workspace, &path, entries)?;
1355 } else if metadata.is_file() {
1356 let modified = metadata
1357 .modified()
1358 .map_err(|error| io_error(&path, error))?;
1359 entries.insert(relative, (metadata.len(), modified));
1360 }
1361 }
1362 Ok(())
1363}
1364
1365pub fn workspace_output_baseline(
1366 workspace: &Path,
1367) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1368 let mut entries = BTreeMap::new();
1369 walk_output_files(workspace, workspace, &mut entries)?;
1370 Ok(WorkspaceOutputBaseline { entries })
1371}
1372
1373fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1376 if relative.as_os_str().is_empty()
1377 || relative
1378 .components()
1379 .any(|component| !matches!(component, Component::Normal(_)))
1380 {
1381 return Err(WorkspaceError::UnsafePath(relative.into()));
1382 }
1383 let mut current = root.to_owned();
1384 for component in relative.components() {
1385 current.push(component.as_os_str());
1386 match fs::symlink_metadata(¤t) {
1387 Ok(metadata) if metadata.file_type().is_symlink() => {
1388 return Err(WorkspaceError::UnsafePath(current));
1389 }
1390 Ok(_) => {}
1391 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1392 Err(error) => return Err(io_error(¤t, error)),
1393 }
1394 }
1395 Ok(())
1396}
1397
1398pub fn summarize_paths(paths: &[PathBuf]) -> String {
1403 const SHOWN: usize = 4;
1404 let mut places = BTreeMap::<String, usize>::new();
1405 for path in paths {
1406 let mut components = path.components();
1407 let first = components
1408 .next()
1409 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1410 .unwrap_or_default();
1411 let place = if components.next().is_some() {
1412 format!("{first}/")
1413 } else {
1414 first
1415 };
1416 *places.entry(place).or_default() += 1;
1417 }
1418 let mut places = places.into_iter().collect::<Vec<_>>();
1419 places.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1420 let mut shown = places
1421 .iter()
1422 .take(SHOWN)
1423 .map(|(place, count)| {
1424 if place.ends_with('/') {
1425 format!("{place} {count}")
1426 } else {
1427 place.clone()
1428 }
1429 })
1430 .collect::<Vec<_>>();
1431 if places.len() > SHOWN {
1432 let rest = places[SHOWN..]
1433 .iter()
1434 .map(|(_, count)| count)
1435 .sum::<usize>();
1436 shown.push(format!("{rest} elsewhere"));
1437 }
1438 shown.join(", ")
1439}
1440
1441struct BuiltDirectories<'a> {
1448 root: &'a Path,
1449 judged: BTreeMap<PathBuf, bool>,
1450 directories: BTreeSet<PathBuf>,
1451}
1452
1453impl<'a> BuiltDirectories<'a> {
1454 fn new(root: &'a Path) -> Self {
1455 Self {
1456 root,
1457 judged: BTreeMap::new(),
1458 directories: BTreeSet::new(),
1459 }
1460 }
1461
1462 fn add(&mut self, relative: &Path) {
1463 let mut directory = PathBuf::new();
1464 let Some(parent) = relative.parent() else {
1465 return;
1466 };
1467 for component in parent.components() {
1468 directory.push(component);
1469 let root = self.root;
1470 let output = *self
1471 .judged
1472 .entry(directory.clone())
1473 .or_insert_with(|| is_build_output(root, &directory));
1474 if output {
1475 self.directories.insert(directory);
1476 return;
1477 }
1478 }
1479 }
1480
1481 fn holds(&self, relative: &Path) -> bool {
1482 relative
1483 .ancestors()
1484 .skip(1)
1485 .any(|directory| self.directories.contains(directory))
1486 }
1487}
1488
1489struct CargoTargets<'a> {
1498 workspace: &'a Path,
1499 judged: BTreeMap<PathBuf, bool>,
1500}
1501
1502impl<'a> CargoTargets<'a> {
1503 fn new(workspace: &'a Path) -> Self {
1504 Self {
1505 workspace,
1506 judged: BTreeMap::new(),
1507 }
1508 }
1509
1510 fn hold(&mut self, relative: &Path) -> bool {
1511 let mut directory = PathBuf::new();
1512 let Some(parent) = relative.parent() else {
1513 return false;
1514 };
1515 for component in parent.components() {
1516 directory.push(component);
1517 let workspace = self.workspace;
1518 let target = *self.judged.entry(directory.clone()).or_insert_with(|| {
1519 let path = workspace.join(&directory);
1520 path.join(".rustc_info.json").is_file()
1521 || path.join(".cargo-lock").is_file()
1522 || fs::read(path.join("CACHEDIR.TAG"))
1523 .is_ok_and(|tag| tag.windows(5).any(|window| window == b"cargo"))
1524 });
1525 if target {
1526 return true;
1527 }
1528 }
1529 false
1530 }
1531}
1532
1533fn compiled_javascript(path: &Path) -> bool {
1535 path.extension()
1536 .and_then(|extension| extension.to_str())
1537 .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "map"))
1538}
1539
1540fn is_build_output(root: &Path, directory: &Path) -> bool {
1541 if fs::symlink_metadata(root.join(directory)).is_err() {
1542 return true;
1543 }
1544 std::process::Command::new("git")
1545 .arg("-C")
1546 .arg(root)
1547 .args(["check-ignore", "-q", "--"])
1548 .arg(directory)
1549 .env("GIT_TERMINAL_PROMPT", "0")
1550 .stdin(std::process::Stdio::null())
1551 .stdout(std::process::Stdio::null())
1552 .stderr(std::process::Stdio::null())
1553 .status()
1554 .is_ok_and(|status| status.code() == Some(0))
1555}
1556
1557const NAMED_FILE_LIMIT: u64 = 16 << 20;
1559
1560fn copy_and_project(root: &Path, workspace: &Path) -> Vec<(String, String)> {
1564 let mut places = Vec::new();
1565 let mut add = |copy: &Path, project: &Path| {
1566 let (copy, project) = (copy.to_string_lossy(), project.to_string_lossy());
1567 for (from, to) in [("\\", "\\"), ("\\", "\\\\"), ("\\", "/")] {
1568 let pair = (copy.replace(from, to), project.replace(from, to));
1569 if !pair.0.is_empty() && pair.0 != pair.1 && !places.contains(&pair) {
1570 places.push(pair);
1571 }
1572 }
1573 };
1574 add(workspace, root);
1575 if let (Ok(copy), Ok(project)) = (
1576 canonicalize_simplified(workspace),
1577 canonicalize_simplified(root),
1578 ) {
1579 add(©, &project);
1580 }
1581 places.sort_by_key(|(copy, _)| std::cmp::Reverse(copy.len()));
1583 places
1584}
1585
1586fn naming_the_project(
1596 file: &Path,
1597 places: &[(String, String)],
1598) -> Result<Option<String>, WorkspaceError> {
1599 let metadata = fs::metadata(file).map_err(|error| io_error(file, error))?;
1600 if !metadata.is_file() || metadata.len() > NAMED_FILE_LIMIT {
1601 return Ok(None);
1602 }
1603 let bytes = fs::read(file).map_err(|error| io_error(file, error))?;
1604 if bytes.contains(&0) {
1605 return Ok(None);
1606 }
1607 let Ok(text) = String::from_utf8(bytes) else {
1608 return Ok(None);
1609 };
1610 Ok(project_named(&text, places))
1611}
1612
1613pub fn console_names(root: &Path, workspace: &Path) -> crate::process_supervision::ConsoleNames {
1619 let up = workspace
1620 .strip_prefix(root)
1621 .ok()
1622 .map(|inside| inside.components().count())
1623 .filter(|levels| *levels > 0)
1624 .map(|levels| "../".repeat(levels));
1625 crate::process_supervision::ConsoleNames {
1626 places: copy_and_project(root, workspace),
1627 up_to_the_project: up,
1628 }
1629}
1630
1631pub(crate) fn project_named(text: &str, places: &[(String, String)]) -> Option<String> {
1634 if !places.iter().any(|(copy, _)| text.contains(copy.as_str())) {
1635 return None;
1636 }
1637 let mut out = String::with_capacity(text.len());
1638 let mut rest = text;
1639 while let Some((at, copy, project)) = places
1642 .iter()
1643 .filter_map(|(copy, project)| Some((rest.find(copy.as_str())?, copy, project)))
1644 .min_by_key(|(at, _, _)| *at)
1645 {
1646 out.push_str(&rest[..at]);
1647 let after = &rest[at + copy.len()..];
1648 let continues = after
1651 .chars()
1652 .next()
1653 .is_some_and(|next| next.is_alphanumeric() || matches!(next, '-' | '_'));
1654 if continues {
1655 let first = rest[at..].chars().next().expect("a match is not empty");
1656 out.push(first);
1657 rest = &rest[at + first.len_utf8()..];
1658 } else {
1659 out.push_str(project);
1660 rest = after;
1661 }
1662 }
1663 out.push_str(rest);
1664 (out != text).then_some(out)
1665}
1666
1667pub fn sync_command_outputs(
1674 root: &Path,
1675 workspace: &Path,
1676 baseline: &WorkspaceOutputBaseline,
1677 protected: &BTreeSet<PathBuf>,
1678) -> Result<CommandOutputSync, WorkspaceError> {
1679 sync_outputs_of(
1680 CommandOutputs::JavaScript,
1681 root,
1682 workspace,
1683 baseline,
1684 protected,
1685 )
1686}
1687
1688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1697pub enum CommandOutputs {
1698 JavaScript,
1699 Go,
1700 Jvm,
1701 Rust,
1702}
1703
1704fn links_the_go_runtime(path: &Path) -> Result<bool, WorkspaceError> {
1707 const MARKER: &[u8] = b"_supercov_runtime";
1708 let bytes = fs::read(path).map_err(|error| io_error(path, error))?;
1709 Ok(bytes.contains(&0) && bytes.windows(MARKER.len()).any(|window| window == MARKER))
1710}
1711
1712const JVM_REPORTS: &[&str] = &[
1714 "surefire-reports",
1715 "failsafe-reports",
1716 "test-results",
1717 "reports",
1718 "site",
1719];
1720
1721fn jvm_build_state(workspace: &Path, relative: &Path) -> bool {
1727 let parts = relative
1728 .components()
1729 .map(|part| part.as_os_str().to_string_lossy().into_owned())
1730 .collect::<Vec<_>>();
1731 if parts.first().is_some_and(|first| first == ".gradle") {
1732 return true;
1733 }
1734 if parts.last().is_some_and(|name| {
1736 matches!(
1737 name.as_str(),
1738 "pom.xml"
1739 | "build.gradle"
1740 | "build.gradle.kts"
1741 | "settings.gradle"
1742 | "settings.gradle.kts"
1743 )
1744 }) {
1745 return true;
1746 }
1747 if relative
1748 .extension()
1749 .is_some_and(|extension| extension == "class" || extension == "jar")
1750 {
1751 return true;
1752 }
1753 let mut module = workspace.to_path_buf();
1754 for (index, part) in parts.iter().enumerate() {
1755 let output = match part.as_str() {
1756 "target" => module.join("pom.xml").is_file(),
1757 "build" => [
1758 "build.gradle",
1759 "build.gradle.kts",
1760 "settings.gradle",
1761 "settings.gradle.kts",
1762 ]
1763 .iter()
1764 .any(|file| module.join(file).is_file()),
1765 _ => false,
1766 };
1767 if output {
1768 return !parts[index + 1..]
1769 .iter()
1770 .any(|inside| JVM_REPORTS.contains(&inside.as_str()));
1771 }
1772 module.push(part);
1773 }
1774 false
1775}
1776
1777pub fn sync_outputs_of(
1780 kind: CommandOutputs,
1781 root: &Path,
1782 workspace: &Path,
1783 baseline: &WorkspaceOutputBaseline,
1784 protected: &BTreeSet<PathBuf>,
1785) -> Result<CommandOutputSync, WorkspaceError> {
1786 let mut current = BTreeMap::new();
1787 walk_output_files(workspace, workspace, &mut current)?;
1788 let mut sync = CommandOutputSync::default();
1789 let places = copy_and_project(root, workspace);
1801 let mut changed = Vec::new();
1802 let mut built = BTreeSet::new();
1803 let mut built_directories = BuiltDirectories::new(root);
1804 let mut cargo_targets = CargoTargets::new(workspace);
1805 for (relative, state) in ¤t {
1806 if baseline.entries.get(relative) == Some(state) {
1807 continue;
1808 }
1809 if !protected.contains(relative) {
1810 match kind {
1811 CommandOutputs::JavaScript => {
1812 let compiled = compiled_javascript(relative);
1817 if built_from_instrumented_source(&workspace.join(relative), compiled)? {
1818 built.insert(relative.clone());
1819 if compiled {
1820 built_directories.add(relative);
1821 }
1822 }
1823 }
1824 CommandOutputs::Go => {
1825 if links_the_go_runtime(&workspace.join(relative))? {
1826 built.insert(relative.clone());
1827 }
1828 }
1829 CommandOutputs::Jvm => {
1830 if jvm_build_state(workspace, relative) {
1831 built.insert(relative.clone());
1832 }
1833 }
1834 CommandOutputs::Rust => {
1842 if relative
1843 .extension()
1844 .is_some_and(|extension| extension == "rs")
1845 || relative
1846 .file_name()
1847 .is_some_and(|name| name == "Cargo.lock")
1848 || cargo_targets.hold(relative)
1849 {
1850 built.insert(relative.clone());
1851 }
1852 }
1853 }
1854 }
1855 changed.push(relative);
1856 }
1857 for relative in changed {
1858 if protected.contains(relative)
1859 || built.contains(relative)
1860 || built_directories.holds(relative)
1861 {
1862 sync.skipped_instrumented.push(relative.clone());
1863 continue;
1864 }
1865 let from = workspace.join(relative);
1866 if unaccompanied_install_manifest(root, workspace, relative) {
1867 continue;
1868 }
1869 validate_writeback_destination(root, relative)?;
1870 let to = root.join(relative);
1871 if let Some(parent) = to.parent() {
1872 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1873 }
1874 match naming_the_project(&from, &places)? {
1875 Some(text) => {
1876 fs::write(&to, text).map_err(|error| io_error(&to, error))?;
1877 if let Ok(metadata) = fs::metadata(&from) {
1878 let _ = fs::set_permissions(&to, metadata.permissions());
1879 }
1880 }
1881 None => {
1882 fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1883 }
1884 }
1885 sync.synced += 1;
1886 sync.synced_paths.push(relative.clone());
1887 }
1888 for relative in baseline.entries.keys() {
1889 if !current.contains_key(relative) && fs::symlink_metadata(root.join(relative)).is_ok() {
1892 sync.deleted_in_workspace.push(relative.clone());
1893 }
1894 }
1895 Ok(sync)
1896}
1897
1898pub fn describe_command_outputs(outputs: &CommandOutputSync) -> Vec<String> {
1900 let mut lines = Vec::new();
1901 if outputs.synced > 0 {
1902 lines.push(format!(
1903 "[supercov] synced {} file(s) the command created or changed back to the project: {}",
1904 outputs.synced,
1905 summarize_paths(&outputs.synced_paths)
1906 ));
1907 }
1908 if !outputs.skipped_instrumented.is_empty() {
1909 lines.push(format!(
1910 "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
1911 outputs.skipped_instrumented.len(),
1912 summarize_paths(&outputs.skipped_instrumented)
1913 ));
1914 }
1915 if !outputs.deleted_in_workspace.is_empty() {
1916 lines.push(format!(
1917 "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project: {}",
1918 outputs.deleted_in_workspace.len(),
1919 summarize_paths(&outputs.deleted_in_workspace)
1920 ));
1921 }
1922 lines
1923}
1924
1925pub fn mirror_git_repository(root: &Path, workspace: &Path) -> Result<(), String> {
1936 if fs::symlink_metadata(root.join(".git")).is_err() {
1937 return Ok(());
1938 }
1939 let target = workspace.join(".git");
1940 let git = |directory: &Path, arguments: &[&str]| -> Result<String, String> {
1941 let output = std::process::Command::new("git")
1942 .arg("-C")
1943 .arg(directory)
1944 .args(arguments)
1945 .env("GIT_TERMINAL_PROMPT", "0")
1946 .stdin(std::process::Stdio::null())
1947 .output()
1948 .map_err(|error| format!("git {}: {error}", arguments.join(" ")))?;
1949 if output.status.success() {
1950 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1951 } else {
1952 Err(format!(
1953 "git {}: {}",
1954 arguments.join(" "),
1955 String::from_utf8_lossy(&output.stderr).trim()
1956 ))
1957 }
1958 };
1959 let result = (|| {
1960 if fs::symlink_metadata(&target).is_ok() {
1961 fs::remove_dir_all(&target).map_err(|error| error.to_string())?;
1962 }
1963 let source = root.to_string_lossy();
1964 let destination = target.to_string_lossy();
1965 git(
1966 workspace,
1967 &[
1968 "clone",
1969 "--quiet",
1970 "--shared",
1971 "--bare",
1972 &source,
1973 &destination,
1974 ],
1975 )?;
1976 git(workspace, &["config", "core.bare", "false"])?;
1977 match git(root, &["symbolic-ref", "-q", "HEAD"]) {
1978 Ok(branch) => git(workspace, &["symbolic-ref", "HEAD", &branch])?,
1979 Err(_) => {
1980 let commit = git(root, &["rev-parse", "HEAD"])?;
1981 git(workspace, &["update-ref", "--no-deref", "HEAD", &commit])?
1982 }
1983 };
1984 git(workspace, &["remote", "remove", "origin"])?;
1985 let remotes = git(root, &["remote"])?;
1986 for name in remotes.lines().filter(|name| !name.is_empty()) {
1987 let url = git(root, &["config", "--get", &format!("remote.{name}.url")])?;
1990 git(workspace, &["remote", "add", name, &url])?;
1991 }
1992 git(
1993 workspace,
1994 &[
1995 "fetch",
1996 "--quiet",
1997 "--no-tags",
1998 &source,
1999 "+refs/remotes/*:refs/remotes/*",
2000 ],
2001 )?;
2002 git(workspace, &["read-tree", "HEAD"])?;
2003 let exclude = target.join("info/exclude");
2005 fs::create_dir_all(target.join("info")).map_err(|error| error.to_string())?;
2006 let mut patterns = fs::read_to_string(&exclude).unwrap_or_default();
2007 patterns.push_str("\n.supercov/\n");
2008 fs::write(&exclude, patterns).map_err(|error| error.to_string())?;
2009 Ok(())
2010 })();
2011 if result.is_err() {
2012 let _ = fs::remove_dir_all(&target);
2013 }
2014 result
2015}
2016
2017pub fn hide_rewritten_files(workspace: &Path, files: &[String]) {
2021 if files.is_empty() || !workspace.join(".git").is_dir() {
2022 return;
2023 }
2024 let run = |arguments: &[&str], input: &str| {
2025 let mut child = std::process::Command::new("git")
2026 .arg("-C")
2027 .arg(workspace)
2028 .args(arguments)
2029 .stdin(std::process::Stdio::piped())
2030 .stdout(std::process::Stdio::piped())
2031 .stderr(std::process::Stdio::null())
2032 .spawn()
2033 .ok()?;
2034 child.stdin.take()?.write_all(input.as_bytes()).ok()?;
2035 let output = child.wait_with_output().ok()?;
2036 output
2037 .status
2038 .success()
2039 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
2040 };
2041 let Some(listed) = run(&["ls-files", "-z"], "") else {
2043 return;
2044 };
2045 let tracked = listed.split('\0').collect::<BTreeSet<_>>();
2046 let hidden = files
2047 .iter()
2048 .filter(|file| tracked.contains(file.as_str()))
2049 .map(String::as_str)
2050 .collect::<Vec<_>>();
2051 if !hidden.is_empty() {
2052 let _ = run(
2053 &["update-index", "--skip-worktree", "--stdin"],
2054 &hidden.join("\n"),
2055 );
2056 }
2057}
2058
2059fn unaccompanied_install_manifest(root: &Path, workspace: &Path, relative: &Path) -> bool {
2066 let manifest = relative
2067 .file_name()
2068 .and_then(|name| name.to_str())
2069 .is_some_and(|name| {
2070 matches!(
2071 name,
2072 "package.json"
2073 | "package-lock.json"
2074 | "npm-shrinkwrap.json"
2075 | "yarn.lock"
2076 | "pnpm-lock.yaml"
2077 | "bun.lock"
2078 | "bun.lockb"
2079 )
2080 });
2081 let Some(directory) = relative.parent() else {
2082 return false;
2083 };
2084 manifest
2085 && workspace.join(directory).join("node_modules").is_dir()
2086 && !root.join(directory).join("node_modules").exists()
2087}
2088
2089fn built_from_instrumented_source(path: &Path, compiled: bool) -> Result<bool, WorkspaceError> {
2101 const RUNTIME: &[u8] = b".supercov/node_modules/";
2102 const PROBE: &[u8] = b"__supercov";
2103 const GLOBAL: &[u8] = b"__SUPERCOV_DIRECT_RUNTIME__";
2104 let contents = match fs::read(path) {
2105 Ok(contents) => contents,
2106 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2107 Err(error) => return Err(io_error(path, error)),
2108 };
2109 let holds = |marker: &[u8]| {
2110 contents
2111 .windows(marker.len())
2112 .any(|window| window == marker)
2113 };
2114 Ok(holds(RUNTIME) || (compiled && (holds(PROBE) || holds(GLOBAL))))
2115}
2116
2117pub fn prune_cached_workspace_sources(
2118 root: &Path,
2119 lock: &ProjectLock,
2120) -> Result<Vec<String>, WorkspaceError> {
2121 require_lock(root, lock)?;
2122 let workspace = cached_workspace_path(root, lock)?;
2123 if fs::symlink_metadata(&workspace).is_err() {
2124 return Ok(Vec::new());
2125 }
2126 let keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
2127 let mut removed = Vec::new();
2128 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
2129 let entry = entry.map_err(|error| io_error(&workspace, error))?;
2130 let name = entry
2131 .file_name()
2132 .into_string()
2133 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
2134 if keep.contains(&name) {
2135 continue;
2136 }
2137 remove_stored_tree_deferred(root, &entry.path())?;
2138 removed.push(name);
2139 }
2140 removed.sort();
2141 Ok(removed)
2142}
2143
2144#[cfg(test)]
2145mod tests {
2146 #[test]
2147 fn file_urls_match_what_node_derives_for_every_path_shape() {
2148 use super::file_url_from_slash_path;
2149 assert_eq!(
2150 file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
2151 "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
2152 );
2153 assert_eq!(
2154 file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
2155 "file:///C:/Users/a%20b/%231/register.mjs"
2156 );
2157 assert_eq!(
2158 file_url_from_slash_path("//server/share/dir/register.mjs"),
2159 "file://server/share/dir/register.mjs"
2160 );
2161 assert_eq!(
2162 file_url_from_slash_path("/w/p/register.mjs"),
2163 "file:///w/p/register.mjs"
2164 );
2165 assert_eq!(
2166 file_url_from_slash_path("/w/p x?q/ü.mjs"),
2167 "file:///w/p%20x%3Fq/%C3%BC.mjs"
2168 );
2169 }
2170
2171 #[test]
2172 fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
2173 use super::strip_verbatim;
2174 assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
2175 assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
2176 assert_eq!(
2177 strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
2178 Some(r"\\server\share\dir")
2179 );
2180 assert_eq!(strip_verbatim(r"C:\a\b"), None);
2181 assert_eq!(strip_verbatim("/w/project"), None);
2182 assert_eq!(strip_verbatim(r"\\server\share"), None);
2183 assert_eq!(strip_verbatim(r"\\?\"), None);
2184 }
2185
2186 use super::*;
2187
2188 fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
2189 let base =
2190 std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
2191 if base.exists() {
2192 fs::remove_dir_all(&base).unwrap();
2193 }
2194 let root = base.join("project");
2195 let workspace = base.join("workspace");
2196 fs::create_dir_all(root.join("src")).unwrap();
2197 fs::create_dir_all(workspace.join("src")).unwrap();
2198 fs::create_dir_all(workspace.join(".supercov")).unwrap();
2199 fs::write(root.join("src/app.ts"), "original\n").unwrap();
2200 fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
2201 fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
2202 (root, workspace)
2203 }
2204
2205 #[test]
2206 fn the_workspace_has_a_repository_of_its_own_that_reads_like_the_project() {
2207 let (root, workspace) = writeback_fixture("git-mirror");
2208 let git = |directory: &Path, arguments: &[&str]| {
2209 let output = std::process::Command::new("git")
2210 .arg("-C")
2211 .arg(directory)
2212 .args(arguments)
2213 .output()
2214 .unwrap();
2215 assert!(output.status.success(), "git {arguments:?}: {output:?}");
2216 String::from_utf8(output.stdout).unwrap().trim().to_owned()
2217 };
2218 git(&root, &["init", "--quiet", "--initial-branch=release/v2"]);
2219 git(&root, &["config", "user.email", "test@example.com"]);
2220 git(&root, &["config", "user.name", "Test"]);
2221 git(
2222 &root,
2223 &[
2224 "remote",
2225 "add",
2226 "origin",
2227 "https://github.com/example/app.git",
2228 ],
2229 );
2230 git(&root, &["add", "src/app.ts"]);
2231 git(&root, &["commit", "--quiet", "-m", "first"]);
2232 let head = git(&root, &["rev-parse", "HEAD"]);
2233
2234 mirror_git_repository(&root, &workspace).unwrap();
2235 assert_eq!(git(&workspace, &["rev-parse", "HEAD"]), head);
2236 assert_eq!(
2237 git(&workspace, &["symbolic-ref", "HEAD"]),
2238 "refs/heads/release/v2"
2239 );
2240 assert_eq!(
2241 git(&workspace, &["config", "--get", "remote.origin.url"]),
2242 "https://github.com/example/app.git"
2243 );
2244 hide_rewritten_files(&workspace, &["src/app.ts".to_owned()]);
2246 assert_eq!(git(&workspace, &["status", "--porcelain"]), "");
2247
2248 fs::write(workspace.join("new.txt"), "new\n").unwrap();
2250 git(&workspace, &["add", "new.txt"]);
2251 git(
2252 &workspace,
2253 &[
2254 "-c",
2255 "user.email=t@example.com",
2256 "-c",
2257 "user.name=T",
2258 "commit",
2259 "--quiet",
2260 "-m",
2261 "second",
2262 ],
2263 );
2264 assert_eq!(git(&root, &["rev-parse", "HEAD"]), head);
2265 assert_eq!(git(&root, &["status", "--porcelain"]), "");
2266 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2267 }
2268
2269 #[test]
2270 fn an_install_the_command_made_does_not_flow_back_without_its_dependencies() {
2271 let (root, workspace) = writeback_fixture("tool-install");
2272 fs::write(workspace.join("package.json"), "{}").unwrap();
2273 fs::write(root.join("package.json"), "{}").unwrap();
2274 fs::create_dir_all(workspace.join("node_modules")).unwrap();
2275 fs::create_dir_all(root.join("node_modules")).unwrap();
2276 let baseline = workspace_output_baseline(&workspace).unwrap();
2277 fs::create_dir_all(workspace.join(".tap/plugins/node_modules/@tapjs/clock")).unwrap();
2279 fs::write(workspace.join(".tap/plugins/package.json"), "{}").unwrap();
2280 fs::write(workspace.join(".tap/plugins/package-lock.json"), "{}").unwrap();
2281 fs::create_dir_all(workspace.join(".tap/test-results")).unwrap();
2282 fs::write(workspace.join(".tap/test-results/basic.tap"), "ok").unwrap();
2283 fs::write(workspace.join("package.json"), "{\"version\":\"2\"}").unwrap();
2285 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2286 assert!(!root.join(".tap/plugins/package.json").exists());
2287 assert!(!root.join(".tap/plugins/package-lock.json").exists());
2288 assert!(root.join(".tap/test-results/basic.tap").is_file());
2289 assert_eq!(
2290 fs::read_to_string(root.join("package.json")).unwrap(),
2291 "{\"version\":\"2\"}"
2292 );
2293 assert_eq!(sync.synced, 2);
2294 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2295 }
2296
2297 #[test]
2298 fn a_file_that_flows_back_names_the_project_and_not_the_copy() {
2299 let (root, workspace) = writeback_fixture("names");
2303 let baseline = workspace_output_baseline(&workspace).unwrap();
2304 let (copy, project) = (workspace.display().to_string(), root.display().to_string());
2305 fs::create_dir_all(workspace.join("coverage")).unwrap();
2306 fs::write(
2307 workspace.join("coverage/coverage-final.json"),
2308 format!("{{\"{copy}/src/fee.js\": {{\"path\": \"{copy}/src/fee.js\"}}}}"),
2309 )
2310 .unwrap();
2311 fs::write(
2312 workspace.join("coverage/cobertura.xml"),
2313 format!("<sources><source>{copy}</source></sources>"),
2314 )
2315 .unwrap();
2316 fs::write(
2318 workspace.join("coverage/neighbour.txt"),
2319 format!("{copy}-old/file and {copy}"),
2320 )
2321 .unwrap();
2322 let mut binary = copy.clone().into_bytes();
2324 binary.push(0);
2325 fs::write(workspace.join("coverage/cache.bin"), &binary).unwrap();
2326 fs::write(workspace.join("coverage/plain.txt"), "nothing to name").unwrap();
2327 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2328 assert_eq!(sync.synced, 5);
2329 assert_eq!(
2330 fs::read_to_string(root.join("coverage/coverage-final.json")).unwrap(),
2331 format!("{{\"{project}/src/fee.js\": {{\"path\": \"{project}/src/fee.js\"}}}}")
2332 );
2333 assert_eq!(
2334 fs::read_to_string(root.join("coverage/cobertura.xml")).unwrap(),
2335 format!("<sources><source>{project}</source></sources>")
2336 );
2337 assert_eq!(
2338 fs::read_to_string(root.join("coverage/neighbour.txt")).unwrap(),
2339 format!("{copy}-old/file and {project}")
2340 );
2341 assert_eq!(fs::read(root.join("coverage/cache.bin")).unwrap(), binary);
2342 assert_eq!(
2343 fs::read_to_string(root.join("coverage/plain.txt")).unwrap(),
2344 "nothing to name"
2345 );
2346 assert!(
2348 fs::read_to_string(workspace.join("coverage/cobertura.xml"))
2349 .unwrap()
2350 .contains(©)
2351 );
2352 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2353 }
2354
2355 #[test]
2356 fn what_a_go_jvm_or_rust_command_writes_flows_back_and_what_it_builds_stays() {
2357 let write = |workspace: &Path, file: &str, contents: &[u8]| {
2362 let path = workspace.join(file);
2363 fs::create_dir_all(path.parent().unwrap()).unwrap();
2364 fs::write(path, contents).unwrap();
2365 };
2366 let flows = |kind: CommandOutputs, name: &str, files: &[(&str, &[u8])]| {
2367 let (root, workspace) = writeback_fixture(name);
2368 write(&workspace, "pom.xml", b"<project/>");
2370 write(&workspace, "app/build.gradle.kts", b"plugins {}");
2371 let baseline = workspace_output_baseline(&workspace).unwrap();
2372 for (file, contents) in files {
2373 write(&workspace, file, contents);
2374 }
2375 let sync =
2376 sync_outputs_of(kind, &root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2377 let synced = files
2378 .iter()
2379 .filter(|(file, _)| root.join(file).is_file())
2380 .map(|(file, _)| (*file).to_owned())
2381 .collect::<Vec<_>>();
2382 assert_eq!(sync.synced, synced.len(), "{sync:?}");
2383 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2384 synced
2385 };
2386 let mut binary = b"\x7fELF\0\0example.com/shop/_supercov_runtime\0".to_vec();
2387 binary.extend([0u8; 16]);
2388 assert_eq!(
2389 flows(
2390 CommandOutputs::Go,
2391 "go",
2392 &[
2393 ("cover.out", b"mode: set\n"),
2394 ("testdata/golden.png", b"\x89PNG\0\0"),
2395 ("shop.test", &binary),
2396 ]
2397 ),
2398 ["cover.out", "testdata/golden.png"]
2399 );
2400 assert_eq!(
2401 flows(
2402 CommandOutputs::Jvm,
2403 "jvm",
2404 &[
2405 (
2406 "target/surefire-reports/TEST-shop.CryptoTest.xml",
2407 b"<testsuite/>"
2408 ),
2409 ("target/site/jacoco/index.html", b"<html/>"),
2410 ("app/build/test-results/test/TEST-a.xml", b"<testsuite/>"),
2411 ("app/build/reports/tests/test/index.html", b"<html/>"),
2412 ("src/test/resources/approved/output.txt", b"approved"),
2413 ("target/classes/shop/Crypto.class", b"\xca\xfe\xba\xbe"),
2415 (
2416 "target/maven-status/compile/createdFiles.lst",
2417 b"shop/Crypto.class"
2418 ),
2419 ("app/build/classes/kotlin/main/A.class", b"\xca\xfe\xba\xbe"),
2420 ("app/build/tmp/compileKotlin/cache.txt", b"state"),
2421 (".gradle/8.10/fileHashes/fileHashes.lock", b"lock"),
2422 ("pom.xml", b"<project><!-- changed --></project>"),
2423 ("src/build/notes.txt", b"kept"),
2425 ]
2426 ),
2427 [
2428 "target/surefire-reports/TEST-shop.CryptoTest.xml",
2429 "target/site/jacoco/index.html",
2430 "app/build/test-results/test/TEST-a.xml",
2431 "app/build/reports/tests/test/index.html",
2432 "src/test/resources/approved/output.txt",
2433 "src/build/notes.txt",
2434 ]
2435 );
2436 assert_eq!(
2437 flows(
2438 CommandOutputs::Rust,
2439 "rust",
2440 &[
2441 ("src/snapshots/sealed.snap", b"SNAP"),
2442 ("report.json", b"{}"),
2443 ("Cargo.lock", b"# lock"),
2444 ("src/generated.rs", b"pub fn made() {}"),
2445 ]
2446 ),
2447 ["src/snapshots/sealed.snap", "report.json"]
2448 );
2449 }
2450
2451 #[test]
2452 fn dependency_trees_never_flow_back() {
2453 let (root, workspace) = writeback_fixture("dependencies");
2456 let baseline = workspace_output_baseline(&workspace).unwrap();
2457 fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
2458 fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
2459 fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
2460 fs::write(
2461 workspace.join("packages/app/node_modules/dep/index.js"),
2462 "dep",
2463 )
2464 .unwrap();
2465 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2466 assert_eq!(sync.synced, 0);
2467 assert!(sync.deleted_in_workspace.is_empty());
2468 assert!(!root.join("node_modules").exists());
2469 assert!(!root.join("packages").exists());
2470 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2471 }
2472
2473 #[test]
2474 fn output_built_from_instrumented_sources_never_flows_back() {
2475 let (root, workspace) = writeback_fixture("built-output");
2481 let baseline = workspace_output_baseline(&workspace).unwrap();
2482 fs::create_dir_all(workspace.join("dist")).unwrap();
2483 fs::write(
2484 workspace.join("dist/app.js"),
2485 "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
2486 )
2487 .unwrap();
2488 fs::write(
2489 workspace.join("dist/app.d.ts"),
2490 "export declare const a: number;\n",
2491 )
2492 .unwrap();
2493
2494 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2495
2496 assert_eq!(
2499 sync.skipped_instrumented,
2500 vec![PathBuf::from("dist/app.d.ts"), PathBuf::from("dist/app.js")],
2501 "the instrumented build is reported, not copied"
2502 );
2503 assert_eq!(sync.synced, 0);
2504 assert!(!root.join("dist").exists());
2505 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2506 }
2507
2508 #[test]
2509 fn what_cargo_built_in_the_copy_stays_there() {
2510 let (root, workspace) = writeback_fixture("cargo-target");
2514 let baseline = workspace_output_baseline(&workspace).unwrap();
2515 for target in ["target", "tests/fixture/target"] {
2516 fs::create_dir_all(workspace.join(target).join("debug/deps")).unwrap();
2517 fs::write(
2518 workspace.join(target).join("debug/deps/libapp.rlib"),
2519 b"\0rlib",
2520 )
2521 .unwrap();
2522 }
2523 fs::write(
2525 workspace.join("tests/fixture/target/CACHEDIR.TAG"),
2526 "Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by cargo.\n",
2527 )
2528 .unwrap();
2529 fs::write(workspace.join("target/.rustc_info.json"), "{}").unwrap();
2532 fs::write(workspace.join("target/debug/.cargo-lock"), "").unwrap();
2533 fs::create_dir_all(workspace.join("reports/target")).unwrap();
2536 fs::write(workspace.join("reports/target/summary.txt"), "ok\n").unwrap();
2537
2538 let sync = sync_outputs_of(
2539 CommandOutputs::Rust,
2540 &root,
2541 &workspace,
2542 &baseline,
2543 &BTreeSet::new(),
2544 )
2545 .unwrap();
2546
2547 assert_eq!(
2548 sync.skipped_instrumented,
2549 vec![
2550 PathBuf::from("target/.rustc_info.json"),
2551 PathBuf::from("target/debug/.cargo-lock"),
2552 PathBuf::from("target/debug/deps/libapp.rlib"),
2553 PathBuf::from("tests/fixture/target/CACHEDIR.TAG"),
2554 PathBuf::from("tests/fixture/target/debug/deps/libapp.rlib"),
2555 ]
2556 );
2557 assert_eq!(sync.synced, 1);
2558 assert!(!root.join("target").exists());
2559 assert_eq!(
2560 fs::read_to_string(root.join("reports/target/summary.txt")).unwrap(),
2561 "ok\n"
2562 );
2563 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2564 }
2565
2566 #[test]
2567 fn a_built_file_in_a_directory_the_project_keeps_withholds_only_itself() {
2568 let (root, workspace) = writeback_fixture("built-beside-outputs");
2571 let baseline = workspace_output_baseline(&workspace).unwrap();
2572 fs::write(
2573 workspace.join("src/bundle.js"),
2574 "import \"../.supercov/node_modules/runtime.mjs\";\n",
2575 )
2576 .unwrap();
2577 fs::write(workspace.join("src/app.snap"), "updated\n").unwrap();
2578
2579 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2580
2581 assert_eq!(
2582 sync.skipped_instrumented,
2583 vec![PathBuf::from("src/bundle.js")]
2584 );
2585 assert_eq!(sync.synced, 1);
2586 assert_eq!(
2587 fs::read_to_string(root.join("src/app.snap")).unwrap(),
2588 "updated\n"
2589 );
2590 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2591 }
2592
2593 #[test]
2594 fn paths_are_summarized_by_where_they_are() {
2595 let paths = [
2596 ".next/BUILD_ID",
2597 ".next/server/app.js",
2598 ".next/server/chunks/a.js",
2599 "tests/e2e/runs/1/report.json",
2600 "coverage/report.json",
2601 "playwright-report/index.html",
2602 "notes.txt",
2603 "out/a.txt",
2604 ]
2605 .map(PathBuf::from);
2606 assert_eq!(
2607 summarize_paths(&paths),
2608 ".next/ 3, coverage/ 1, notes.txt, out/ 1, 2 elsewhere"
2609 );
2610 assert_eq!(summarize_paths(&paths[..1]), ".next/ 1");
2611 }
2612
2613 #[test]
2614 fn a_report_that_quotes_the_runtime_does_not_make_its_directory_a_build() {
2615 let (root, workspace) = writeback_fixture("report-quotes-runtime");
2618 let baseline = workspace_output_baseline(&workspace).unwrap();
2619 fs::create_dir_all(workspace.join("test-results/adds-an-item")).unwrap();
2620 fs::write(
2621 workspace.join("test-results/adds-an-item/error-context.md"),
2622 "at launch (/w/.supercov/node_modules/playwright.mjs:907:44)\n",
2623 )
2624 .unwrap();
2625 fs::write(
2626 workspace.join("test-results/adds-an-item/trace.zip"),
2627 "trace",
2628 )
2629 .unwrap();
2630
2631 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2632
2633 assert_eq!(
2634 sync.skipped_instrumented,
2635 vec![PathBuf::from("test-results/adds-an-item/error-context.md")]
2636 );
2637 assert_eq!(sync.synced, 1);
2638 assert!(root.join("test-results/adds-an-item/trace.zip").exists());
2639 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2640 }
2641
2642 #[test]
2643 fn a_build_directory_git_ignores_stays_behind_even_when_the_project_has_one() {
2644 let (root, workspace) = writeback_fixture("built-ignored");
2645 let git = |arguments: &[&str]| {
2646 std::process::Command::new("git")
2647 .arg("-C")
2648 .arg(&root)
2649 .args(arguments)
2650 .output()
2651 .unwrap()
2652 };
2653 if !git(&["init", "-q"]).status.success() {
2654 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2655 return;
2656 }
2657 fs::write(root.join(".gitignore"), ".next\n").unwrap();
2658 fs::create_dir_all(root.join(".next")).unwrap();
2659 fs::write(root.join(".next/BUILD_ID"), "mine\n").unwrap();
2660 let baseline = workspace_output_baseline(&workspace).unwrap();
2661 fs::create_dir_all(workspace.join(".next/server")).unwrap();
2662 fs::write(
2663 workspace.join(".next/server/chunk.js"),
2664 "__supercovCoverageHitV3(__supercovProbeFileV2, 0);\n",
2667 )
2668 .unwrap();
2669 fs::write(workspace.join(".next/BUILD_ID"), "instrumented\n").unwrap();
2670
2671 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2672
2673 assert_eq!(sync.synced, 0);
2674 assert_eq!(sync.skipped_instrumented.len(), 2);
2675 assert_eq!(
2676 fs::read_to_string(root.join(".next/BUILD_ID")).unwrap(),
2677 "mine\n"
2678 );
2679 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2680 }
2681
2682 #[test]
2683 fn a_minified_build_of_the_instrumented_copy_stays_in_the_workspace() {
2684 let (root, workspace) = writeback_fixture("built-minified");
2685 let initialized = std::process::Command::new("git")
2686 .arg("-C")
2687 .arg(&root)
2688 .args(["init", "-q"])
2689 .output()
2690 .unwrap();
2691 if !initialized.status.success() {
2692 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2693 return;
2694 }
2695 fs::write(root.join(".gitignore"), "dist\n").unwrap();
2696 fs::create_dir_all(root.join("dist")).unwrap();
2697 fs::write(root.join("dist/index.html"), "mine\n").unwrap();
2698 let baseline = workspace_output_baseline(&workspace).unwrap();
2699 fs::create_dir_all(workspace.join("dist/assets")).unwrap();
2700 fs::write(
2701 workspace.join("dist/assets/index-DzZxFCVp.js"),
2702 "const e=globalThis.__SUPERCOV_DIRECT_RUNTIME__.coverageHitV2;e(t,0);\n",
2705 )
2706 .unwrap();
2707 fs::write(workspace.join("dist/index.html"), "instrumented\n").unwrap();
2708
2709 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2710
2711 assert_eq!(sync.synced, 0);
2712 assert_eq!(sync.skipped_instrumented.len(), 2);
2713 assert_eq!(
2714 fs::read_to_string(root.join("dist/index.html")).unwrap(),
2715 "mine\n"
2716 );
2717 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2718 }
2719
2720 #[test]
2721 fn command_outputs_flow_back_to_the_project() {
2722 let (root, workspace) = writeback_fixture("outputs");
2723 let baseline = workspace_output_baseline(&workspace).unwrap();
2724 fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
2725 fs::write(
2726 workspace.join("src/__snapshots__/app.snap"),
2727 "updated snapshot\n",
2728 )
2729 .unwrap();
2730 fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
2731 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2732 assert_eq!(sync.synced, 1);
2733 assert_eq!(
2734 fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
2735 "updated snapshot\n"
2736 );
2737 assert!(!root.join(".supercov/state.json").exists());
2738 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2739 }
2740
2741 #[test]
2742 fn instrumented_sources_never_flow_back() {
2743 let (root, workspace) = writeback_fixture("protected");
2744 let baseline = workspace_output_baseline(&workspace).unwrap();
2745 fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
2748 let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
2749 let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
2750 assert_eq!(sync.synced, 0);
2751 assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
2752 assert_eq!(
2753 fs::read_to_string(root.join("src/app.ts")).unwrap(),
2754 "original\n"
2755 );
2756 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2757 }
2758
2759 #[test]
2760 fn workspace_deletions_are_reported_but_never_propagated() {
2761 let (root, workspace) = writeback_fixture("deletions");
2762 fs::write(workspace.join("stale.txt"), "old\n").unwrap();
2763 fs::write(root.join("stale.txt"), "old\n").unwrap();
2764 fs::write(workspace.join("built.txt"), "old\n").unwrap();
2766 let baseline = workspace_output_baseline(&workspace).unwrap();
2767 fs::remove_file(workspace.join("stale.txt")).unwrap();
2768 fs::remove_file(workspace.join("built.txt")).unwrap();
2769 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
2770 assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
2771 assert!(root.join("stale.txt").exists());
2772 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2773 }
2774
2775 #[test]
2778 fn names_that_could_leave_their_directory_are_refused() {
2779 let (root, workspace) = writeback_fixture("names");
2780 let unsafe_path = |result: Result<PathBuf, WorkspaceError>| {
2781 matches!(result, Err(WorkspaceError::UnsafePath(_)))
2782 };
2783 for run_id in ["", ".", "..", "a/b", "a\\b", "a\0b", "a\nb"] {
2784 assert!(
2785 unsafe_path(isolated_workspace_path(&root, run_id)),
2786 "{run_id:?}"
2787 );
2788 }
2789 let isolated = isolated_workspace_path(&root, "run_1").unwrap();
2790 assert!(isolated.starts_with(workspace_container(&root).join("work").join("run_1")));
2791
2792 fs::write(workspace.join("kept.txt"), "kept\n").unwrap();
2793 for requested in [
2794 workspace.join("kept.txt"),
2795 PathBuf::from("../kept.txt"),
2796 PathBuf::from("./kept.txt"),
2797 PathBuf::new(),
2798 PathBuf::from("missing.txt"),
2799 ] {
2800 assert!(
2801 unsafe_path(checked_reuse_path(&workspace, &requested)),
2802 "{requested:?}"
2803 );
2804 }
2805 assert_eq!(
2806 checked_reuse_path(&workspace, Path::new("kept.txt")).unwrap(),
2807 workspace.join("kept.txt")
2808 );
2809
2810 for relative in ["", "../escape.txt", "/etc/passwd", "a/../../b"] {
2811 assert!(
2812 matches!(
2813 validate_writeback_destination(&root, Path::new(relative)),
2814 Err(WorkspaceError::UnsafePath(_))
2815 ),
2816 "{relative:?}"
2817 );
2818 }
2819 validate_writeback_destination(&root, Path::new("reports/new/result.txt")).unwrap();
2821 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2822 }
2823
2824 #[cfg(unix)]
2825 #[test]
2826 fn writeback_refuses_a_symlinked_destination() {
2827 let (root, workspace) = writeback_fixture("symlink");
2828 let outside = root.parent().unwrap().join("outside");
2829 fs::create_dir_all(&outside).unwrap();
2830 std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
2831 let baseline = workspace_output_baseline(&workspace).unwrap();
2832 fs::create_dir_all(workspace.join("reports")).unwrap();
2833 fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
2834 let error =
2835 sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
2836 assert!(matches!(error, WorkspaceError::UnsafePath(_)));
2837 assert!(!outside.join("result.txt").exists());
2838 fs::remove_dir_all(root.parent().unwrap()).unwrap();
2839 }
2840
2841 struct OrdinaryCopyOperations;
2842
2843 impl WorkspaceOperations for OrdinaryCopyOperations {
2844 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2845 fs::copy(source, destination)
2846 .map(|_| ())
2847 .map_err(|error| io_error(destination, error))
2848 }
2849
2850 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2851 atomic_rename(source, destination).map_err(WorkspaceError::from)
2852 }
2853 }
2854
2855 struct FaultOperations {
2856 copy_count: usize,
2857 fail_copy_at: Option<usize>,
2858 rename_count: usize,
2859 fail_rename_at: Option<usize>,
2860 }
2861
2862 impl WorkspaceOperations for FaultOperations {
2863 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2864 self.copy_count += 1;
2865 if self.fail_copy_at == Some(self.copy_count) {
2866 return Err(io_error(
2867 destination,
2868 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
2869 ));
2870 }
2871 SystemWorkspaceOperations.copy_file(source, destination)
2872 }
2873
2874 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
2875 self.rename_count += 1;
2876 if self.fail_rename_at == Some(self.rename_count) {
2877 return Err(io_error(
2878 destination,
2879 io::Error::other("injected publication rename failure"),
2880 ));
2881 }
2882 SystemWorkspaceOperations.rename(source, destination)
2883 }
2884 }
2885
2886 fn transaction_debris(root: &Path) -> Vec<String> {
2887 let workspace = copy_path(root, 1).unwrap();
2888 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
2889 fs::read_dir(workspace.parent().unwrap())
2890 .unwrap()
2891 .filter_map(Result::ok)
2892 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2893 .filter(|name| name.starts_with(&prefix))
2894 .collect()
2895 }
2896
2897 fn project() -> PathBuf {
2898 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
2899 fs::create_dir_all(root.join("src")).unwrap();
2900 fs::write(root.join("src/index.js"), "one").unwrap();
2901 fs::write(root.join("package.json"), "{}").unwrap();
2902 root
2903 }
2904
2905 #[test]
2906 #[cfg(unix)]
2907 fn root_node_modules_symlink_is_followed() {
2908 let root = project();
2913 let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
2914 fs::create_dir_all(store.join("left-pad")).unwrap();
2915 fs::write(store.join("left-pad/package.json"), "{}").unwrap();
2916 std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
2917 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2918 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2919 assert!(
2920 fs::symlink_metadata(workspace.join("node_modules/left-pad"))
2921 .unwrap()
2922 .file_type()
2923 .is_symlink()
2924 );
2925 lock.release().unwrap();
2926 fs::remove_dir_all(root).unwrap();
2927 fs::remove_dir_all(store).unwrap();
2928 }
2929
2930 #[test]
2931 fn a_package_copy_sits_where_the_package_is_in_its_repository() {
2932 let repository = project();
2935 fs::create_dir_all(repository.join(".git")).unwrap();
2936 fs::create_dir_all(repository.join("cmd/testserver")).unwrap();
2937 fs::write(repository.join("cmd/testserver/main.go"), "package main\n").unwrap();
2938 fs::write(repository.join("tsconfig.base.json"), "{}").unwrap();
2939 fs::write(repository.join("package-lock.json"), "{}").unwrap();
2940 let root = repository.join("packages/web");
2941 fs::create_dir_all(root.join("src")).unwrap();
2942 fs::write(root.join("src/app.js"), "export {};\n").unwrap();
2943 fs::create_dir_all(repository.join("packages/ui")).unwrap();
2944 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2945 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2946 let name = repository.file_name().unwrap();
2947 assert!(workspace.ends_with(Path::new(name).join("packages/web")));
2948 assert!(workspace.join("src/app.js").is_file());
2949 assert_eq!(
2950 fs::read_to_string(workspace.join("../../cmd/testserver/main.go")).unwrap(),
2951 "package main\n"
2952 );
2953 assert!(workspace.join("../../tsconfig.base.json").is_file());
2954 assert!(workspace.join("../ui").is_dir());
2955 assert!(fs::symlink_metadata(workspace.join("../../.git")).is_err());
2958 assert!(fs::symlink_metadata(workspace.join("../../package-lock.json")).is_err());
2959 lock.release().unwrap();
2960 fs::remove_dir_all(repository).unwrap();
2961 }
2962
2963 #[test]
2964 #[cfg(unix)]
2965 fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
2966 let root = project();
2973 let nested = root.join("examples/app/node_modules/@scope");
2974 fs::create_dir_all(&nested).unwrap();
2975 std::os::unix::fs::symlink(
2976 "../../../../packages/react/node_modules/@scope/pkg",
2977 nested.join("pkg"),
2978 )
2979 .unwrap();
2980 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2981 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2982 let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
2983 let metadata = fs::symlink_metadata(&mirrored).unwrap();
2984 assert!(metadata.file_type().is_symlink());
2985 assert_eq!(
2986 fs::read_link(&mirrored).unwrap(),
2987 PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
2988 );
2989 lock.release().unwrap();
2990 fs::remove_dir_all(root).unwrap();
2991 }
2992
2993 #[test]
2994 #[cfg(target_os = "macos")]
2995 fn nested_node_modules_are_cloned_not_linked() {
2996 let root = project();
3002 let nested = root.join("packages/app/node_modules");
3003 fs::create_dir_all(nested.join("dep")).unwrap();
3004 fs::create_dir_all(nested.join(".bin")).unwrap();
3005 fs::write(nested.join("dep/index.js"), "dep").unwrap();
3006 std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
3007 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3008 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3009 let mirrored = workspace.join("packages/app/node_modules");
3010 assert!(
3011 fs::symlink_metadata(mirrored.join("dep"))
3012 .unwrap()
3013 .file_type()
3014 .is_dir()
3015 );
3016 assert_eq!(
3017 fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
3018 "dep"
3019 );
3020 assert_eq!(
3021 fs::read_link(mirrored.join(".bin/dep")).unwrap(),
3022 PathBuf::from("../dep/index.js")
3023 );
3024 fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
3025 assert_eq!(
3026 fs::read_to_string(nested.join("dep/index.js")).unwrap(),
3027 "dep"
3028 );
3029 lock.release().unwrap();
3030 fs::remove_dir_all(root).unwrap();
3031 }
3032
3033 #[test]
3034 #[cfg(unix)]
3035 fn hard_linked_dependency_tree_is_real_and_replaceable() {
3036 use std::os::unix::fs::MetadataExt;
3040 let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
3041 let source = base.join("node_modules");
3042 fs::create_dir_all(source.join("dep")).unwrap();
3043 fs::create_dir_all(source.join(".bin")).unwrap();
3044 fs::write(source.join("dep/index.js"), "dep").unwrap();
3045 std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
3046 let destination = base.join("workspace/node_modules");
3047 fs::create_dir_all(destination.parent().unwrap()).unwrap();
3048 assert!(hard_link_tree(&source, &destination));
3049 assert_eq!(
3050 fs::metadata(destination.join("dep/index.js"))
3051 .unwrap()
3052 .ino(),
3053 fs::metadata(source.join("dep/index.js")).unwrap().ino()
3054 );
3055 assert_eq!(
3056 fs::read_link(destination.join(".bin/dep")).unwrap(),
3057 PathBuf::from("../dep/index.js")
3058 );
3059 fs::remove_dir_all(destination.join("dep")).unwrap();
3060 fs::create_dir_all(destination.join("dep")).unwrap();
3061 fs::write(destination.join("dep/index.js"), "replaced").unwrap();
3062 assert_eq!(
3063 fs::read_to_string(source.join("dep/index.js")).unwrap(),
3064 "dep"
3065 );
3066 fs::remove_dir_all(base).unwrap();
3067 }
3068
3069 #[test]
3070 #[cfg(unix)]
3071 fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
3072 let root = project();
3078 let nested = root.join("examples/app/lib");
3079 fs::create_dir_all(&nested).unwrap();
3080 std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
3081 .unwrap();
3082 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3083 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3084 assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
3085 assert!(workspace.join("src/index.js").exists());
3086 lock.release().unwrap();
3087 fs::remove_dir_all(root).unwrap();
3088 }
3089
3090 #[test]
3091 fn isolated_copy_never_changes_source_and_requires_the_lock() {
3092 let root = project();
3093 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3094 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
3095 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
3096 assert_eq!(
3097 fs::read_to_string(root.join("src/index.js")).unwrap(),
3098 "one"
3099 );
3100 lock.release().unwrap();
3101 assert!(matches!(
3102 prepare_isolated_workspace(&root, "other", &lock),
3103 Err(WorkspaceError::MissingLock)
3104 ));
3105 fs::remove_dir_all(root).unwrap();
3106 }
3107
3108 #[test]
3109 fn everything_supercov_writes_lives_under_the_store() {
3110 let root = project();
3111 assert_eq!(
3112 workspace_container(&root),
3113 root.join(".supercov/workspaces")
3114 );
3115 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3116 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3117 assert!(workspace.starts_with(root.join(".supercov")));
3118 assert_eq!(
3119 fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
3120 "*\n"
3121 );
3122 lock.release().unwrap();
3123 fs::remove_dir_all(root).unwrap();
3124 }
3125
3126 #[test]
3127 fn an_unowned_container_gets_a_deterministic_fallback() {
3128 let root = project();
3129 fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
3130 fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
3131 let container = workspace_container(&root);
3132 assert_ne!(container, root.join(".supercov/workspaces"));
3133 let name = container
3134 .file_name()
3135 .unwrap()
3136 .to_string_lossy()
3137 .into_owned();
3138 assert!(name.starts_with("workspaces-"));
3139 fs::remove_dir_all(root).unwrap();
3140 }
3141
3142 #[test]
3143 fn user_supercov_directory_is_copied_and_never_adopted() {
3144 let root = project();
3145 fs::create_dir(root.join("supercov")).unwrap();
3146 fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
3147 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3148 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3149 assert_ne!(workspace_container(&root), root.join("supercov"));
3150 assert_eq!(
3151 fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
3152 "export default 1;\n"
3153 );
3154 assert_eq!(
3155 fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
3156 "export default 1;\n"
3157 );
3158 assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
3159 lock.release().unwrap();
3160 fs::remove_dir_all(root).unwrap();
3161 }
3162
3163 #[cfg(unix)]
3164 #[test]
3165 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
3166 let root = project();
3167 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3168 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3169 fs::create_dir_all(first.join("build")).unwrap();
3170 fs::write(first.join("build/output.js"), "instrumented").unwrap();
3171 fs::write(first.join("stale.txt"), "stale").unwrap();
3172 fs::write(root.join("src/index.js"), "two").unwrap();
3173 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
3174 assert_eq!(first, second);
3175 assert_eq!(
3176 fs::read_to_string(second.join("src/index.js")).unwrap(),
3177 "two"
3178 );
3179 assert_eq!(
3180 fs::read_to_string(second.join("build/output.js")).unwrap(),
3181 "instrumented"
3182 );
3183 assert!(!second.join("stale.txt").exists());
3184 lock.release().unwrap();
3185 fs::remove_dir_all(root).unwrap();
3186 }
3187
3188 #[test]
3189 fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
3190 let root = project();
3191 fs::create_dir_all(root.join("packages/app/dist")).unwrap();
3192 fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
3193 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3194 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3195 fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
3196 fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
3197 let second =
3198 prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
3199 assert_eq!(
3200 fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
3201 "instrumented"
3202 );
3203 assert!(!second.join("packages/app/dist/stale.js").exists());
3204 lock.release().unwrap();
3205 fs::remove_dir_all(root).unwrap();
3206 }
3207
3208 #[test]
3209 fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
3210 let root = project();
3211 fs::create_dir_all(root.join("tests/e2e")).unwrap();
3212 fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
3213 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3214 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3215 let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
3216 fs::create_dir_all(&artifacts).unwrap();
3217 fs::write(artifacts.join("digest"), "instrumented test").unwrap();
3218 fs::write(
3219 workspace.join(".supercov/frontend-cache.json"),
3220 "{\"schemaVersion\":2}",
3221 )
3222 .unwrap();
3223 prune_cached_workspace_sources(&root, &lock).unwrap();
3224 assert!(!workspace.join("tests/e2e/app.spec.js").exists());
3225 assert_eq!(
3226 fs::read_to_string(artifacts.join("digest")).unwrap(),
3227 "instrumented test"
3228 );
3229 lock.release().unwrap();
3230 fs::remove_dir_all(root).unwrap();
3231 }
3232
3233 #[test]
3234 fn ordinary_copy_fallback_preserves_workspace_semantics() {
3235 let root = project();
3236 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3237 let mut operations = OrdinaryCopyOperations;
3238 let workspace =
3239 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
3240 assert_eq!(
3241 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3242 "one"
3243 );
3244 assert_eq!(
3245 fs::read_to_string(root.join("src/index.js")).unwrap(),
3246 "one"
3247 );
3248 assert!(transaction_debris(&root).is_empty());
3249 lock.release().unwrap();
3250 fs::remove_dir_all(root).unwrap();
3251 }
3252
3253 #[test]
3254 fn enospc_during_copy_preserves_the_complete_generation() {
3255 let root = project();
3256 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3257 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3258 fs::write(workspace.join("generation"), "complete").unwrap();
3259 fs::write(root.join("src/index.js"), "new source").unwrap();
3260 let mut operations = FaultOperations {
3261 copy_count: 0,
3262 fail_copy_at: Some(1),
3263 rename_count: 0,
3264 fail_rename_at: None,
3265 };
3266 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
3267 .unwrap_err();
3268 assert!(matches!(
3269 error,
3270 WorkspaceError::Io { ref source, .. }
3271 if source.kind() == io::ErrorKind::StorageFull
3272 ));
3273 assert_eq!(
3274 fs::read_to_string(workspace.join("generation")).unwrap(),
3275 "complete"
3276 );
3277 assert_eq!(
3278 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3279 "one"
3280 );
3281 assert!(transaction_debris(&root).is_empty());
3282 lock.release().unwrap();
3283 fs::remove_dir_all(root).unwrap();
3284 }
3285
3286 #[test]
3287 fn failed_publication_rename_restores_the_complete_generation() {
3288 let root = project();
3289 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3290 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3291 fs::write(workspace.join("generation"), "complete").unwrap();
3292 fs::write(root.join("src/index.js"), "new source").unwrap();
3293 let mut operations = FaultOperations {
3294 copy_count: 0,
3295 fail_copy_at: None,
3296 rename_count: 0,
3297 fail_rename_at: Some(2),
3298 };
3299 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
3300 .unwrap_err();
3301 assert!(matches!(
3302 error,
3303 WorkspaceError::Io { ref source, .. }
3304 if source.kind() == io::ErrorKind::Other
3305 ));
3306 assert_eq!(
3307 operations.rename_count, 3,
3308 "prior generation was not restored"
3309 );
3310 assert_eq!(
3311 fs::read_to_string(workspace.join("generation")).unwrap(),
3312 "complete"
3313 );
3314 assert_eq!(
3315 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3316 "one"
3317 );
3318 assert!(transaction_debris(&root).is_empty());
3319 lock.release().unwrap();
3320 fs::remove_dir_all(root).unwrap();
3321 }
3322
3323 #[cfg(unix)]
3324 #[test]
3325 fn relocates_internal_links_and_rejects_external_links() {
3326 use std::os::unix::fs::symlink;
3327
3328 let root = project();
3329 fs::create_dir_all(root.join("shared")).unwrap();
3330 fs::write(root.join("shared/value"), "inside").unwrap();
3331 symlink("../shared/value", root.join("src/value-link")).unwrap();
3332 let external = project();
3333 fs::write(external.join("outside"), "outside").unwrap();
3334 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3335 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3336 assert_eq!(
3337 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
3338 "inside"
3339 );
3340 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
3341 let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3342 assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
3343 assert_eq!(
3344 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
3345 "one"
3346 );
3347 lock.release().unwrap();
3348 fs::remove_dir_all(root).unwrap();
3349 fs::remove_dir_all(external).unwrap();
3350 }
3351
3352 #[cfg(windows)]
3353 #[test]
3354 fn relocates_internal_junctions_without_symlink_privileges() {
3355 let root = project();
3356 fs::create_dir_all(root.join("shared")).unwrap();
3357 fs::write(root.join("shared/value"), "inside").unwrap();
3358 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
3359 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3360 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3361 let isolated_link = workspace.join("linked-shared");
3362 assert!(junction::exists(&isolated_link).unwrap());
3363 assert_eq!(
3364 canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
3365 canonicalize_simplified(workspace.join("shared")).unwrap()
3366 );
3367 assert_eq!(
3368 fs::read_to_string(isolated_link.join("value")).unwrap(),
3369 "inside"
3370 );
3371 lock.release().unwrap();
3372 fs::remove_dir_all(root).unwrap();
3373 }
3374
3375 #[cfg(windows)]
3376 #[test]
3377 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
3378 let root = project();
3379 fs::create_dir_all(root.join("node_modules/example")).unwrap();
3380 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
3381 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
3382 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3383 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
3384 let package = workspace.join("node_modules/example");
3385 assert!(junction::exists(&package).unwrap());
3386 assert_eq!(
3387 canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
3388 canonicalize_simplified(root.join("node_modules/example")).unwrap()
3389 );
3390 let metadata = workspace.join("node_modules/.package-lock.json");
3391 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
3392 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
3393 lock.release().unwrap();
3394 fs::remove_dir_all(root).unwrap();
3395 }
3396
3397 #[test]
3398 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
3399 let root = project();
3400 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
3401 ensure_container(&root).unwrap();
3402 let workspace = cached_workspace_path(&root, &lock).unwrap();
3403 let parent = workspace.parent().unwrap();
3404 fs::create_dir_all(parent).unwrap();
3405 let previous = parent.join(format!(
3406 "{}old",
3407 transaction_prefix(&root, "previous").unwrap()
3408 ));
3409 let staging = parent.join(format!(
3410 "{}new",
3411 transaction_prefix(&root, "staging").unwrap()
3412 ));
3413 fs::create_dir_all(&previous).unwrap();
3414 fs::write(previous.join("complete"), "yes").unwrap();
3415 fs::create_dir_all(&staging).unwrap();
3416 let result = recover_cached_workspace(&root, &lock).unwrap();
3417 assert!(result.restored_previous);
3418 assert_eq!(result.removed_staging, 1);
3419 assert_eq!(
3420 fs::read_to_string(workspace.join("complete")).unwrap(),
3421 "yes"
3422 );
3423 lock.release().unwrap();
3424 fs::remove_dir_all(root).unwrap();
3425 }
3426}