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