1use std::{
4 collections::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};
13
14use crate::lifecycle::{LifecycleError, ProjectLock, atomic_rename, remove_stored_tree_deferred};
15
16const WORKSPACE_MARKER: &str = ".supercov-workspace-store";
17const ROOT_EXCLUSIONS: &[&str] = &[
18 ".cache",
19 ".git",
20 ".supercov",
21 ".mcdc-pool",
22 "node_modules",
23 "build",
24 "dist",
25 ".next",
26 ".nuxt",
27 ".output",
28 "coverage",
29 "playwright-report",
30 "test-results",
31];
32const NESTED_EXCLUSIONS: &[&str] = &[".supercov", ".mcdc-pool"];
33static UNIQUE: AtomicU64 = AtomicU64::new(0);
34
35#[derive(Debug)]
36pub enum WorkspaceError {
37 Io { path: PathBuf, source: io::Error },
38 UnsafePath(PathBuf),
39 EscapingLink { path: PathBuf, target: PathBuf },
40 UnsupportedEntry(PathBuf),
41 MissingLock,
42 Lifecycle(LifecycleError),
43 InvalidCacheMetadata(serde_json::Error),
44 UnsupportedPlatform(&'static str),
45}
46
47impl std::fmt::Display for WorkspaceError {
48 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
51 Self::UnsafePath(path) => {
52 write!(formatter, "unsafe workspace path: {}", path.display())
53 }
54 Self::EscapingLink { path, target } => write!(
55 formatter,
56 "refusing to preserve symlink outside the isolated project: {} -> {}",
57 path.display(),
58 target.display()
59 ),
60 Self::UnsupportedEntry(path) => {
61 write!(
62 formatter,
63 "unsupported filesystem entry in isolated project: {}",
64 path.display()
65 )
66 }
67 Self::MissingLock => write!(
68 formatter,
69 "isolated workspace preparation requires the active project lock"
70 ),
71 Self::Lifecycle(error) => write!(formatter, "{error}"),
72 Self::InvalidCacheMetadata(error) => {
73 write!(formatter, "invalid build-cache metadata: {error}")
74 }
75 Self::UnsupportedPlatform(reason) => {
76 write!(formatter, "unsupported workspace platform: {reason}")
77 }
78 }
79 }
80}
81
82impl std::error::Error for WorkspaceError {}
83
84impl From<LifecycleError> for WorkspaceError {
85 fn from(value: LifecycleError) -> Self {
86 Self::Lifecycle(value)
87 }
88}
89
90fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
91 WorkspaceError::Io {
92 path: path.to_owned(),
93 source,
94 }
95}
96
97fn unique() -> String {
98 let nanos = SystemTime::now()
99 .duration_since(UNIX_EPOCH)
100 .unwrap_or_default()
101 .as_nanos();
102 format!(
103 "{}-{nanos}-{}",
104 std::process::id(),
105 UNIQUE.fetch_add(1, Ordering::Relaxed)
106 )
107}
108
109fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
110 root.file_name()
111 .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
112}
113
114pub fn workspace_container(root: &Path) -> PathBuf {
115 root.join("supercov")
116}
117
118pub fn cached_workspace_path(root: &Path) -> Result<PathBuf, WorkspaceError> {
119 Ok(workspace_container(root)
120 .join("workspace")
121 .join(project_name(root)?))
122}
123
124pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
125 if run_id.is_empty()
126 || run_id == "."
127 || run_id == ".."
128 || run_id
129 .chars()
130 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
131 {
132 return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
133 }
134 Ok(root
135 .join(".supercov/work")
136 .join(run_id)
137 .join(project_name(root)?))
138}
139
140fn marker_owned(path: &Path) -> bool {
141 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
142 && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
143 .is_ok_and(|metadata| metadata.file_type().is_file())
144}
145
146fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
147 let container = workspace_container(root);
148 match fs::symlink_metadata(&container) {
149 Ok(metadata) if !metadata.file_type().is_dir() => {
150 return Err(WorkspaceError::UnsafePath(container));
151 }
152 Ok(_) => {}
153 Err(error) if error.kind() == io::ErrorKind::NotFound => {
154 fs::create_dir(&container).map_err(|source| io_error(&container, source))?;
155 }
156 Err(source) => return Err(io_error(&container, source)),
157 }
158 for (name, contents) in [
159 (".gitignore", "*\n"),
160 (
161 WORKSPACE_MARKER,
162 "Supercov instrumented workspace. Safe to delete.\n",
163 ),
164 ] {
165 let path = container.join(name);
166 match OpenOptions::new().write(true).create_new(true).open(&path) {
167 Ok(mut file) => file
168 .write_all(contents.as_bytes())
169 .and_then(|_| file.sync_all())
170 .map_err(|source| io_error(&path, source))?,
171 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
172 if !fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file())
173 {
174 return Err(WorkspaceError::UnsafePath(path));
175 }
176 }
177 Err(source) => return Err(io_error(&path, source)),
178 }
179 }
180 Ok(container)
181}
182
183fn lexical_normalize(path: &Path) -> Option<PathBuf> {
184 let mut normalized = PathBuf::new();
185 for component in path.components() {
186 match component {
187 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
188 Component::RootDir => normalized.push(component.as_os_str()),
189 Component::CurDir => {}
190 Component::ParentDir => {
191 if !normalized.pop() {
192 return None;
193 }
194 }
195 Component::Normal(value) => normalized.push(value),
196 }
197 }
198 Some(normalized)
199}
200
201fn inside(root: &Path, path: &Path) -> bool {
202 path == root
203 || path.strip_prefix(root).is_ok_and(|local| {
204 !local.as_os_str().is_empty()
205 && local
206 .components()
207 .all(|component| matches!(component, Component::Normal(_)))
208 })
209}
210
211trait WorkspaceOperations {
212 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
213 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
214}
215
216struct SystemWorkspaceOperations;
217
218impl WorkspaceOperations for SystemWorkspaceOperations {
219 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
220 reflink_copy::reflink_or_copy(source, destination)
221 .map(|_| ())
222 .map_err(|source_error| io_error(destination, source_error))
223 }
224
225 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
226 atomic_rename(source, destination).map_err(WorkspaceError::from)
227 }
228}
229
230#[cfg(unix)]
231fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
232 std::os::unix::fs::symlink(target, destination)
233}
234
235#[cfg(windows)]
236fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
237 if directory {
238 match junction::create(target, destination) {
245 Ok(()) => Ok(()),
246 Err(junction_error) => {
247 let _ = fs::remove_dir(destination);
248 std::os::windows::fs::symlink_dir(target, destination).map_err(
249 |symlink_error| {
250 io::Error::new(
251 symlink_error.kind(),
252 format!(
253 "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
254 ),
255 )
256 },
257 )
258 }
259 }
260 } else {
261 std::os::windows::fs::symlink_file(target, destination)
265 }
266}
267
268#[derive(Clone, Copy)]
269struct CopyRoots<'a> {
270 source: &'a Path,
271 destination: &'a Path,
272 final_destination: &'a Path,
273 canonical_source: &'a Path,
274}
275
276fn copy_tree<Operations: WorkspaceOperations>(
277 source: &Path,
278 destination: &Path,
279 roots: CopyRoots<'_>,
280 root_level: bool,
281 operations: &mut Operations,
282) -> Result<(), WorkspaceError> {
283 fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
284 let mut entries = fs::read_dir(source)
285 .map_err(|error| io_error(source, error))?
286 .collect::<Result<Vec<_>, _>>()
287 .map_err(|error| io_error(source, error))?;
288 entries.sort_by_key(fs::DirEntry::file_name);
289 for entry in entries {
290 let name = entry.file_name();
291 let name_text = name
292 .to_str()
293 .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
294 if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
295 || NESTED_EXCLUSIONS.contains(&name_text)
296 {
297 continue;
298 }
299 let from = entry.path();
300 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
301 if metadata.file_type().is_dir() && marker_owned(&from) {
302 continue;
303 }
304 let to = destination.join(&name);
305 if metadata.file_type().is_dir() {
306 copy_tree(&from, &to, roots, false, operations)?;
307 } else if metadata.file_type().is_symlink() {
308 let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
309 let unresolved_target = if link.is_absolute() {
310 link.clone()
311 } else {
312 from.parent().expect("entry parent").join(&link)
313 };
314 let lexical_target = lexical_normalize(&unresolved_target).ok_or_else(|| {
315 WorkspaceError::EscapingLink {
316 path: from.clone(),
317 target: link.clone(),
318 }
319 })?;
320 let canonical_target =
321 fs::canonicalize(&from).map_err(|error| io_error(&from, error))?;
322 if !inside(roots.source, &lexical_target)
323 || !inside(roots.canonical_source, &canonical_target)
324 {
325 return Err(WorkspaceError::EscapingLink {
326 path: from,
327 target: link,
328 });
329 }
330 let local_target = lexical_target
331 .strip_prefix(roots.source)
332 .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
333 let relocated = roots.destination.join(local_target);
334 let target_metadata = fs::metadata(&canonical_target)
335 .map_err(|error| io_error(&canonical_target, error))?;
336 let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
337 roots.final_destination.join(local_target)
338 } else if link.is_absolute() {
339 pathdiff(&to, &relocated)?
340 } else {
341 link
342 };
343 create_link(&isolated_link, &to, target_metadata.is_dir())
344 .map_err(|error| io_error(&to, error))?;
345 } else if metadata.file_type().is_file() {
346 operations.copy_file(&from, &to)?;
347 } else {
348 return Err(WorkspaceError::UnsupportedEntry(from));
349 }
350 }
351 Ok(())
352}
353
354fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
355 let from = from
356 .parent()
357 .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
358 let from_components = from.components().collect::<Vec<_>>();
359 let to_components = to.components().collect::<Vec<_>>();
360 let common = from_components
361 .iter()
362 .zip(&to_components)
363 .take_while(|(left, right)| left == right)
364 .count();
365 if common == 0 {
366 return Err(WorkspaceError::UnsafePath(to.into()));
367 }
368 let mut relative = PathBuf::new();
369 for _ in common..from_components.len() {
370 relative.push("..");
371 }
372 for component in &to_components[common..] {
373 relative.push(component.as_os_str());
374 }
375 Ok(relative)
376}
377
378fn link_node_modules<Operations: WorkspaceOperations>(
379 root: &Path,
380 workspace: &Path,
381 operations: &mut Operations,
382) -> Result<(), WorkspaceError> {
383 #[cfg(unix)]
384 let _ = &operations;
385 let source = root.join("node_modules");
386 let source_metadata = match fs::symlink_metadata(&source) {
387 Ok(metadata) => metadata,
388 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
389 Err(error) => return Err(io_error(&source, error)),
390 };
391 if !source_metadata.file_type().is_dir() {
392 return Err(WorkspaceError::UnsafePath(source));
393 }
394 let destination = workspace.join("node_modules");
395 fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
396 let mut entries = fs::read_dir(&source)
397 .map_err(|error| io_error(&source, error))?
398 .collect::<Result<Vec<_>, _>>()
399 .map_err(|error| io_error(&source, error))?;
400 entries.sort_by_key(fs::DirEntry::file_name);
401 for entry in entries {
402 let target = entry.path();
403 let to = destination.join(entry.file_name());
404 #[cfg(unix)]
405 create_link(&target, &to, false).map_err(|error| io_error(&to, error))?;
406 #[cfg(windows)]
407 {
408 let file_type = entry
409 .file_type()
410 .map_err(|error| io_error(&target, error))?;
411 let resolved = if file_type.is_symlink() {
412 fs::canonicalize(&target).map_err(|error| io_error(&target, error))?
413 } else {
414 target.clone()
415 };
416 let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
417 if metadata.is_dir() {
418 create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
419 } else if metadata.is_file() {
420 operations.copy_file(&resolved, &to)?;
423 } else {
424 return Err(WorkspaceError::UnsupportedEntry(target));
425 }
426 }
427 }
428 Ok(())
429}
430
431fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
432 if lock.protects(root) {
433 Ok(())
434 } else {
435 Err(WorkspaceError::MissingLock)
436 }
437}
438
439pub fn prepare_isolated_workspace(
440 root: &Path,
441 run_id: &str,
442 lock: &ProjectLock,
443) -> Result<PathBuf, WorkspaceError> {
444 require_lock(root, lock)?;
445 let mut operations = SystemWorkspaceOperations;
446 let workspace = isolated_workspace_path(root, run_id)?;
447 remove_stored_tree_deferred(root, &workspace)?;
448 let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?;
449 copy_tree(
450 root,
451 &workspace,
452 CopyRoots {
453 source: root,
454 destination: &workspace,
455 final_destination: &workspace,
456 canonical_source: &canonical_root,
457 },
458 true,
459 &mut operations,
460 )?;
461 link_node_modules(root, &workspace, &mut operations)?;
462 Ok(workspace)
463}
464
465fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
466 Ok(format!(
467 ".{}.{}-",
468 project_name(root)?.to_string_lossy(),
469 kind
470 ))
471}
472
473fn transaction_path(root: &Path, kind: &str) -> Result<PathBuf, WorkspaceError> {
474 let workspace = cached_workspace_path(root)?;
475 Ok(workspace.parent().expect("workspace parent").join(format!(
476 "{}{}",
477 transaction_prefix(root, kind)?,
478 unique()
479 )))
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
483#[serde(rename_all = "camelCase")]
484pub struct CacheRecoveryResult {
485 pub restored_previous: bool,
486 pub removed_staging: usize,
487 pub removed_previous: usize,
488}
489
490pub fn recover_cached_workspace(
491 root: &Path,
492 lock: &ProjectLock,
493) -> Result<CacheRecoveryResult, WorkspaceError> {
494 require_lock(root, lock)?;
495 let workspace = cached_workspace_path(root)?;
496 let parent = workspace.parent().expect("workspace parent");
497 let entries = match fs::read_dir(parent) {
498 Ok(entries) => entries,
499 Err(error) if error.kind() == io::ErrorKind::NotFound => {
500 return Ok(CacheRecoveryResult {
501 restored_previous: false,
502 removed_staging: 0,
503 removed_previous: 0,
504 });
505 }
506 Err(error) => return Err(io_error(parent, error)),
507 };
508 let staging_prefix = transaction_prefix(root, "staging")?;
509 let previous_prefix = transaction_prefix(root, "previous")?;
510 let mut staging = Vec::new();
511 let mut previous = Vec::new();
512 let mut invalid_previous = Vec::new();
513 for entry in entries {
514 let entry = entry.map_err(|error| io_error(parent, error))?;
515 let name = entry.file_name().to_string_lossy().into_owned();
516 if name.starts_with(&staging_prefix) {
517 staging.push(entry.path());
518 } else if name.starts_with(&previous_prefix) {
519 let metadata = entry
520 .file_type()
521 .map_err(|error| io_error(&entry.path(), error))?;
522 if metadata.is_dir() {
523 let modified = entry
524 .metadata()
525 .and_then(|metadata| metadata.modified())
526 .unwrap_or(UNIX_EPOCH);
527 previous.push((modified, entry.path()));
528 } else {
529 invalid_previous.push(entry.path());
530 }
531 }
532 }
533 previous.sort_by(|left, right| right.0.cmp(&left.0));
534 let mut restored = false;
535 if fs::symlink_metadata(&workspace).is_err()
536 && let Some((_, newest)) = previous.first()
537 {
538 atomic_rename(newest, &workspace)?;
539 previous.remove(0);
540 restored = true;
541 }
542 let removed_staging = staging.len();
543 let removed_previous = previous.len() + invalid_previous.len();
544 for path in staging
545 .into_iter()
546 .chain(previous.into_iter().map(|(_, path)| path))
547 .chain(invalid_previous)
548 {
549 remove_stored_tree_deferred(root, &path)?;
550 }
551 Ok(CacheRecoveryResult {
552 restored_previous: restored,
553 removed_staging,
554 removed_previous,
555 })
556}
557
558fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
559 if requested.is_absolute()
560 || requested
561 .components()
562 .any(|component| !matches!(component, Component::Normal(_)))
563 {
564 return Err(WorkspaceError::UnsafePath(requested.into()));
565 }
566 let path = workspace.join(requested);
567 if path == workspace || fs::symlink_metadata(&path).is_err() {
568 return Err(WorkspaceError::UnsafePath(requested.into()));
569 }
570 Ok(path)
571}
572
573pub fn prepare_cached_workspace(
574 root: &Path,
575 lock: &ProjectLock,
576 reuse_paths: &[PathBuf],
577) -> Result<PathBuf, WorkspaceError> {
578 let mut operations = SystemWorkspaceOperations;
579 prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
580}
581
582fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
583 root: &Path,
584 lock: &ProjectLock,
585 reuse_paths: &[PathBuf],
586 operations: &mut Operations,
587) -> Result<PathBuf, WorkspaceError> {
588 require_lock(root, lock)?;
589 ensure_container(root)?;
590 recover_cached_workspace(root, lock)?;
591 let workspace = cached_workspace_path(root)?;
592 let staging = transaction_path(root, "staging")?;
593 let previous = transaction_path(root, "previous")?;
594 let result = (|| {
595 let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?;
596 copy_tree(
597 root,
598 &staging,
599 CopyRoots {
600 source: root,
601 destination: &staging,
602 final_destination: &workspace,
603 canonical_source: &canonical_root,
604 },
605 true,
606 operations,
607 )?;
608 link_node_modules(root, &staging, operations)?;
609 for requested in reuse_paths {
610 let from = checked_reuse_path(&workspace, requested)?;
611 let to = staging.join(requested);
612 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
613 if metadata.file_type().is_dir() {
614 let canonical_workspace =
615 fs::canonicalize(&workspace).map_err(|error| io_error(&workspace, error))?;
616 copy_tree(
617 &from,
618 &to,
619 CopyRoots {
620 source: &workspace,
621 destination: &staging,
622 final_destination: &workspace,
623 canonical_source: &canonical_workspace,
624 },
625 false,
626 operations,
627 )?;
628 } else if metadata.file_type().is_file() {
629 fs::create_dir_all(to.parent().expect("reuse parent"))
630 .map_err(|error| io_error(&to, error))?;
631 operations.copy_file(&from, &to)?;
632 } else {
633 return Err(WorkspaceError::UnsupportedEntry(from));
634 }
635 }
636 let mut moved_previous = false;
637 if fs::symlink_metadata(&workspace).is_ok() {
638 operations.rename(&workspace, &previous)?;
639 moved_previous = true;
640 }
641 if let Err(error) = operations.rename(&staging, &workspace) {
642 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
643 let _ = operations.rename(&previous, &workspace);
644 }
645 return Err(error);
646 }
647 if fs::symlink_metadata(&previous).is_ok() {
648 remove_stored_tree_deferred(root, &previous)?;
649 }
650 Ok(workspace.clone())
651 })();
652 if fs::symlink_metadata(&staging).is_ok() {
653 remove_stored_tree_deferred(root, &staging)?;
654 }
655 result
656}
657
658#[derive(Deserialize)]
659#[serde(rename_all = "camelCase")]
660struct BuildCacheMetadata {
661 #[serde(default)]
662 artifact_paths: Vec<String>,
663}
664
665pub fn prune_cached_workspace_sources(
666 root: &Path,
667 lock: &ProjectLock,
668) -> Result<Vec<String>, WorkspaceError> {
669 require_lock(root, lock)?;
670 let workspace = cached_workspace_path(root)?;
671 if fs::symlink_metadata(&workspace).is_err() {
672 return Ok(Vec::new());
673 }
674 let mut keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
675 let metadata_path = workspace.join(".supercov/build-cache.json");
676 if let Ok(bytes) = fs::read(&metadata_path) {
677 let metadata: BuildCacheMetadata =
678 serde_json::from_slice(&bytes).map_err(WorkspaceError::InvalidCacheMetadata)?;
679 for artifact in metadata.artifact_paths {
680 if let Some(top) = Path::new(&artifact)
681 .components()
682 .next()
683 .and_then(|component| match component {
684 Component::Normal(value) => value.to_str(),
685 _ => None,
686 })
687 {
688 keep.insert(top.into());
689 }
690 }
691 }
692 let mut removed = Vec::new();
693 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
694 let entry = entry.map_err(|error| io_error(&workspace, error))?;
695 let name = entry
696 .file_name()
697 .into_string()
698 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
699 if keep.contains(&name) {
700 continue;
701 }
702 remove_stored_tree_deferred(root, &entry.path())?;
703 removed.push(name);
704 }
705 removed.sort();
706 Ok(removed)
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 struct OrdinaryCopyOperations;
714
715 impl WorkspaceOperations for OrdinaryCopyOperations {
716 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
717 fs::copy(source, destination)
718 .map(|_| ())
719 .map_err(|error| io_error(destination, error))
720 }
721
722 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
723 atomic_rename(source, destination).map_err(WorkspaceError::from)
724 }
725 }
726
727 struct FaultOperations {
728 copy_count: usize,
729 fail_copy_at: Option<usize>,
730 rename_count: usize,
731 fail_rename_at: Option<usize>,
732 }
733
734 impl WorkspaceOperations for FaultOperations {
735 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
736 self.copy_count += 1;
737 if self.fail_copy_at == Some(self.copy_count) {
738 return Err(io_error(
739 destination,
740 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
741 ));
742 }
743 SystemWorkspaceOperations.copy_file(source, destination)
744 }
745
746 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
747 self.rename_count += 1;
748 if self.fail_rename_at == Some(self.rename_count) {
749 return Err(io_error(
750 destination,
751 io::Error::other("injected publication rename failure"),
752 ));
753 }
754 SystemWorkspaceOperations.rename(source, destination)
755 }
756 }
757
758 fn transaction_debris(root: &Path) -> Vec<String> {
759 let workspace = cached_workspace_path(root).unwrap();
760 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
761 fs::read_dir(workspace.parent().unwrap())
762 .unwrap()
763 .filter_map(Result::ok)
764 .map(|entry| entry.file_name().to_string_lossy().into_owned())
765 .filter(|name| name.starts_with(&prefix))
766 .collect()
767 }
768
769 fn project() -> PathBuf {
770 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
771 fs::create_dir_all(root.join("src")).unwrap();
772 fs::write(root.join("src/index.js"), "one").unwrap();
773 fs::write(root.join("package.json"), "{}").unwrap();
774 root
775 }
776
777 #[test]
778 fn isolated_copy_never_changes_source_and_requires_the_lock() {
779 let root = project();
780 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
781 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
782 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
783 assert_eq!(
784 fs::read_to_string(root.join("src/index.js")).unwrap(),
785 "one"
786 );
787 lock.release().unwrap();
788 assert!(matches!(
789 prepare_isolated_workspace(&root, "other", &lock),
790 Err(WorkspaceError::MissingLock)
791 ));
792 fs::remove_dir_all(root).unwrap();
793 }
794
795 #[test]
796 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
797 let root = project();
798 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
799 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
800 fs::create_dir_all(first.join("build")).unwrap();
801 fs::write(first.join("build/output.js"), "instrumented").unwrap();
802 fs::write(first.join("stale.txt"), "stale").unwrap();
803 fs::write(root.join("src/index.js"), "two").unwrap();
804 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
805 assert_eq!(first, second);
806 assert_eq!(
807 fs::read_to_string(second.join("src/index.js")).unwrap(),
808 "two"
809 );
810 assert_eq!(
811 fs::read_to_string(second.join("build/output.js")).unwrap(),
812 "instrumented"
813 );
814 assert!(!second.join("stale.txt").exists());
815 lock.release().unwrap();
816 fs::remove_dir_all(root).unwrap();
817 }
818
819 #[test]
820 fn ordinary_copy_fallback_preserves_workspace_semantics() {
821 let root = project();
822 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
823 let mut operations = OrdinaryCopyOperations;
824 let workspace =
825 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
826 assert_eq!(
827 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
828 "one"
829 );
830 assert_eq!(
831 fs::read_to_string(root.join("src/index.js")).unwrap(),
832 "one"
833 );
834 assert!(transaction_debris(&root).is_empty());
835 lock.release().unwrap();
836 fs::remove_dir_all(root).unwrap();
837 }
838
839 #[test]
840 fn enospc_during_copy_preserves_the_complete_generation() {
841 let root = project();
842 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
843 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
844 fs::write(workspace.join("generation"), "complete").unwrap();
845 fs::write(root.join("src/index.js"), "new source").unwrap();
846 let mut operations = FaultOperations {
847 copy_count: 0,
848 fail_copy_at: Some(1),
849 rename_count: 0,
850 fail_rename_at: None,
851 };
852 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
853 .unwrap_err();
854 assert!(matches!(
855 error,
856 WorkspaceError::Io { ref source, .. }
857 if source.kind() == io::ErrorKind::StorageFull
858 ));
859 assert_eq!(
860 fs::read_to_string(workspace.join("generation")).unwrap(),
861 "complete"
862 );
863 assert_eq!(
864 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
865 "one"
866 );
867 assert!(transaction_debris(&root).is_empty());
868 lock.release().unwrap();
869 fs::remove_dir_all(root).unwrap();
870 }
871
872 #[test]
873 fn failed_publication_rename_restores_the_complete_generation() {
874 let root = project();
875 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
876 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
877 fs::write(workspace.join("generation"), "complete").unwrap();
878 fs::write(root.join("src/index.js"), "new source").unwrap();
879 let mut operations = FaultOperations {
880 copy_count: 0,
881 fail_copy_at: None,
882 rename_count: 0,
883 fail_rename_at: Some(2),
884 };
885 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
886 .unwrap_err();
887 assert!(matches!(
888 error,
889 WorkspaceError::Io { ref source, .. }
890 if source.kind() == io::ErrorKind::Other
891 ));
892 assert_eq!(
893 operations.rename_count, 3,
894 "prior generation was not restored"
895 );
896 assert_eq!(
897 fs::read_to_string(workspace.join("generation")).unwrap(),
898 "complete"
899 );
900 assert_eq!(
901 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
902 "one"
903 );
904 assert!(transaction_debris(&root).is_empty());
905 lock.release().unwrap();
906 fs::remove_dir_all(root).unwrap();
907 }
908
909 #[cfg(unix)]
910 #[test]
911 fn relocates_internal_links_and_rejects_external_links() {
912 use std::os::unix::fs::symlink;
913
914 let root = project();
915 fs::create_dir_all(root.join("shared")).unwrap();
916 fs::write(root.join("shared/value"), "inside").unwrap();
917 symlink("../shared/value", root.join("src/value-link")).unwrap();
918 let external = project();
919 fs::write(external.join("outside"), "outside").unwrap();
920 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
921 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
922 assert_eq!(
923 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
924 "inside"
925 );
926 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
927 assert!(matches!(
928 prepare_cached_workspace(&root, &lock, &[]),
929 Err(WorkspaceError::EscapingLink { .. })
930 ));
931 assert_eq!(
932 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
933 "one"
934 );
935 lock.release().unwrap();
936 fs::remove_dir_all(root).unwrap();
937 fs::remove_dir_all(external).unwrap();
938 }
939
940 #[cfg(windows)]
941 #[test]
942 fn relocates_internal_junctions_without_symlink_privileges() {
943 let root = project();
944 fs::create_dir_all(root.join("shared")).unwrap();
945 fs::write(root.join("shared/value"), "inside").unwrap();
946 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
947 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
948 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
949 let isolated_link = workspace.join("linked-shared");
950 assert!(junction::exists(&isolated_link).unwrap());
951 assert_eq!(
952 fs::canonicalize(junction::get_target(&isolated_link).unwrap()).unwrap(),
953 fs::canonicalize(workspace.join("shared")).unwrap()
954 );
955 assert_eq!(
956 fs::read_to_string(isolated_link.join("value")).unwrap(),
957 "inside"
958 );
959 lock.release().unwrap();
960 fs::remove_dir_all(root).unwrap();
961 }
962
963 #[cfg(windows)]
964 #[test]
965 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
966 let root = project();
967 fs::create_dir_all(root.join("node_modules/example")).unwrap();
968 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
969 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
970 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
971 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
972 let package = workspace.join("node_modules/example");
973 assert!(junction::exists(&package).unwrap());
974 assert_eq!(
975 fs::canonicalize(junction::get_target(&package).unwrap()).unwrap(),
976 fs::canonicalize(root.join("node_modules/example")).unwrap()
977 );
978 let metadata = workspace.join("node_modules/.package-lock.json");
979 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
980 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
981 lock.release().unwrap();
982 fs::remove_dir_all(root).unwrap();
983 }
984
985 #[test]
986 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
987 let root = project();
988 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
989 ensure_container(&root).unwrap();
990 let workspace = cached_workspace_path(&root).unwrap();
991 let parent = workspace.parent().unwrap();
992 fs::create_dir_all(parent).unwrap();
993 let previous = parent.join(format!(
994 "{}old",
995 transaction_prefix(&root, "previous").unwrap()
996 ));
997 let staging = parent.join(format!(
998 "{}new",
999 transaction_prefix(&root, "staging").unwrap()
1000 ));
1001 fs::create_dir_all(&previous).unwrap();
1002 fs::write(previous.join("complete"), "yes").unwrap();
1003 fs::create_dir_all(&staging).unwrap();
1004 let result = recover_cached_workspace(&root, &lock).unwrap();
1005 assert!(result.restored_previous);
1006 assert_eq!(result.removed_staging, 1);
1007 assert_eq!(
1008 fs::read_to_string(workspace.join("complete")).unwrap(),
1009 "yes"
1010 );
1011 lock.release().unwrap();
1012 fs::remove_dir_all(root).unwrap();
1013 }
1014}