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