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 skipped_instrumented: Vec<PathBuf>,
1093 pub deleted_in_workspace: Vec<PathBuf>,
1096}
1097
1098fn walk_output_files(
1099 workspace: &Path,
1100 directory: &Path,
1101 entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1102) -> Result<(), WorkspaceError> {
1103 for entry in fs::read_dir(directory)
1104 .map_err(|error| io_error(directory, error))?
1105 .collect::<Result<Vec<_>, _>>()
1106 .map_err(|error| io_error(directory, error))?
1107 {
1108 let path = entry.path();
1109 let name = entry.file_name();
1110 let relative = path
1111 .strip_prefix(workspace)
1112 .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1113 .to_owned();
1114 if relative.components().count() == 1
1118 && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1119 {
1120 continue;
1121 }
1122 let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1123 if metadata.is_dir() && name.to_str() == Some("node_modules") {
1127 continue;
1128 }
1129 if fs::symlink_metadata(&path)
1130 .map_err(|error| io_error(&path, error))?
1131 .file_type()
1132 .is_symlink()
1133 {
1134 continue;
1135 }
1136 if metadata.is_dir() {
1137 walk_output_files(workspace, &path, entries)?;
1138 } else if metadata.is_file() {
1139 let modified = metadata
1140 .modified()
1141 .map_err(|error| io_error(&path, error))?;
1142 entries.insert(relative, (metadata.len(), modified));
1143 }
1144 }
1145 Ok(())
1146}
1147
1148pub fn workspace_output_baseline(
1149 workspace: &Path,
1150) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1151 let mut entries = BTreeMap::new();
1152 walk_output_files(workspace, workspace, &mut entries)?;
1153 Ok(WorkspaceOutputBaseline { entries })
1154}
1155
1156fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1159 if relative.as_os_str().is_empty()
1160 || relative
1161 .components()
1162 .any(|component| !matches!(component, Component::Normal(_)))
1163 {
1164 return Err(WorkspaceError::UnsafePath(relative.into()));
1165 }
1166 let mut current = root.to_owned();
1167 for component in relative.components() {
1168 current.push(component.as_os_str());
1169 match fs::symlink_metadata(¤t) {
1170 Ok(metadata) if metadata.file_type().is_symlink() => {
1171 return Err(WorkspaceError::UnsafePath(current));
1172 }
1173 Ok(_) => {}
1174 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1175 Err(error) => return Err(io_error(¤t, error)),
1176 }
1177 }
1178 Ok(())
1179}
1180
1181pub fn sync_command_outputs(
1188 root: &Path,
1189 workspace: &Path,
1190 baseline: &WorkspaceOutputBaseline,
1191 protected: &BTreeSet<PathBuf>,
1192) -> Result<CommandOutputSync, WorkspaceError> {
1193 let mut current = BTreeMap::new();
1194 walk_output_files(workspace, workspace, &mut current)?;
1195 let mut sync = CommandOutputSync::default();
1196 for (relative, state) in ¤t {
1197 if baseline.entries.get(relative) == Some(state) {
1198 continue;
1199 }
1200 if protected.contains(relative) {
1201 sync.skipped_instrumented.push(relative.clone());
1202 continue;
1203 }
1204 let from = workspace.join(relative);
1205 if references_generated_runtime(&from)? {
1212 sync.skipped_instrumented.push(relative.clone());
1213 continue;
1214 }
1215 if unaccompanied_install_manifest(root, workspace, relative) {
1216 continue;
1217 }
1218 validate_writeback_destination(root, relative)?;
1219 let to = root.join(relative);
1220 if let Some(parent) = to.parent() {
1221 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1222 }
1223 fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1224 sync.synced += 1;
1225 }
1226 for relative in baseline.entries.keys() {
1227 if !current.contains_key(relative) {
1228 sync.deleted_in_workspace.push(relative.clone());
1229 }
1230 }
1231 Ok(sync)
1232}
1233
1234pub fn mirror_git_repository(root: &Path, workspace: &Path) -> Result<(), String> {
1245 if fs::symlink_metadata(root.join(".git")).is_err() {
1246 return Ok(());
1247 }
1248 let target = workspace.join(".git");
1249 let git = |directory: &Path, arguments: &[&str]| -> Result<String, String> {
1250 let output = std::process::Command::new("git")
1251 .arg("-C")
1252 .arg(directory)
1253 .args(arguments)
1254 .env("GIT_TERMINAL_PROMPT", "0")
1255 .stdin(std::process::Stdio::null())
1256 .output()
1257 .map_err(|error| format!("git {}: {error}", arguments.join(" ")))?;
1258 if output.status.success() {
1259 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1260 } else {
1261 Err(format!(
1262 "git {}: {}",
1263 arguments.join(" "),
1264 String::from_utf8_lossy(&output.stderr).trim()
1265 ))
1266 }
1267 };
1268 let result = (|| {
1269 if fs::symlink_metadata(&target).is_ok() {
1270 fs::remove_dir_all(&target).map_err(|error| error.to_string())?;
1271 }
1272 let source = root.to_string_lossy();
1273 let destination = target.to_string_lossy();
1274 git(
1275 workspace,
1276 &[
1277 "clone",
1278 "--quiet",
1279 "--shared",
1280 "--bare",
1281 &source,
1282 &destination,
1283 ],
1284 )?;
1285 git(workspace, &["config", "core.bare", "false"])?;
1286 match git(root, &["symbolic-ref", "-q", "HEAD"]) {
1287 Ok(branch) => git(workspace, &["symbolic-ref", "HEAD", &branch])?,
1288 Err(_) => {
1289 let commit = git(root, &["rev-parse", "HEAD"])?;
1290 git(workspace, &["update-ref", "--no-deref", "HEAD", &commit])?
1291 }
1292 };
1293 git(workspace, &["remote", "remove", "origin"])?;
1294 let remotes = git(root, &["remote"])?;
1295 for name in remotes.lines().filter(|name| !name.is_empty()) {
1296 let url = git(root, &["config", "--get", &format!("remote.{name}.url")])?;
1299 git(workspace, &["remote", "add", name, &url])?;
1300 }
1301 git(
1302 workspace,
1303 &[
1304 "fetch",
1305 "--quiet",
1306 "--no-tags",
1307 &source,
1308 "+refs/remotes/*:refs/remotes/*",
1309 ],
1310 )?;
1311 git(workspace, &["read-tree", "HEAD"])?;
1312 let exclude = target.join("info/exclude");
1314 fs::create_dir_all(target.join("info")).map_err(|error| error.to_string())?;
1315 let mut patterns = fs::read_to_string(&exclude).unwrap_or_default();
1316 patterns.push_str("\n.supercov/\n");
1317 fs::write(&exclude, patterns).map_err(|error| error.to_string())?;
1318 Ok(())
1319 })();
1320 if result.is_err() {
1321 let _ = fs::remove_dir_all(&target);
1322 }
1323 result
1324}
1325
1326pub fn hide_rewritten_files(workspace: &Path, files: &[String]) {
1330 if files.is_empty() || !workspace.join(".git").is_dir() {
1331 return;
1332 }
1333 let run = |arguments: &[&str], input: &str| {
1334 let mut child = std::process::Command::new("git")
1335 .arg("-C")
1336 .arg(workspace)
1337 .args(arguments)
1338 .stdin(std::process::Stdio::piped())
1339 .stdout(std::process::Stdio::piped())
1340 .stderr(std::process::Stdio::null())
1341 .spawn()
1342 .ok()?;
1343 child.stdin.take()?.write_all(input.as_bytes()).ok()?;
1344 let output = child.wait_with_output().ok()?;
1345 output
1346 .status
1347 .success()
1348 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1349 };
1350 let Some(listed) = run(&["ls-files", "-z"], "") else {
1352 return;
1353 };
1354 let tracked = listed.split('\0').collect::<BTreeSet<_>>();
1355 let hidden = files
1356 .iter()
1357 .filter(|file| tracked.contains(file.as_str()))
1358 .map(String::as_str)
1359 .collect::<Vec<_>>();
1360 if !hidden.is_empty() {
1361 let _ = run(
1362 &["update-index", "--skip-worktree", "--stdin"],
1363 &hidden.join("\n"),
1364 );
1365 }
1366}
1367
1368fn unaccompanied_install_manifest(root: &Path, workspace: &Path, relative: &Path) -> bool {
1375 let manifest = relative
1376 .file_name()
1377 .and_then(|name| name.to_str())
1378 .is_some_and(|name| {
1379 matches!(
1380 name,
1381 "package.json"
1382 | "package-lock.json"
1383 | "npm-shrinkwrap.json"
1384 | "yarn.lock"
1385 | "pnpm-lock.yaml"
1386 | "bun.lock"
1387 | "bun.lockb"
1388 )
1389 });
1390 let Some(directory) = relative.parent() else {
1391 return false;
1392 };
1393 manifest
1394 && workspace.join(directory).join("node_modules").is_dir()
1395 && !root.join(directory).join("node_modules").exists()
1396}
1397
1398fn references_generated_runtime(path: &Path) -> Result<bool, WorkspaceError> {
1402 const MARKER: &[u8] = b".supercov/node_modules/";
1403 let contents = match fs::read(path) {
1404 Ok(contents) => contents,
1405 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1406 Err(error) => return Err(io_error(path, error)),
1407 };
1408 Ok(contents
1409 .windows(MARKER.len())
1410 .any(|window| window == MARKER))
1411}
1412
1413pub fn prune_cached_workspace_sources(
1414 root: &Path,
1415 lock: &ProjectLock,
1416) -> Result<Vec<String>, WorkspaceError> {
1417 require_lock(root, lock)?;
1418 let workspace = cached_workspace_path(root)?;
1419 if fs::symlink_metadata(&workspace).is_err() {
1420 return Ok(Vec::new());
1421 }
1422 let mut keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
1423 let metadata_path = workspace.join(".supercov/build-cache.json");
1424 if let Ok(bytes) = fs::read(&metadata_path) {
1425 let metadata: BuildCacheMetadata =
1426 serde_json::from_slice(&bytes).map_err(WorkspaceError::InvalidCacheMetadata)?;
1427 retain_cached_artifact_roots(&mut keep, metadata.artifact_paths);
1428 }
1429 let mut removed = Vec::new();
1430 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
1431 let entry = entry.map_err(|error| io_error(&workspace, error))?;
1432 let name = entry
1433 .file_name()
1434 .into_string()
1435 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
1436 if keep.contains(&name) {
1437 continue;
1438 }
1439 remove_stored_tree_deferred(root, &entry.path())?;
1440 removed.push(name);
1441 }
1442 removed.sort();
1443 Ok(removed)
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 #[test]
1449 fn file_urls_match_what_node_derives_for_every_path_shape() {
1450 use super::file_url_from_slash_path;
1451 assert_eq!(
1452 file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
1453 "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
1454 );
1455 assert_eq!(
1456 file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
1457 "file:///C:/Users/a%20b/%231/register.mjs"
1458 );
1459 assert_eq!(
1460 file_url_from_slash_path("//server/share/dir/register.mjs"),
1461 "file://server/share/dir/register.mjs"
1462 );
1463 assert_eq!(
1464 file_url_from_slash_path("/w/p/register.mjs"),
1465 "file:///w/p/register.mjs"
1466 );
1467 assert_eq!(
1468 file_url_from_slash_path("/w/p x?q/ü.mjs"),
1469 "file:///w/p%20x%3Fq/%C3%BC.mjs"
1470 );
1471 }
1472
1473 #[test]
1474 fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
1475 use super::strip_verbatim;
1476 assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
1477 assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
1478 assert_eq!(
1479 strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
1480 Some(r"\\server\share\dir")
1481 );
1482 assert_eq!(strip_verbatim(r"C:\a\b"), None);
1483 assert_eq!(strip_verbatim("/w/project"), None);
1484 assert_eq!(strip_verbatim(r"\\server\share"), None);
1485 assert_eq!(strip_verbatim(r"\\?\"), None);
1486 }
1487
1488 use super::*;
1489
1490 fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
1491 let base =
1492 std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
1493 if base.exists() {
1494 fs::remove_dir_all(&base).unwrap();
1495 }
1496 let root = base.join("project");
1497 let workspace = base.join("workspace");
1498 fs::create_dir_all(root.join("src")).unwrap();
1499 fs::create_dir_all(workspace.join("src")).unwrap();
1500 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1501 fs::write(root.join("src/app.ts"), "original\n").unwrap();
1502 fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
1503 fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
1504 (root, workspace)
1505 }
1506
1507 #[test]
1508 fn the_workspace_has_a_repository_of_its_own_that_reads_like_the_project() {
1509 let (root, workspace) = writeback_fixture("git-mirror");
1510 let git = |directory: &Path, arguments: &[&str]| {
1511 let output = std::process::Command::new("git")
1512 .arg("-C")
1513 .arg(directory)
1514 .args(arguments)
1515 .output()
1516 .unwrap();
1517 assert!(output.status.success(), "git {arguments:?}: {output:?}");
1518 String::from_utf8(output.stdout).unwrap().trim().to_owned()
1519 };
1520 git(&root, &["init", "--quiet", "--initial-branch=release/v2"]);
1521 git(&root, &["config", "user.email", "test@example.com"]);
1522 git(&root, &["config", "user.name", "Test"]);
1523 git(
1524 &root,
1525 &[
1526 "remote",
1527 "add",
1528 "origin",
1529 "https://github.com/example/app.git",
1530 ],
1531 );
1532 git(&root, &["add", "src/app.ts"]);
1533 git(&root, &["commit", "--quiet", "-m", "first"]);
1534 let head = git(&root, &["rev-parse", "HEAD"]);
1535
1536 mirror_git_repository(&root, &workspace).unwrap();
1537 assert_eq!(git(&workspace, &["rev-parse", "HEAD"]), head);
1538 assert_eq!(
1539 git(&workspace, &["symbolic-ref", "HEAD"]),
1540 "refs/heads/release/v2"
1541 );
1542 assert_eq!(
1543 git(&workspace, &["config", "--get", "remote.origin.url"]),
1544 "https://github.com/example/app.git"
1545 );
1546 hide_rewritten_files(&workspace, &["src/app.ts".to_owned()]);
1548 assert_eq!(git(&workspace, &["status", "--porcelain"]), "");
1549
1550 fs::write(workspace.join("new.txt"), "new\n").unwrap();
1552 git(&workspace, &["add", "new.txt"]);
1553 git(
1554 &workspace,
1555 &[
1556 "-c",
1557 "user.email=t@example.com",
1558 "-c",
1559 "user.name=T",
1560 "commit",
1561 "--quiet",
1562 "-m",
1563 "second",
1564 ],
1565 );
1566 assert_eq!(git(&root, &["rev-parse", "HEAD"]), head);
1567 assert_eq!(git(&root, &["status", "--porcelain"]), "");
1568 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1569 }
1570
1571 #[test]
1572 fn an_install_the_command_made_does_not_flow_back_without_its_dependencies() {
1573 let (root, workspace) = writeback_fixture("tool-install");
1574 fs::write(workspace.join("package.json"), "{}").unwrap();
1575 fs::write(root.join("package.json"), "{}").unwrap();
1576 fs::create_dir_all(workspace.join("node_modules")).unwrap();
1577 fs::create_dir_all(root.join("node_modules")).unwrap();
1578 let baseline = workspace_output_baseline(&workspace).unwrap();
1579 fs::create_dir_all(workspace.join(".tap/plugins/node_modules/@tapjs/clock")).unwrap();
1581 fs::write(workspace.join(".tap/plugins/package.json"), "{}").unwrap();
1582 fs::write(workspace.join(".tap/plugins/package-lock.json"), "{}").unwrap();
1583 fs::create_dir_all(workspace.join(".tap/test-results")).unwrap();
1584 fs::write(workspace.join(".tap/test-results/basic.tap"), "ok").unwrap();
1585 fs::write(workspace.join("package.json"), "{\"version\":\"2\"}").unwrap();
1587 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1588 assert!(!root.join(".tap/plugins/package.json").exists());
1589 assert!(!root.join(".tap/plugins/package-lock.json").exists());
1590 assert!(root.join(".tap/test-results/basic.tap").is_file());
1591 assert_eq!(
1592 fs::read_to_string(root.join("package.json")).unwrap(),
1593 "{\"version\":\"2\"}"
1594 );
1595 assert_eq!(sync.synced, 2);
1596 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1597 }
1598
1599 #[test]
1600 fn dependency_trees_never_flow_back() {
1601 let (root, workspace) = writeback_fixture("dependencies");
1604 let baseline = workspace_output_baseline(&workspace).unwrap();
1605 fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
1606 fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
1607 fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
1608 fs::write(
1609 workspace.join("packages/app/node_modules/dep/index.js"),
1610 "dep",
1611 )
1612 .unwrap();
1613 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1614 assert_eq!(sync.synced, 0);
1615 assert!(sync.deleted_in_workspace.is_empty());
1616 assert!(!root.join("node_modules").exists());
1617 assert!(!root.join("packages").exists());
1618 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1619 }
1620
1621 #[test]
1622 fn output_built_from_instrumented_sources_never_flows_back() {
1623 let (root, workspace) = writeback_fixture("built-output");
1629 let baseline = workspace_output_baseline(&workspace).unwrap();
1630 fs::create_dir_all(workspace.join("dist")).unwrap();
1631 fs::write(
1632 workspace.join("dist/app.js"),
1633 "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
1634 )
1635 .unwrap();
1636 fs::write(
1637 workspace.join("dist/app.d.ts"),
1638 "export declare const a: number;\n",
1639 )
1640 .unwrap();
1641
1642 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1643
1644 assert_eq!(
1645 sync.skipped_instrumented,
1646 vec![PathBuf::from("dist/app.js")],
1647 "the instrumented artifact is reported, not copied"
1648 );
1649 assert!(!root.join("dist/app.js").exists());
1650 assert_eq!(sync.synced, 1);
1653 assert!(root.join("dist/app.d.ts").exists());
1654 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1655 }
1656
1657 #[test]
1658 fn command_outputs_flow_back_to_the_project() {
1659 let (root, workspace) = writeback_fixture("outputs");
1660 let baseline = workspace_output_baseline(&workspace).unwrap();
1661 fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
1662 fs::write(
1663 workspace.join("src/__snapshots__/app.snap"),
1664 "updated snapshot\n",
1665 )
1666 .unwrap();
1667 fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
1668 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1669 assert_eq!(sync.synced, 1);
1670 assert_eq!(
1671 fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
1672 "updated snapshot\n"
1673 );
1674 assert!(!root.join(".supercov/state.json").exists());
1675 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1676 }
1677
1678 #[test]
1679 fn instrumented_sources_never_flow_back() {
1680 let (root, workspace) = writeback_fixture("protected");
1681 let baseline = workspace_output_baseline(&workspace).unwrap();
1682 fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
1685 let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
1686 let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
1687 assert_eq!(sync.synced, 0);
1688 assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
1689 assert_eq!(
1690 fs::read_to_string(root.join("src/app.ts")).unwrap(),
1691 "original\n"
1692 );
1693 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1694 }
1695
1696 #[test]
1697 fn workspace_deletions_are_reported_but_never_propagated() {
1698 let (root, workspace) = writeback_fixture("deletions");
1699 fs::write(workspace.join("stale.txt"), "old\n").unwrap();
1700 fs::write(root.join("stale.txt"), "old\n").unwrap();
1701 let baseline = workspace_output_baseline(&workspace).unwrap();
1702 fs::remove_file(workspace.join("stale.txt")).unwrap();
1703 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1704 assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
1705 assert!(root.join("stale.txt").exists());
1706 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1707 }
1708
1709 #[cfg(unix)]
1710 #[test]
1711 fn writeback_refuses_a_symlinked_destination() {
1712 let (root, workspace) = writeback_fixture("symlink");
1713 let outside = root.parent().unwrap().join("outside");
1714 fs::create_dir_all(&outside).unwrap();
1715 std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
1716 let baseline = workspace_output_baseline(&workspace).unwrap();
1717 fs::create_dir_all(workspace.join("reports")).unwrap();
1718 fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
1719 let error =
1720 sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
1721 assert!(matches!(error, WorkspaceError::UnsafePath(_)));
1722 assert!(!outside.join("result.txt").exists());
1723 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1724 }
1725
1726 struct OrdinaryCopyOperations;
1727
1728 impl WorkspaceOperations for OrdinaryCopyOperations {
1729 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1730 fs::copy(source, destination)
1731 .map(|_| ())
1732 .map_err(|error| io_error(destination, error))
1733 }
1734
1735 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1736 atomic_rename(source, destination).map_err(WorkspaceError::from)
1737 }
1738 }
1739
1740 struct FaultOperations {
1741 copy_count: usize,
1742 fail_copy_at: Option<usize>,
1743 rename_count: usize,
1744 fail_rename_at: Option<usize>,
1745 }
1746
1747 impl WorkspaceOperations for FaultOperations {
1748 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1749 self.copy_count += 1;
1750 if self.fail_copy_at == Some(self.copy_count) {
1751 return Err(io_error(
1752 destination,
1753 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
1754 ));
1755 }
1756 SystemWorkspaceOperations.copy_file(source, destination)
1757 }
1758
1759 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1760 self.rename_count += 1;
1761 if self.fail_rename_at == Some(self.rename_count) {
1762 return Err(io_error(
1763 destination,
1764 io::Error::other("injected publication rename failure"),
1765 ));
1766 }
1767 SystemWorkspaceOperations.rename(source, destination)
1768 }
1769 }
1770
1771 fn transaction_debris(root: &Path) -> Vec<String> {
1772 let workspace = cached_workspace_path(root).unwrap();
1773 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
1774 fs::read_dir(workspace.parent().unwrap())
1775 .unwrap()
1776 .filter_map(Result::ok)
1777 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1778 .filter(|name| name.starts_with(&prefix))
1779 .collect()
1780 }
1781
1782 fn project() -> PathBuf {
1783 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
1784 fs::create_dir_all(root.join("src")).unwrap();
1785 fs::write(root.join("src/index.js"), "one").unwrap();
1786 fs::write(root.join("package.json"), "{}").unwrap();
1787 root
1788 }
1789
1790 #[test]
1791 #[cfg(unix)]
1792 fn root_node_modules_symlink_is_followed() {
1793 let root = project();
1798 let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
1799 fs::create_dir_all(store.join("left-pad")).unwrap();
1800 fs::write(store.join("left-pad/package.json"), "{}").unwrap();
1801 std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
1802 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
1803 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
1804 assert!(
1805 fs::symlink_metadata(workspace.join("node_modules/left-pad"))
1806 .unwrap()
1807 .file_type()
1808 .is_symlink()
1809 );
1810 lock.release().unwrap();
1811 fs::remove_dir_all(root).unwrap();
1812 fs::remove_dir_all(store).unwrap();
1813 }
1814
1815 #[test]
1816 #[cfg(unix)]
1817 fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
1818 let root = project();
1825 let nested = root.join("examples/app/node_modules/@scope");
1826 fs::create_dir_all(&nested).unwrap();
1827 std::os::unix::fs::symlink(
1828 "../../../../packages/react/node_modules/@scope/pkg",
1829 nested.join("pkg"),
1830 )
1831 .unwrap();
1832 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
1833 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
1834 let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
1835 let metadata = fs::symlink_metadata(&mirrored).unwrap();
1836 assert!(metadata.file_type().is_symlink());
1837 assert_eq!(
1838 fs::read_link(&mirrored).unwrap(),
1839 PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
1840 );
1841 lock.release().unwrap();
1842 fs::remove_dir_all(root).unwrap();
1843 }
1844
1845 #[test]
1846 #[cfg(target_os = "macos")]
1847 fn nested_node_modules_are_cloned_not_linked() {
1848 let root = project();
1854 let nested = root.join("packages/app/node_modules");
1855 fs::create_dir_all(nested.join("dep")).unwrap();
1856 fs::create_dir_all(nested.join(".bin")).unwrap();
1857 fs::write(nested.join("dep/index.js"), "dep").unwrap();
1858 std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
1859 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
1860 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
1861 let mirrored = workspace.join("packages/app/node_modules");
1862 assert!(
1863 fs::symlink_metadata(mirrored.join("dep"))
1864 .unwrap()
1865 .file_type()
1866 .is_dir()
1867 );
1868 assert_eq!(
1869 fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
1870 "dep"
1871 );
1872 assert_eq!(
1873 fs::read_link(mirrored.join(".bin/dep")).unwrap(),
1874 PathBuf::from("../dep/index.js")
1875 );
1876 fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
1877 assert_eq!(
1878 fs::read_to_string(nested.join("dep/index.js")).unwrap(),
1879 "dep"
1880 );
1881 lock.release().unwrap();
1882 fs::remove_dir_all(root).unwrap();
1883 }
1884
1885 #[test]
1886 #[cfg(unix)]
1887 fn hard_linked_dependency_tree_is_real_and_replaceable() {
1888 use std::os::unix::fs::MetadataExt;
1892 let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
1893 let source = base.join("node_modules");
1894 fs::create_dir_all(source.join("dep")).unwrap();
1895 fs::create_dir_all(source.join(".bin")).unwrap();
1896 fs::write(source.join("dep/index.js"), "dep").unwrap();
1897 std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
1898 let destination = base.join("workspace/node_modules");
1899 fs::create_dir_all(destination.parent().unwrap()).unwrap();
1900 assert!(hard_link_tree(&source, &destination));
1901 assert_eq!(
1902 fs::metadata(destination.join("dep/index.js"))
1903 .unwrap()
1904 .ino(),
1905 fs::metadata(source.join("dep/index.js")).unwrap().ino()
1906 );
1907 assert_eq!(
1908 fs::read_link(destination.join(".bin/dep")).unwrap(),
1909 PathBuf::from("../dep/index.js")
1910 );
1911 fs::remove_dir_all(destination.join("dep")).unwrap();
1912 fs::create_dir_all(destination.join("dep")).unwrap();
1913 fs::write(destination.join("dep/index.js"), "replaced").unwrap();
1914 assert_eq!(
1915 fs::read_to_string(source.join("dep/index.js")).unwrap(),
1916 "dep"
1917 );
1918 fs::remove_dir_all(base).unwrap();
1919 }
1920
1921 #[test]
1922 #[cfg(unix)]
1923 fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
1924 let root = project();
1930 let nested = root.join("examples/app/lib");
1931 fs::create_dir_all(&nested).unwrap();
1932 std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
1933 .unwrap();
1934 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
1935 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
1936 assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
1937 assert!(workspace.join("src/index.js").exists());
1938 lock.release().unwrap();
1939 fs::remove_dir_all(root).unwrap();
1940 }
1941
1942 #[test]
1943 fn isolated_copy_never_changes_source_and_requires_the_lock() {
1944 let root = project();
1945 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
1946 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
1947 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
1948 assert_eq!(
1949 fs::read_to_string(root.join("src/index.js")).unwrap(),
1950 "one"
1951 );
1952 lock.release().unwrap();
1953 assert!(matches!(
1954 prepare_isolated_workspace(&root, "other", &lock),
1955 Err(WorkspaceError::MissingLock)
1956 ));
1957 fs::remove_dir_all(root).unwrap();
1958 }
1959
1960 #[test]
1961 fn everything_supercov_writes_lives_under_the_store() {
1962 let root = project();
1963 assert_eq!(
1964 workspace_container(&root),
1965 root.join(".supercov/workspaces")
1966 );
1967 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
1968 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
1969 assert!(workspace.starts_with(root.join(".supercov")));
1970 assert_eq!(
1971 fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
1972 "*\n"
1973 );
1974 lock.release().unwrap();
1975 fs::remove_dir_all(root).unwrap();
1976 }
1977
1978 #[test]
1979 fn an_unowned_container_gets_a_deterministic_fallback() {
1980 let root = project();
1981 fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
1982 fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
1983 let container = workspace_container(&root);
1984 assert_ne!(container, root.join(".supercov/workspaces"));
1985 let name = container
1986 .file_name()
1987 .unwrap()
1988 .to_string_lossy()
1989 .into_owned();
1990 assert!(name.starts_with("workspaces-"));
1991 fs::remove_dir_all(root).unwrap();
1992 }
1993
1994 #[test]
1995 fn user_supercov_directory_is_copied_and_never_adopted() {
1996 let root = project();
1997 fs::create_dir(root.join("supercov")).unwrap();
1998 fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
1999 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2000 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2001 assert_ne!(workspace_container(&root), root.join("supercov"));
2002 assert_eq!(
2003 fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
2004 "export default 1;\n"
2005 );
2006 assert_eq!(
2007 fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
2008 "export default 1;\n"
2009 );
2010 assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
2011 lock.release().unwrap();
2012 fs::remove_dir_all(root).unwrap();
2013 }
2014
2015 #[cfg(unix)]
2016 #[test]
2017 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
2018 let root = project();
2019 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2020 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2021 fs::create_dir_all(first.join("build")).unwrap();
2022 fs::write(first.join("build/output.js"), "instrumented").unwrap();
2023 fs::write(first.join("stale.txt"), "stale").unwrap();
2024 fs::write(root.join("src/index.js"), "two").unwrap();
2025 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
2026 assert_eq!(first, second);
2027 assert_eq!(
2028 fs::read_to_string(second.join("src/index.js")).unwrap(),
2029 "two"
2030 );
2031 assert_eq!(
2032 fs::read_to_string(second.join("build/output.js")).unwrap(),
2033 "instrumented"
2034 );
2035 assert!(!second.join("stale.txt").exists());
2036 lock.release().unwrap();
2037 fs::remove_dir_all(root).unwrap();
2038 }
2039
2040 #[test]
2041 fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
2042 let root = project();
2043 fs::create_dir_all(root.join("packages/app/dist")).unwrap();
2044 fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
2045 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2046 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2047 fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
2048 fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
2049 let second =
2050 prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
2051 assert_eq!(
2052 fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
2053 "instrumented"
2054 );
2055 assert!(!second.join("packages/app/dist/stale.js").exists());
2056 lock.release().unwrap();
2057 fs::remove_dir_all(root).unwrap();
2058 }
2059
2060 #[test]
2061 fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
2062 let root = project();
2063 fs::create_dir_all(root.join("tests/e2e")).unwrap();
2064 fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
2065 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2066 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2067 let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
2068 fs::create_dir_all(&artifacts).unwrap();
2069 fs::write(artifacts.join("digest"), "instrumented test").unwrap();
2070 fs::write(
2071 workspace.join(".supercov/frontend-cache.json"),
2072 "{\"schemaVersion\":2}",
2073 )
2074 .unwrap();
2075 prune_cached_workspace_sources(&root, &lock).unwrap();
2076 assert!(!workspace.join("tests/e2e/app.spec.js").exists());
2077 assert_eq!(
2078 fs::read_to_string(artifacts.join("digest")).unwrap(),
2079 "instrumented test"
2080 );
2081 lock.release().unwrap();
2082 fs::remove_dir_all(root).unwrap();
2083 }
2084
2085 #[test]
2086 fn ordinary_copy_fallback_preserves_workspace_semantics() {
2087 let root = project();
2088 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2089 let mut operations = OrdinaryCopyOperations;
2090 let workspace =
2091 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
2092 assert_eq!(
2093 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2094 "one"
2095 );
2096 assert_eq!(
2097 fs::read_to_string(root.join("src/index.js")).unwrap(),
2098 "one"
2099 );
2100 assert!(transaction_debris(&root).is_empty());
2101 lock.release().unwrap();
2102 fs::remove_dir_all(root).unwrap();
2103 }
2104
2105 #[test]
2106 fn enospc_during_copy_preserves_the_complete_generation() {
2107 let root = project();
2108 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2109 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2110 fs::write(workspace.join("generation"), "complete").unwrap();
2111 fs::write(root.join("src/index.js"), "new source").unwrap();
2112 let mut operations = FaultOperations {
2113 copy_count: 0,
2114 fail_copy_at: Some(1),
2115 rename_count: 0,
2116 fail_rename_at: None,
2117 };
2118 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2119 .unwrap_err();
2120 assert!(matches!(
2121 error,
2122 WorkspaceError::Io { ref source, .. }
2123 if source.kind() == io::ErrorKind::StorageFull
2124 ));
2125 assert_eq!(
2126 fs::read_to_string(workspace.join("generation")).unwrap(),
2127 "complete"
2128 );
2129 assert_eq!(
2130 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2131 "one"
2132 );
2133 assert!(transaction_debris(&root).is_empty());
2134 lock.release().unwrap();
2135 fs::remove_dir_all(root).unwrap();
2136 }
2137
2138 #[test]
2139 fn failed_publication_rename_restores_the_complete_generation() {
2140 let root = project();
2141 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2142 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2143 fs::write(workspace.join("generation"), "complete").unwrap();
2144 fs::write(root.join("src/index.js"), "new source").unwrap();
2145 let mut operations = FaultOperations {
2146 copy_count: 0,
2147 fail_copy_at: None,
2148 rename_count: 0,
2149 fail_rename_at: Some(2),
2150 };
2151 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2152 .unwrap_err();
2153 assert!(matches!(
2154 error,
2155 WorkspaceError::Io { ref source, .. }
2156 if source.kind() == io::ErrorKind::Other
2157 ));
2158 assert_eq!(
2159 operations.rename_count, 3,
2160 "prior generation was not restored"
2161 );
2162 assert_eq!(
2163 fs::read_to_string(workspace.join("generation")).unwrap(),
2164 "complete"
2165 );
2166 assert_eq!(
2167 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2168 "one"
2169 );
2170 assert!(transaction_debris(&root).is_empty());
2171 lock.release().unwrap();
2172 fs::remove_dir_all(root).unwrap();
2173 }
2174
2175 #[cfg(unix)]
2176 #[test]
2177 fn relocates_internal_links_and_rejects_external_links() {
2178 use std::os::unix::fs::symlink;
2179
2180 let root = project();
2181 fs::create_dir_all(root.join("shared")).unwrap();
2182 fs::write(root.join("shared/value"), "inside").unwrap();
2183 symlink("../shared/value", root.join("src/value-link")).unwrap();
2184 let external = project();
2185 fs::write(external.join("outside"), "outside").unwrap();
2186 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2187 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2188 assert_eq!(
2189 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
2190 "inside"
2191 );
2192 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
2193 let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2194 assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
2195 assert_eq!(
2196 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2197 "one"
2198 );
2199 lock.release().unwrap();
2200 fs::remove_dir_all(root).unwrap();
2201 fs::remove_dir_all(external).unwrap();
2202 }
2203
2204 #[cfg(windows)]
2205 #[test]
2206 fn relocates_internal_junctions_without_symlink_privileges() {
2207 let root = project();
2208 fs::create_dir_all(root.join("shared")).unwrap();
2209 fs::write(root.join("shared/value"), "inside").unwrap();
2210 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
2211 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2212 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2213 let isolated_link = workspace.join("linked-shared");
2214 assert!(junction::exists(&isolated_link).unwrap());
2215 assert_eq!(
2216 canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
2217 canonicalize_simplified(workspace.join("shared")).unwrap()
2218 );
2219 assert_eq!(
2220 fs::read_to_string(isolated_link.join("value")).unwrap(),
2221 "inside"
2222 );
2223 lock.release().unwrap();
2224 fs::remove_dir_all(root).unwrap();
2225 }
2226
2227 #[cfg(windows)]
2228 #[test]
2229 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
2230 let root = project();
2231 fs::create_dir_all(root.join("node_modules/example")).unwrap();
2232 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
2233 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
2234 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2235 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2236 let package = workspace.join("node_modules/example");
2237 assert!(junction::exists(&package).unwrap());
2238 assert_eq!(
2239 canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
2240 canonicalize_simplified(root.join("node_modules/example")).unwrap()
2241 );
2242 let metadata = workspace.join("node_modules/.package-lock.json");
2243 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
2244 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
2245 lock.release().unwrap();
2246 fs::remove_dir_all(root).unwrap();
2247 }
2248
2249 #[test]
2250 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
2251 let root = project();
2252 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2253 ensure_container(&root).unwrap();
2254 let workspace = cached_workspace_path(&root).unwrap();
2255 let parent = workspace.parent().unwrap();
2256 fs::create_dir_all(parent).unwrap();
2257 let previous = parent.join(format!(
2258 "{}old",
2259 transaction_prefix(&root, "previous").unwrap()
2260 ));
2261 let staging = parent.join(format!(
2262 "{}new",
2263 transaction_prefix(&root, "staging").unwrap()
2264 ));
2265 fs::create_dir_all(&previous).unwrap();
2266 fs::write(previous.join("complete"), "yes").unwrap();
2267 fs::create_dir_all(&staging).unwrap();
2268 let result = recover_cached_workspace(&root, &lock).unwrap();
2269 assert!(result.restored_previous);
2270 assert_eq!(result.removed_staging, 1);
2271 assert_eq!(
2272 fs::read_to_string(workspace.join("complete")).unwrap(),
2273 "yes"
2274 );
2275 lock.release().unwrap();
2276 fs::remove_dir_all(root).unwrap();
2277 }
2278}