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::{
16 LifecycleError, ProjectLock, atomic_rename, atomic_write, remove_stored_tree_deferred,
17};
18
19const WORKSPACE_MARKER: &str = ".supercov-workspace-store";
20const WORKSPACE_MARKER_CONTENTS: &[u8] = b"Supercov instrumented workspace. Safe to delete.\n";
21const CARGO_WORKSPACE_VERSION: u32 = 2;
22const CARGO_WORKSPACE_LOCATOR_VERSION: u32 = 1;
23const CARGO_WORKSPACE_LOCATOR: &str = ".supercov/cargo-workspace.json";
24const ROOT_EXCLUSIONS: &[&str] = &[
25 ".cache",
26 ".git",
27 ".supercov",
28 ".mcdc-pool",
29 "node_modules",
30 "build",
31 "dist",
32 ".next",
33 ".nuxt",
34 ".output",
35 "coverage",
36 "playwright-report",
37 "test-results",
38 "target",
39];
40const NESTED_EXCLUSIONS: &[&str] = &[".supercov", ".mcdc-pool"];
41static UNIQUE: AtomicU64 = AtomicU64::new(0);
42
43#[derive(Debug)]
44pub enum WorkspaceError {
45 Io { path: PathBuf, source: io::Error },
46 UnsafePath(PathBuf),
47 UnsupportedEntry(PathBuf),
48 MissingLock,
49 Lifecycle(LifecycleError),
50 InvalidCacheMetadata(serde_json::Error),
51 UnsupportedPlatform(&'static str),
52}
53
54impl std::fmt::Display for WorkspaceError {
55 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
58 Self::UnsafePath(path) => {
59 write!(formatter, "unsafe workspace path: {}", path.display())
60 }
61 Self::UnsupportedEntry(path) => {
62 write!(
63 formatter,
64 "unsupported filesystem entry in isolated project: {}",
65 path.display()
66 )
67 }
68 Self::MissingLock => write!(
69 formatter,
70 "isolated workspace preparation requires the active project lock"
71 ),
72 Self::Lifecycle(error) => write!(formatter, "{error}"),
73 Self::InvalidCacheMetadata(error) => {
74 write!(formatter, "invalid build-cache metadata: {error}")
75 }
76 Self::UnsupportedPlatform(reason) => {
77 write!(formatter, "unsupported workspace platform: {reason}")
78 }
79 }
80 }
81}
82
83impl std::error::Error for WorkspaceError {}
84
85impl From<LifecycleError> for WorkspaceError {
86 fn from(value: LifecycleError) -> Self {
87 Self::Lifecycle(value)
88 }
89}
90
91fn omit_escaping_link(path: &Path, target: &Path) {
96 eprintln!(
97 "[supercov] omitting symlink outside the isolated project: {} -> {}",
98 path.display(),
99 target.display()
100 );
101}
102
103pub(crate) fn canonicalize_simplified<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
111 fs::canonicalize(path).map(simplified)
112}
113
114#[cfg(windows)]
115pub(crate) fn simplified(path: PathBuf) -> PathBuf {
116 match path.to_str().and_then(strip_verbatim) {
117 Some(plain) => PathBuf::from(plain),
118 None => path,
119 }
120}
121
122#[cfg(not(windows))]
123pub(crate) fn simplified(path: PathBuf) -> PathBuf {
124 path
125}
126
127#[cfg_attr(not(windows), allow(dead_code))]
131pub(crate) fn strip_verbatim(path: &str) -> Option<String> {
132 let rest = path.strip_prefix(r"\\?\")?;
133 if let Some(unc) = rest.strip_prefix(r"UNC\") {
134 return Some(format!(r"\\{unc}"));
135 }
136 let mut chars = rest.chars();
137 let drive = chars.next()?;
138 if drive.is_ascii_alphabetic() && chars.next() == Some(':') {
139 return Some(rest.to_owned());
140 }
141 None
142}
143
144pub(crate) fn file_url(path: &Path) -> String {
153 let text = path.to_string_lossy();
154 #[cfg(windows)]
155 let text = text.replace('\\', "/");
156 file_url_from_slash_path(&text)
157}
158
159pub(crate) fn file_url_from_slash_path(slash_path: &str) -> String {
161 let mut url = String::from("file://");
162 let mut rest = slash_path;
163 if let Some(unc) = rest.strip_prefix("//") {
164 let (host, tail) = unc.split_once('/').unwrap_or((unc, ""));
166 url.push_str(host);
167 rest = tail;
168 url.push('/');
169 } else if !rest.starts_with('/') {
170 url.push('/');
172 }
173 for byte in rest.bytes() {
174 let keep =
175 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':');
176 if keep {
177 url.push(byte as char);
178 } else {
179 url.push_str(&format!("%{byte:02X}"));
180 }
181 }
182 url
183}
184
185fn io_error(path: &Path, source: io::Error) -> WorkspaceError {
186 WorkspaceError::Io {
187 path: path.to_owned(),
188 source,
189 }
190}
191
192fn unique() -> String {
193 let nanos = SystemTime::now()
194 .duration_since(UNIX_EPOCH)
195 .unwrap_or_default()
196 .as_nanos();
197 format!(
198 "{}-{nanos}-{}",
199 std::process::id(),
200 UNIQUE.fetch_add(1, Ordering::Relaxed)
201 )
202}
203
204fn project_name(root: &Path) -> Result<&std::ffi::OsStr, WorkspaceError> {
205 root.file_name()
206 .ok_or_else(|| WorkspaceError::UnsafePath(root.into()))
207}
208
209pub fn workspace_container(root: &Path) -> PathBuf {
210 let preferred = root.join(".supercov/workspaces");
215 if fs::symlink_metadata(&preferred).is_err() || owned_workspace_path(&preferred) {
216 return preferred;
217 }
218 let digest = format!("{:x}", Sha256::digest(root.as_os_str().as_encoded_bytes()));
219 for sequence in 0..1_024usize {
220 let name = if sequence == 0 {
221 format!(".supercov/workspaces-{}", &digest[..16])
222 } else {
223 format!(".supercov/workspaces-{}-{sequence}", &digest[..16])
224 };
225 let candidate = root.join(name);
226 if fs::symlink_metadata(&candidate).is_err() || owned_workspace_path(&candidate) {
227 return candidate;
228 }
229 }
230 root.join(format!(".supercov/workspaces-{}-overflow", &digest[..16]))
231}
232
233pub fn cached_workspace_path(root: &Path) -> Result<PathBuf, WorkspaceError> {
234 Ok(workspace_container(root)
235 .join("workspace")
236 .join(project_name(root)?))
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
240#[serde(rename_all = "camelCase", deny_unknown_fields)]
241struct CargoWorkspaceMarker {
242 version: u32,
243 root_sha256: String,
244 token: String,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
248#[serde(rename_all = "lowercase")]
249enum CargoWorkspacePlacement {
250 Sibling,
251 Temporary,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
255#[serde(rename_all = "camelCase", deny_unknown_fields)]
256struct CargoWorkspaceLocator {
257 version: u32,
258 root_sha256: String,
259 placement: CargoWorkspacePlacement,
260 token: String,
261}
262
263fn canonical_root_and_digest(root: &Path) -> Result<(PathBuf, String), WorkspaceError> {
264 let canonical = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
265 let digest = format!(
266 "{:x}",
267 Sha256::digest(canonical.as_os_str().as_encoded_bytes())
268 );
269 Ok((canonical, digest))
270}
271
272fn preferred_cargo_workspace_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
273 let (canonical, digest) = canonical_root_and_digest(root)?;
274 let parent = canonical
275 .parent()
276 .ok_or_else(|| WorkspaceError::UnsafePath(canonical.clone()))?;
277 Ok(parent.join(format!(".supercov-cargo-{}", &digest[..24])))
278}
279
280fn cargo_locator_path(root: &Path) -> PathBuf {
281 root.join(CARGO_WORKSPACE_LOCATOR)
282}
283
284fn valid_token(token: &str) -> bool {
285 token.len() == 64
286 && token
287 .bytes()
288 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
289}
290
291fn new_cargo_token() -> Result<String, WorkspaceError> {
292 let mut bytes = [0_u8; 32];
293 getrandom::fill(&mut bytes).map_err(|source| {
294 io_error(
295 Path::new(CARGO_WORKSPACE_LOCATOR),
296 io::Error::other(source.to_string()),
297 )
298 })?;
299 Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
300}
301
302fn container_for_locator(
303 root: &Path,
304 locator: &CargoWorkspaceLocator,
305) -> Result<PathBuf, WorkspaceError> {
306 match locator.placement {
307 CargoWorkspacePlacement::Sibling => preferred_cargo_workspace_container(root),
308 CargoWorkspacePlacement::Temporary => {
309 let temporary_root = canonicalize_simplified(std::env::temp_dir())
310 .map_err(|source| io_error(&std::env::temp_dir(), source))?;
311 Ok(temporary_root.join(format!(
312 ".supercov-cargo-{}-{}",
313 &locator.root_sha256[..24],
314 &locator.token[..32]
315 )))
316 }
317 }
318}
319
320fn validate_cargo_locator(
321 root: &Path,
322 locator: CargoWorkspaceLocator,
323) -> Result<CargoWorkspaceLocator, WorkspaceError> {
324 let (_, digest) = canonical_root_and_digest(root)?;
325 if locator.version != CARGO_WORKSPACE_LOCATOR_VERSION
326 || locator.root_sha256 != digest
327 || !valid_token(&locator.token)
328 {
329 return Err(WorkspaceError::UnsafePath(cargo_locator_path(root)));
330 }
331 Ok(locator)
332}
333
334fn read_cargo_locator(root: &Path) -> Result<Option<CargoWorkspaceLocator>, WorkspaceError> {
335 let path = cargo_locator_path(root);
336 let metadata = match fs::symlink_metadata(&path) {
337 Ok(metadata) => metadata,
338 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
339 Err(error) => return Err(io_error(&path, error)),
340 };
341 if !metadata.file_type().is_file() {
342 return Err(WorkspaceError::UnsafePath(path));
343 }
344 let locator = serde_json::from_slice(&fs::read(&path).map_err(|error| io_error(&path, error))?)
345 .map_err(WorkspaceError::InvalidCacheMetadata)?;
346 validate_cargo_locator(root, locator).map(Some)
347}
348
349fn write_cargo_locator(root: &Path, locator: &CargoWorkspaceLocator) -> Result<(), WorkspaceError> {
350 let mut bytes =
351 serde_json::to_vec_pretty(locator).map_err(WorkspaceError::InvalidCacheMetadata)?;
352 bytes.push(b'\n');
353 atomic_write(root, &cargo_locator_path(root), &bytes).map_err(WorkspaceError::from)
354}
355
356pub fn cargo_workspace_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
357 match read_cargo_locator(root)? {
358 Some(locator) => container_for_locator(root, &locator),
359 None => preferred_cargo_workspace_container(root),
360 }
361}
362
363pub fn cargo_cached_workspace_path(root: &Path) -> Result<PathBuf, WorkspaceError> {
364 let container = cargo_workspace_container(root)?;
365 Ok(container.join("workspace/root").join(project_name(root)?))
366}
367
368fn expected_cargo_marker(root: &Path, token: &str) -> Result<CargoWorkspaceMarker, WorkspaceError> {
369 let (_, digest) = canonical_root_and_digest(root)?;
370 Ok(CargoWorkspaceMarker {
371 version: CARGO_WORKSPACE_VERSION,
372 root_sha256: digest,
373 token: token.into(),
374 })
375}
376
377fn read_cargo_marker(path: &Path) -> Result<CargoWorkspaceMarker, WorkspaceError> {
378 let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?;
379 if !metadata.file_type().is_file() {
380 return Err(WorkspaceError::UnsafePath(path.into()));
381 }
382 serde_json::from_slice(&fs::read(path).map_err(|error| io_error(path, error))?)
383 .map_err(WorkspaceError::InvalidCacheMetadata)
384}
385
386fn validate_cargo_marker(
387 root: &Path,
388 container: &Path,
389 marker: &CargoWorkspaceMarker,
390) -> Result<(), WorkspaceError> {
391 let (_, digest) = canonical_root_and_digest(root)?;
392 if marker.version != CARGO_WORKSPACE_VERSION
393 || marker.root_sha256 != digest
394 || !valid_token(&marker.token)
395 {
396 return Err(WorkspaceError::UnsafePath(container.into()));
397 }
398 Ok(())
399}
400
401fn ensure_cargo_container_at(
402 root: &Path,
403 container: &Path,
404 expected: &CargoWorkspaceMarker,
405) -> Result<(), WorkspaceError> {
406 let mut created = false;
407 match fs::symlink_metadata(container) {
408 Ok(metadata) if !metadata.file_type().is_dir() => {
409 return Err(WorkspaceError::UnsafePath(container.into()));
410 }
411 Ok(_) => {}
412 Err(error) if error.kind() == io::ErrorKind::NotFound => {
413 let parent = container
414 .parent()
415 .ok_or_else(|| WorkspaceError::UnsafePath(container.into()))?;
416 let parent_metadata =
417 fs::symlink_metadata(parent).map_err(|source| io_error(parent, source))?;
418 if !parent_metadata.file_type().is_dir() {
419 return Err(WorkspaceError::UnsafePath(parent.into()));
420 }
421 fs::create_dir(container).map_err(|source| io_error(container, source))?;
422 created = true;
423 }
424 Err(source) => return Err(io_error(container, source)),
425 }
426 let marker_path = container.join(WORKSPACE_MARKER);
427 let result = (|| {
428 match fs::symlink_metadata(&marker_path) {
429 Ok(_) if read_cargo_marker(&marker_path)? != *expected => {
430 return Err(WorkspaceError::UnsafePath(container.into()));
431 }
432 Ok(_) => {}
433 Err(error) if error.kind() == io::ErrorKind::NotFound && created => {
434 let mut bytes = serde_json::to_vec_pretty(&expected)
435 .map_err(WorkspaceError::InvalidCacheMetadata)?;
436 bytes.push(b'\n');
437 let mut file = OpenOptions::new()
438 .write(true)
439 .create_new(true)
440 .open(&marker_path)
441 .map_err(|source| io_error(&marker_path, source))?;
442 file.write_all(&bytes)
443 .and_then(|_| file.sync_all())
444 .map_err(|source| io_error(&marker_path, source))?;
445 }
446 Err(error) if error.kind() == io::ErrorKind::NotFound => {
447 return Err(WorkspaceError::UnsafePath(container.into()));
448 }
449 Err(source) => return Err(io_error(&marker_path, source)),
450 }
451 for path in [container.join(".cargo"), container.join("workspace/.cargo")] {
452 if fs::symlink_metadata(&path).is_ok() {
453 return Err(WorkspaceError::UnsafePath(path));
454 }
455 }
456 if project_name(root)? != ".cargo" {
457 let path = container.join("workspace/root/.cargo");
458 if fs::symlink_metadata(&path).is_ok() {
459 return Err(WorkspaceError::UnsafePath(path));
460 }
461 }
462 Ok(())
463 })();
464 if result.is_err() && created {
465 let _ = fs::remove_dir_all(container);
466 }
467 result
468}
469
470fn fallback_eligible(error: &WorkspaceError) -> bool {
471 matches!(
472 error,
473 WorkspaceError::Io { source, .. }
474 if matches!(
475 source.kind(),
476 io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem
477 )
478 )
479}
480
481fn ensure_cargo_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
482 if let Some(locator) = read_cargo_locator(root)? {
483 let container = container_for_locator(root, &locator)?;
484 let expected = expected_cargo_marker(root, &locator.token)?;
485 ensure_cargo_container_at(root, &container, &expected)?;
486 return Ok(container);
487 }
488
489 let preferred = preferred_cargo_workspace_container(root)?;
490 if fs::symlink_metadata(&preferred).is_ok() {
491 let marker = read_cargo_marker(&preferred.join(WORKSPACE_MARKER))?;
492 validate_cargo_marker(root, &preferred, &marker)?;
493 let (_, digest) = canonical_root_and_digest(root)?;
494 let locator = CargoWorkspaceLocator {
495 version: CARGO_WORKSPACE_LOCATOR_VERSION,
496 root_sha256: digest,
497 placement: CargoWorkspacePlacement::Sibling,
498 token: marker.token.clone(),
499 };
500 write_cargo_locator(root, &locator)?;
501 ensure_cargo_container_at(root, &preferred, &marker)?;
502 return Ok(preferred);
503 }
504
505 let (_, digest) = canonical_root_and_digest(root)?;
506 let token = new_cargo_token()?;
507 let preferred_locator = CargoWorkspaceLocator {
508 version: CARGO_WORKSPACE_LOCATOR_VERSION,
509 root_sha256: digest.clone(),
510 placement: CargoWorkspacePlacement::Sibling,
511 token: token.clone(),
512 };
513 let expected = expected_cargo_marker(root, &token)?;
514 match ensure_cargo_container_at(root, &preferred, &expected) {
515 Ok(()) => {
516 write_cargo_locator(root, &preferred_locator)?;
517 Ok(preferred)
518 }
519 Err(error) if fallback_eligible(&error) => {
520 let fallback_locator = CargoWorkspaceLocator {
521 placement: CargoWorkspacePlacement::Temporary,
522 ..preferred_locator
523 };
524 write_cargo_locator(root, &fallback_locator)?;
525 let fallback = container_for_locator(root, &fallback_locator)?;
526 ensure_cargo_container_at(root, &fallback, &expected)?;
527 Ok(fallback)
528 }
529 Err(error) => Err(error),
530 }
531}
532
533pub fn isolated_workspace_path(root: &Path, run_id: &str) -> Result<PathBuf, WorkspaceError> {
534 if run_id.is_empty()
535 || run_id == "."
536 || run_id == ".."
537 || run_id
538 .chars()
539 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
540 {
541 return Err(WorkspaceError::UnsafePath(PathBuf::from(run_id)));
542 }
543 Ok(workspace_container(root)
544 .join("work")
545 .join(run_id)
546 .join(project_name(root)?))
547}
548
549pub(crate) fn owned_workspace_path(path: &Path) -> bool {
550 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
551 && fs::symlink_metadata(path.join(WORKSPACE_MARKER))
552 .is_ok_and(|metadata| metadata.file_type().is_file())
553 && fs::read(path.join(WORKSPACE_MARKER))
554 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
555}
556
557fn ensure_container(root: &Path) -> Result<PathBuf, WorkspaceError> {
558 let container = workspace_container(root);
559 let mut created = false;
560 match fs::symlink_metadata(&container) {
561 Ok(metadata) if !metadata.file_type().is_dir() => {
562 return Err(WorkspaceError::UnsafePath(container));
563 }
564 Ok(_) => {}
565 Err(error) if error.kind() == io::ErrorKind::NotFound => {
566 fs::create_dir_all(&container).map_err(|source| io_error(&container, source))?;
567 let store_ignore = root.join(".supercov/.gitignore");
570 if fs::symlink_metadata(&store_ignore).is_err() {
571 let _ = fs::write(&store_ignore, b"*\n");
572 }
573 created = true;
574 }
575 Err(source) => return Err(io_error(&container, source)),
576 }
577 let result = (|| {
578 if !created && !owned_workspace_path(&container) {
579 return Err(WorkspaceError::UnsafePath(container.clone()));
580 }
581 for (name, contents) in [
582 (".gitignore", b"*\n".as_slice()),
583 (WORKSPACE_MARKER, WORKSPACE_MARKER_CONTENTS),
584 ] {
585 let path = container.join(name);
586 match OpenOptions::new().write(true).create_new(true).open(&path) {
587 Ok(mut file) => file
588 .write_all(contents)
589 .and_then(|_| file.sync_all())
590 .map_err(|source| io_error(&path, source))?,
591 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
592 if !fs::symlink_metadata(&path)
593 .is_ok_and(|metadata| metadata.file_type().is_file())
594 {
595 return Err(WorkspaceError::UnsafePath(path));
596 }
597 if name == WORKSPACE_MARKER
598 && !fs::read(&path)
599 .is_ok_and(|contents| contents == WORKSPACE_MARKER_CONTENTS)
600 {
601 return Err(WorkspaceError::UnsafePath(path));
602 }
603 }
604 Err(source) => return Err(io_error(&path, source)),
605 }
606 }
607 Ok(container.clone())
608 })();
609 if result.is_err() && created {
610 let _ = fs::remove_dir_all(&container);
611 }
612 result
613}
614
615fn lexical_normalize(path: &Path) -> Option<PathBuf> {
616 let mut normalized = PathBuf::new();
617 for component in path.components() {
618 match component {
619 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
620 Component::RootDir => normalized.push(component.as_os_str()),
621 Component::CurDir => {}
622 Component::ParentDir => {
623 if !normalized.pop() {
624 return None;
625 }
626 }
627 Component::Normal(value) => normalized.push(value),
628 }
629 }
630 Some(normalized)
631}
632
633fn inside(root: &Path, path: &Path) -> bool {
634 path == root
635 || path.strip_prefix(root).is_ok_and(|local| {
636 !local.as_os_str().is_empty()
637 && local
638 .components()
639 .all(|component| matches!(component, Component::Normal(_)))
640 })
641}
642
643trait WorkspaceOperations {
644 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
645 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError>;
646}
647
648struct SystemWorkspaceOperations;
649
650impl WorkspaceOperations for SystemWorkspaceOperations {
651 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
652 const REFLINK_MINIMUM_BYTES: u64 = 1024 * 1024;
657 let bytes = fs::symlink_metadata(source)
658 .map_err(|source_error| io_error(source, source_error))?
659 .len();
660 if bytes < REFLINK_MINIMUM_BYTES {
661 fs::copy(source, destination)
662 .map(|_| ())
663 .map_err(|source_error| io_error(destination, source_error))
664 } else {
665 reflink_copy::reflink_or_copy(source, destination)
666 .map(|_| ())
667 .map_err(|source_error| io_error(destination, source_error))
668 }
669 }
670
671 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
672 atomic_rename(source, destination).map_err(WorkspaceError::from)
673 }
674}
675
676#[cfg(unix)]
677fn create_link(target: &Path, destination: &Path, _directory: bool) -> io::Result<()> {
678 std::os::unix::fs::symlink(target, destination)
679}
680
681#[cfg(windows)]
682fn create_link(target: &Path, destination: &Path, directory: bool) -> io::Result<()> {
683 if directory {
684 match junction::create(target, destination) {
691 Ok(()) => Ok(()),
692 Err(junction_error) => {
693 let _ = fs::remove_dir(destination);
694 std::os::windows::fs::symlink_dir(target, destination).map_err(
695 |symlink_error| {
696 io::Error::new(
697 symlink_error.kind(),
698 format!(
699 "junction creation failed ({junction_error}); directory symlink fallback failed ({symlink_error})"
700 ),
701 )
702 },
703 )
704 }
705 }
706 } else {
707 std::os::windows::fs::symlink_file(target, destination)
711 }
712}
713
714#[cfg(target_os = "macos")]
720fn clone_directory(source: &Path, destination: &Path) -> bool {
721 use std::{ffi::CString, os::unix::ffi::OsStrExt};
722
723 let (Ok(source_c), Ok(destination_c)) = (
724 CString::new(source.as_os_str().as_bytes()),
725 CString::new(destination.as_os_str().as_bytes()),
726 ) else {
727 return false;
728 };
729 let status = unsafe { libc::clonefile(source_c.as_ptr(), destination_c.as_ptr(), 0) };
732 if status == 0 {
733 return true;
734 }
735 let _ = fs::remove_dir_all(destination);
736 false
737}
738
739#[cfg(all(unix, not(target_os = "macos")))]
740fn clone_directory(_source: &Path, _destination: &Path) -> bool {
741 false
742}
743
744#[cfg(unix)]
756fn hard_link_tree(source: &Path, destination: &Path) -> bool {
757 fn link(source: &Path, destination: &Path) -> io::Result<()> {
758 fs::create_dir(destination)?;
759 for entry in fs::read_dir(source)? {
760 let entry = entry?;
761 let from = entry.path();
762 let to = destination.join(entry.file_name());
763 let file_type = entry.file_type()?;
764 if file_type.is_dir() {
765 link(&from, &to)?;
766 } else if file_type.is_symlink() {
767 std::os::unix::fs::symlink(fs::read_link(&from)?, &to)?;
768 } else if file_type.is_file() {
769 fs::hard_link(&from, &to)?;
770 } else {
771 return Err(io::Error::new(
772 io::ErrorKind::Unsupported,
773 "entry is neither a file, a directory nor a symlink",
774 ));
775 }
776 }
777 Ok(())
778 }
779 if link(source, destination).is_ok() {
780 return true;
781 }
782 let _ = fs::remove_dir_all(destination);
783 false
784}
785
786#[derive(Clone, Copy)]
787struct CopyRoots<'a> {
788 source: &'a Path,
789 destination: &'a Path,
790 final_destination: &'a Path,
791 canonical_source: &'a Path,
792}
793
794fn copy_tree<Operations: WorkspaceOperations>(
795 source: &Path,
796 destination: &Path,
797 roots: CopyRoots<'_>,
798 root_level: bool,
799 operations: &mut Operations,
800) -> Result<(), WorkspaceError> {
801 fs::create_dir_all(destination).map_err(|source| io_error(destination, source))?;
802 let mut entries = fs::read_dir(source)
803 .map_err(|error| io_error(source, error))?
804 .collect::<Result<Vec<_>, _>>()
805 .map_err(|error| io_error(source, error))?;
806 entries.sort_by_key(fs::DirEntry::file_name);
807 for entry in entries {
808 let name = entry.file_name();
809 let name_text = name
810 .to_str()
811 .ok_or_else(|| WorkspaceError::UnsafePath(entry.path()))?;
812 if (root_level && ROOT_EXCLUSIONS.contains(&name_text))
813 || NESTED_EXCLUSIONS.contains(&name_text)
814 {
815 continue;
816 }
817 let from = entry.path();
818 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
819 if metadata.file_type().is_dir() && owned_workspace_path(&from) {
820 continue;
821 }
822 let to = destination.join(&name);
823 if metadata.file_type().is_dir() {
824 #[cfg(unix)]
847 if name_text == "node_modules" && !root_level {
848 if clone_directory(&from, &to) || hard_link_tree(&from, &to) {
849 continue;
850 }
851 fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
852 let mut packages = fs::read_dir(&from)
853 .map_err(|error| io_error(&from, error))?
854 .collect::<Result<Vec<_>, _>>()
855 .map_err(|error| io_error(&from, error))?;
856 packages.sort_by_key(fs::DirEntry::file_name);
857 for package in packages {
858 let link_destination = to.join(package.file_name());
859 create_link(&package.path(), &link_destination, false)
860 .map_err(|error| io_error(&link_destination, error))?;
861 }
862 continue;
863 }
864 copy_tree(&from, &to, roots, false, operations)?;
865 } else if metadata.file_type().is_symlink() {
866 let link = fs::read_link(&from).map_err(|error| io_error(&from, error))?;
867 let unresolved_target = if link.is_absolute() {
868 link.clone()
869 } else {
870 from.parent().expect("entry parent").join(&link)
871 };
872 let Some(lexical_target) = lexical_normalize(&unresolved_target) else {
873 omit_escaping_link(&from, &link);
874 continue;
875 };
876 let canonical_target = match canonicalize_simplified(&from) {
884 Ok(target) => Some(target),
885 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
886 Err(error) => return Err(io_error(&from, error)),
887 };
888 let escapes = !inside(roots.source, &lexical_target)
889 || canonical_target
890 .as_deref()
891 .is_some_and(|target| !inside(roots.canonical_source, target));
892 if escapes {
893 omit_escaping_link(&from, &link);
894 continue;
895 }
896 let local_target = lexical_target
897 .strip_prefix(roots.source)
898 .map_err(|_| WorkspaceError::UnsafePath(lexical_target.clone()))?;
899 let relocated = roots.destination.join(local_target);
900 let Some(canonical_target) = canonical_target else {
901 if cfg!(windows) {
902 continue;
906 }
907 let isolated_link = if link.is_absolute() {
908 pathdiff(&to, &relocated)?
909 } else {
910 link
911 };
912 create_link(&isolated_link, &to, false).map_err(|error| io_error(&to, error))?;
913 continue;
914 };
915 let target_metadata = fs::metadata(&canonical_target)
916 .map_err(|error| io_error(&canonical_target, error))?;
917 let isolated_link = if cfg!(windows) && target_metadata.is_dir() {
918 roots.final_destination.join(local_target)
919 } else if link.is_absolute() {
920 pathdiff(&to, &relocated)?
921 } else {
922 link
923 };
924 create_link(&isolated_link, &to, target_metadata.is_dir())
925 .map_err(|error| io_error(&to, error))?;
926 } else if metadata.file_type().is_file() {
927 operations.copy_file(&from, &to)?;
928 } else {
929 return Err(WorkspaceError::UnsupportedEntry(from));
930 }
931 }
932 Ok(())
933}
934
935fn pathdiff(from: &Path, to: &Path) -> Result<PathBuf, WorkspaceError> {
936 let from = from
937 .parent()
938 .ok_or_else(|| WorkspaceError::UnsafePath(from.into()))?;
939 let from_components = from.components().collect::<Vec<_>>();
940 let to_components = to.components().collect::<Vec<_>>();
941 let common = from_components
942 .iter()
943 .zip(&to_components)
944 .take_while(|(left, right)| left == right)
945 .count();
946 if common == 0 {
947 return Err(WorkspaceError::UnsafePath(to.into()));
948 }
949 let mut relative = PathBuf::new();
950 for _ in common..from_components.len() {
951 relative.push("..");
952 }
953 for component in &to_components[common..] {
954 relative.push(component.as_os_str());
955 }
956 Ok(relative)
957}
958
959fn link_node_modules<Operations: WorkspaceOperations>(
960 root: &Path,
961 workspace: &Path,
962 operations: &mut Operations,
963) -> Result<(), WorkspaceError> {
964 #[cfg(unix)]
965 let _ = &operations;
966 let source = root.join("node_modules");
967 let source_metadata = match fs::symlink_metadata(&source) {
968 Ok(metadata) => metadata,
969 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
970 Err(error) => return Err(io_error(&source, error)),
971 };
972 let source = if source_metadata.file_type().is_symlink() {
977 let resolved =
978 canonicalize_simplified(&source).map_err(|error| io_error(&source, error))?;
979 if !fs::symlink_metadata(&resolved)
980 .map_err(|error| io_error(&resolved, error))?
981 .file_type()
982 .is_dir()
983 {
984 return Err(WorkspaceError::UnsafePath(source));
985 }
986 resolved
987 } else if source_metadata.file_type().is_dir() {
988 source
989 } else {
990 return Err(WorkspaceError::UnsafePath(source));
991 };
992 let destination = workspace.join("node_modules");
993 fs::create_dir_all(&destination).map_err(|error| io_error(&destination, error))?;
994 let mut entries = fs::read_dir(&source)
995 .map_err(|error| io_error(&source, error))?
996 .collect::<Result<Vec<_>, _>>()
997 .map_err(|error| io_error(&source, error))?;
998 entries.sort_by_key(fs::DirEntry::file_name);
999 for entry in entries {
1000 let target = entry.path();
1001 let to = destination.join(entry.file_name());
1002 #[cfg(unix)]
1003 create_link(&target, &to, false).map_err(|error| io_error(&to, error))?;
1004 #[cfg(windows)]
1005 {
1006 let file_type = entry
1007 .file_type()
1008 .map_err(|error| io_error(&target, error))?;
1009 let resolved = if file_type.is_symlink() {
1010 canonicalize_simplified(&target).map_err(|error| io_error(&target, error))?
1011 } else {
1012 target.clone()
1013 };
1014 let metadata = fs::metadata(&resolved).map_err(|error| io_error(&resolved, error))?;
1015 if metadata.is_dir() {
1016 create_link(&resolved, &to, true).map_err(|error| io_error(&to, error))?;
1017 } else if metadata.is_file() {
1018 operations.copy_file(&resolved, &to)?;
1021 } else {
1022 return Err(WorkspaceError::UnsupportedEntry(target));
1023 }
1024 }
1025 }
1026 Ok(())
1027}
1028
1029fn require_lock(root: &Path, lock: &ProjectLock) -> Result<(), WorkspaceError> {
1030 if lock.protects(root) {
1031 Ok(())
1032 } else {
1033 Err(WorkspaceError::MissingLock)
1034 }
1035}
1036
1037pub fn prepare_isolated_workspace(
1038 root: &Path,
1039 run_id: &str,
1040 lock: &ProjectLock,
1041) -> Result<PathBuf, WorkspaceError> {
1042 require_lock(root, lock)?;
1043 ensure_container(root)?;
1044 let mut operations = SystemWorkspaceOperations;
1045 let workspace = isolated_workspace_path(root, run_id)?;
1046 remove_stored_tree_deferred(root, &workspace)?;
1047 let canonical_root = canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1048 copy_tree(
1049 root,
1050 &workspace,
1051 CopyRoots {
1052 source: root,
1053 destination: &workspace,
1054 final_destination: &workspace,
1055 canonical_source: &canonical_root,
1056 },
1057 true,
1058 &mut operations,
1059 )?;
1060 link_node_modules(root, &workspace, &mut operations)?;
1061 Ok(workspace)
1062}
1063
1064fn transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
1065 Ok(format!(
1066 ".{}.{}-",
1067 project_name(root)?.to_string_lossy(),
1068 kind
1069 ))
1070}
1071
1072fn transaction_path(root: &Path, kind: &str) -> Result<PathBuf, WorkspaceError> {
1073 let workspace = cached_workspace_path(root)?;
1074 Ok(workspace.parent().expect("workspace parent").join(format!(
1075 "{}{}",
1076 transaction_prefix(root, kind)?,
1077 unique()
1078 )))
1079}
1080
1081#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1082#[serde(rename_all = "camelCase")]
1083pub struct CacheRecoveryResult {
1084 pub restored_previous: bool,
1085 pub removed_staging: usize,
1086 pub removed_previous: usize,
1087}
1088
1089pub fn recover_cached_workspace(
1090 root: &Path,
1091 lock: &ProjectLock,
1092) -> Result<CacheRecoveryResult, WorkspaceError> {
1093 require_lock(root, lock)?;
1094 let workspace = cached_workspace_path(root)?;
1095 let parent = workspace.parent().expect("workspace parent");
1096 let entries = match fs::read_dir(parent) {
1097 Ok(entries) => entries,
1098 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1099 return Ok(CacheRecoveryResult {
1100 restored_previous: false,
1101 removed_staging: 0,
1102 removed_previous: 0,
1103 });
1104 }
1105 Err(error) => return Err(io_error(parent, error)),
1106 };
1107 let staging_prefix = transaction_prefix(root, "staging")?;
1108 let previous_prefix = transaction_prefix(root, "previous")?;
1109 let mut staging = Vec::new();
1110 let mut previous = Vec::new();
1111 let mut invalid_previous = Vec::new();
1112 for entry in entries {
1113 let entry = entry.map_err(|error| io_error(parent, error))?;
1114 let name = entry.file_name().to_string_lossy().into_owned();
1115 if name.starts_with(&staging_prefix) {
1116 staging.push(entry.path());
1117 } else if name.starts_with(&previous_prefix) {
1118 let metadata = entry
1119 .file_type()
1120 .map_err(|error| io_error(&entry.path(), error))?;
1121 if metadata.is_dir() {
1122 let modified = entry
1123 .metadata()
1124 .and_then(|metadata| metadata.modified())
1125 .unwrap_or(UNIX_EPOCH);
1126 previous.push((modified, entry.path()));
1127 } else {
1128 invalid_previous.push(entry.path());
1129 }
1130 }
1131 }
1132 previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
1133 let mut restored = false;
1134 if fs::symlink_metadata(&workspace).is_err()
1135 && let Some((_, newest)) = previous.first()
1136 {
1137 atomic_rename(newest, &workspace)?;
1138 previous.remove(0);
1139 restored = true;
1140 }
1141 let removed_staging = staging.len();
1142 let removed_previous = previous.len() + invalid_previous.len();
1143 for path in staging
1144 .into_iter()
1145 .chain(previous.into_iter().map(|(_, path)| path))
1146 .chain(invalid_previous)
1147 {
1148 remove_stored_tree_deferred(root, &path)?;
1149 }
1150 Ok(CacheRecoveryResult {
1151 restored_previous: restored,
1152 removed_staging,
1153 removed_previous,
1154 })
1155}
1156
1157fn checked_reuse_path(workspace: &Path, requested: &Path) -> Result<PathBuf, WorkspaceError> {
1158 if requested.is_absolute()
1159 || requested
1160 .components()
1161 .any(|component| !matches!(component, Component::Normal(_)))
1162 {
1163 return Err(WorkspaceError::UnsafePath(requested.into()));
1164 }
1165 let path = workspace.join(requested);
1166 if path == workspace || fs::symlink_metadata(&path).is_err() {
1167 return Err(WorkspaceError::UnsafePath(requested.into()));
1168 }
1169 Ok(path)
1170}
1171
1172pub fn prepare_cached_workspace(
1173 root: &Path,
1174 lock: &ProjectLock,
1175 reuse_paths: &[PathBuf],
1176) -> Result<PathBuf, WorkspaceError> {
1177 let mut operations = SystemWorkspaceOperations;
1178 prepare_cached_workspace_with_operations(root, lock, reuse_paths, &mut operations)
1179}
1180
1181fn cargo_transaction_prefix(root: &Path, kind: &str) -> Result<String, WorkspaceError> {
1182 Ok(format!(
1183 ".{}.{}-",
1184 project_name(root)?.to_string_lossy(),
1185 kind
1186 ))
1187}
1188
1189fn cargo_transaction_path(
1190 root: &Path,
1191 container: &Path,
1192 kind: &str,
1193) -> Result<PathBuf, WorkspaceError> {
1194 Ok(container.join(format!(
1195 "{}{}",
1196 cargo_transaction_prefix(root, kind)?,
1197 unique()
1198 )))
1199}
1200
1201fn validate_cargo_descendant(container: &Path, target: &Path) -> Result<(), WorkspaceError> {
1202 let local = target
1203 .strip_prefix(container)
1204 .map_err(|_| WorkspaceError::UnsafePath(target.into()))?;
1205 if local.as_os_str().is_empty()
1206 || local
1207 .components()
1208 .any(|component| !matches!(component, Component::Normal(_)))
1209 {
1210 return Err(WorkspaceError::UnsafePath(target.into()));
1211 }
1212 let mut current = container.to_owned();
1213 for component in local.components() {
1214 current.push(component.as_os_str());
1215 match fs::symlink_metadata(¤t) {
1216 Ok(metadata) if metadata.file_type().is_symlink() => {
1217 return Err(WorkspaceError::UnsafePath(current));
1218 }
1219 Ok(_) => {}
1220 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1221 Err(error) => return Err(io_error(¤t, error)),
1222 }
1223 }
1224 Ok(())
1225}
1226
1227fn remove_cargo_owned_tree(container: &Path, target: &Path) -> Result<bool, WorkspaceError> {
1228 validate_cargo_descendant(container, target)?;
1229 match fs::symlink_metadata(target) {
1230 Ok(metadata) if metadata.file_type().is_dir() => {
1231 fs::remove_dir_all(target).map_err(|error| io_error(target, error))?;
1232 Ok(true)
1233 }
1234 Ok(_) => Err(WorkspaceError::UnsafePath(target.into())),
1235 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
1236 Err(error) => Err(io_error(target, error)),
1237 }
1238}
1239
1240fn recover_cargo_workspace_at(
1241 root: &Path,
1242 container: &Path,
1243 workspace: &Path,
1244) -> Result<CacheRecoveryResult, WorkspaceError> {
1245 let staging_prefix = cargo_transaction_prefix(root, "staging")?;
1246 let previous_prefix = cargo_transaction_prefix(root, "previous")?;
1247 let mut staging = Vec::new();
1248 let mut previous = Vec::new();
1249 for entry in fs::read_dir(container)
1250 .map_err(|error| io_error(container, error))?
1251 .collect::<Result<Vec<_>, _>>()
1252 .map_err(|error| io_error(container, error))?
1253 {
1254 let name = entry.file_name().to_string_lossy().into_owned();
1255 let metadata = entry
1256 .file_type()
1257 .map_err(|error| io_error(&entry.path(), error))?;
1258 if name.starts_with(&staging_prefix) {
1259 if !metadata.is_dir() {
1260 return Err(WorkspaceError::UnsafePath(entry.path()));
1261 }
1262 staging.push(entry.path());
1263 } else if name.starts_with(&previous_prefix) {
1264 if !metadata.is_dir() {
1265 return Err(WorkspaceError::UnsafePath(entry.path()));
1266 }
1267 let modified = entry
1268 .metadata()
1269 .and_then(|metadata| metadata.modified())
1270 .unwrap_or(UNIX_EPOCH);
1271 previous.push((modified, entry.path()));
1272 }
1273 }
1274 previous.sort_by_key(|entry| std::cmp::Reverse(entry.0));
1275 let mut restored = false;
1276 if fs::symlink_metadata(workspace).is_err()
1277 && let Some((_, newest)) = previous.first()
1278 {
1279 fs::create_dir_all(workspace.parent().expect("Cargo workspace parent"))
1280 .map_err(|error| io_error(workspace, error))?;
1281 atomic_rename(newest, workspace)?;
1282 previous.remove(0);
1283 restored = true;
1284 }
1285 let removed_staging = staging.len();
1286 let removed_previous = previous.len();
1287 for path in staging
1288 .into_iter()
1289 .chain(previous.into_iter().map(|(_, path)| path))
1290 {
1291 remove_cargo_owned_tree(container, &path)?;
1292 }
1293 Ok(CacheRecoveryResult {
1294 restored_previous: restored,
1295 removed_staging,
1296 removed_previous,
1297 })
1298}
1299
1300pub fn recover_cargo_cached_workspace(
1301 root: &Path,
1302 lock: &ProjectLock,
1303) -> Result<CacheRecoveryResult, WorkspaceError> {
1304 require_lock(root, lock)?;
1305 let container = ensure_cargo_container(root)?;
1306 let workspace = cargo_cached_workspace_path(root)?;
1307 recover_cargo_workspace_at(root, &container, &workspace)
1308}
1309
1310pub fn prepare_cargo_cached_workspace(
1311 root: &Path,
1312 lock: &ProjectLock,
1313) -> Result<PathBuf, WorkspaceError> {
1314 let mut operations = SystemWorkspaceOperations;
1315 prepare_cargo_cached_workspace_with_operations(root, lock, &mut operations)
1316}
1317
1318fn prepare_cargo_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1319 root: &Path,
1320 lock: &ProjectLock,
1321 operations: &mut Operations,
1322) -> Result<PathBuf, WorkspaceError> {
1323 require_lock(root, lock)?;
1324 let container = ensure_cargo_container(root)?;
1325 let workspace = cargo_cached_workspace_path(root)?;
1326 recover_cargo_workspace_at(root, &container, &workspace)?;
1327 let staging = cargo_transaction_path(root, &container, "staging")?;
1328 let previous = cargo_transaction_path(root, &container, "previous")?;
1329 let result = (|| {
1330 let canonical_root =
1331 canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1332 copy_tree(
1333 root,
1334 &staging,
1335 CopyRoots {
1336 source: root,
1337 destination: &staging,
1338 final_destination: &workspace,
1339 canonical_source: &canonical_root,
1340 },
1341 true,
1342 operations,
1343 )?;
1344 link_node_modules(root, &staging, operations)?;
1345 fs::create_dir_all(workspace.parent().expect("Cargo workspace parent"))
1346 .map_err(|error| io_error(&workspace, error))?;
1347 let mut moved_previous = false;
1348 if fs::symlink_metadata(&workspace).is_ok() {
1349 operations.rename(&workspace, &previous)?;
1350 moved_previous = true;
1351 }
1352 if let Err(error) = operations.rename(&staging, &workspace) {
1353 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1354 let _ = operations.rename(&previous, &workspace);
1355 }
1356 return Err(error);
1357 }
1358 if fs::symlink_metadata(&previous).is_ok() {
1359 remove_cargo_owned_tree(&container, &previous)?;
1360 }
1361 Ok(workspace.clone())
1362 })();
1363 if fs::symlink_metadata(&staging).is_ok() {
1364 remove_cargo_owned_tree(&container, &staging)?;
1365 }
1366 result
1367}
1368
1369pub fn remove_cargo_workspace_run(root: &Path, run_id: &str) -> Result<bool, WorkspaceError> {
1370 let container = cargo_workspace_container(root)?;
1371 match fs::symlink_metadata(&container) {
1372 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
1373 Err(error) => return Err(io_error(&container, error)),
1374 Ok(metadata) if !metadata.file_type().is_dir() => {
1375 return Err(WorkspaceError::UnsafePath(container));
1376 }
1377 Ok(_) => {}
1378 }
1379 let marker = read_cargo_marker(&container.join(WORKSPACE_MARKER))?;
1380 validate_cargo_marker(root, &container, &marker)?;
1381 let workspace = cargo_cached_workspace_path(root)?;
1382 remove_cargo_owned_tree(&container, &workspace.join(".supercov/work").join(run_id))
1383}
1384
1385pub fn clean_cargo_workspace(root: &Path, dry_run: bool) -> Result<bool, WorkspaceError> {
1386 let container = cargo_workspace_container(root)?;
1387 let locator_path = cargo_locator_path(root);
1388 let has_locator = fs::symlink_metadata(&locator_path).is_ok();
1389 match fs::symlink_metadata(&container) {
1390 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1391 if has_locator && !dry_run {
1392 let metadata = fs::symlink_metadata(&locator_path)
1393 .map_err(|source| io_error(&locator_path, source))?;
1394 if !metadata.file_type().is_file() {
1395 return Err(WorkspaceError::UnsafePath(locator_path));
1396 }
1397 fs::remove_file(&locator_path).map_err(|source| io_error(&locator_path, source))?;
1398 }
1399 return Ok(has_locator);
1400 }
1401 Err(error) => return Err(io_error(&container, error)),
1402 Ok(metadata) if !metadata.file_type().is_dir() => {
1403 return Err(WorkspaceError::UnsafePath(container));
1404 }
1405 Ok(_) => {}
1406 }
1407 let marker = read_cargo_marker(&container.join(WORKSPACE_MARKER))?;
1408 validate_cargo_marker(root, &container, &marker)?;
1409 if !dry_run {
1410 fs::remove_dir_all(&container).map_err(|error| io_error(&container, error))?;
1411 if has_locator {
1412 let metadata = fs::symlink_metadata(&locator_path)
1413 .map_err(|source| io_error(&locator_path, source))?;
1414 if !metadata.file_type().is_file() {
1415 return Err(WorkspaceError::UnsafePath(locator_path));
1416 }
1417 fs::remove_file(&locator_path).map_err(|source| io_error(&locator_path, source))?;
1418 }
1419 }
1420 Ok(true)
1421}
1422
1423fn prepare_cached_workspace_with_operations<Operations: WorkspaceOperations>(
1424 root: &Path,
1425 lock: &ProjectLock,
1426 reuse_paths: &[PathBuf],
1427 operations: &mut Operations,
1428) -> Result<PathBuf, WorkspaceError> {
1429 require_lock(root, lock)?;
1430 ensure_container(root)?;
1431 recover_cached_workspace(root, lock)?;
1432 let workspace = cached_workspace_path(root)?;
1433 let staging = transaction_path(root, "staging")?;
1434 let previous = transaction_path(root, "previous")?;
1435 let result = (|| {
1436 let canonical_root =
1437 canonicalize_simplified(root).map_err(|error| io_error(root, error))?;
1438 copy_tree(
1439 root,
1440 &staging,
1441 CopyRoots {
1442 source: root,
1443 destination: &staging,
1444 final_destination: &workspace,
1445 canonical_source: &canonical_root,
1446 },
1447 true,
1448 operations,
1449 )?;
1450 link_node_modules(root, &staging, operations)?;
1451 for requested in reuse_paths {
1452 let from = checked_reuse_path(&workspace, requested)?;
1453 let to = staging.join(requested);
1454 match fs::symlink_metadata(&to) {
1459 Ok(existing) if existing.file_type().is_dir() => {
1460 fs::remove_dir_all(&to).map_err(|error| io_error(&to, error))?;
1461 }
1462 Ok(_) => {
1463 fs::remove_file(&to).map_err(|error| io_error(&to, error))?;
1464 }
1465 Err(_) => {}
1466 }
1467 let metadata = fs::symlink_metadata(&from).map_err(|error| io_error(&from, error))?;
1468 if metadata.file_type().is_dir() {
1469 let canonical_workspace = canonicalize_simplified(&workspace)
1470 .map_err(|error| io_error(&workspace, error))?;
1471 copy_tree(
1472 &from,
1473 &to,
1474 CopyRoots {
1475 source: &workspace,
1476 destination: &staging,
1477 final_destination: &workspace,
1478 canonical_source: &canonical_workspace,
1479 },
1480 false,
1481 operations,
1482 )?;
1483 } else if metadata.file_type().is_file() {
1484 fs::create_dir_all(to.parent().expect("reuse parent"))
1485 .map_err(|error| io_error(&to, error))?;
1486 operations.copy_file(&from, &to)?;
1487 } else {
1488 return Err(WorkspaceError::UnsupportedEntry(from));
1489 }
1490 }
1491 let mut moved_previous = false;
1492 if fs::symlink_metadata(&workspace).is_ok() {
1493 operations.rename(&workspace, &previous)?;
1494 moved_previous = true;
1495 }
1496 if let Err(error) = operations.rename(&staging, &workspace) {
1497 if moved_previous && fs::symlink_metadata(&workspace).is_err() {
1498 let _ = operations.rename(&previous, &workspace);
1499 }
1500 return Err(error);
1501 }
1502 if fs::symlink_metadata(&previous).is_ok() {
1503 remove_stored_tree_deferred(root, &previous)?;
1504 }
1505 Ok(workspace.clone())
1506 })();
1507 if fs::symlink_metadata(&staging).is_ok() {
1508 remove_stored_tree_deferred(root, &staging)?;
1509 }
1510 result
1511}
1512
1513#[derive(Deserialize)]
1514#[serde(rename_all = "camelCase")]
1515struct BuildCacheMetadata {
1516 #[serde(default)]
1517 artifact_paths: Vec<String>,
1518}
1519
1520fn retain_cached_artifact_roots(
1521 keep: &mut BTreeSet<String>,
1522 artifact_paths: impl IntoIterator<Item = String>,
1523) {
1524 for artifact in artifact_paths {
1525 if let Some(top) = Path::new(&artifact)
1526 .components()
1527 .next()
1528 .and_then(|component| match component {
1529 Component::Normal(value) => value.to_str(),
1530 _ => None,
1531 })
1532 {
1533 keep.insert(top.into());
1534 }
1535 }
1536}
1537
1538pub struct WorkspaceOutputBaseline {
1542 entries: BTreeMap<PathBuf, (u64, SystemTime)>,
1543}
1544
1545#[derive(Debug, Default, PartialEq, Eq)]
1547pub struct CommandOutputSync {
1548 pub synced: usize,
1549 pub skipped_instrumented: Vec<PathBuf>,
1553 pub deleted_in_workspace: Vec<PathBuf>,
1556}
1557
1558fn walk_output_files(
1559 workspace: &Path,
1560 directory: &Path,
1561 entries: &mut BTreeMap<PathBuf, (u64, SystemTime)>,
1562) -> Result<(), WorkspaceError> {
1563 for entry in fs::read_dir(directory)
1564 .map_err(|error| io_error(directory, error))?
1565 .collect::<Result<Vec<_>, _>>()
1566 .map_err(|error| io_error(directory, error))?
1567 {
1568 let path = entry.path();
1569 let name = entry.file_name();
1570 let relative = path
1571 .strip_prefix(workspace)
1572 .map_err(|_| WorkspaceError::UnsafePath(path.clone()))?
1573 .to_owned();
1574 if relative.components().count() == 1
1578 && matches!(name.to_str(), Some(".supercov") | Some(".git"))
1579 {
1580 continue;
1581 }
1582 let metadata = entry.metadata().map_err(|error| io_error(&path, error))?;
1583 if metadata.is_dir() && name.to_str() == Some("node_modules") {
1587 continue;
1588 }
1589 if fs::symlink_metadata(&path)
1590 .map_err(|error| io_error(&path, error))?
1591 .file_type()
1592 .is_symlink()
1593 {
1594 continue;
1595 }
1596 if metadata.is_dir() {
1597 walk_output_files(workspace, &path, entries)?;
1598 } else if metadata.is_file() {
1599 let modified = metadata
1600 .modified()
1601 .map_err(|error| io_error(&path, error))?;
1602 entries.insert(relative, (metadata.len(), modified));
1603 }
1604 }
1605 Ok(())
1606}
1607
1608pub fn workspace_output_baseline(
1609 workspace: &Path,
1610) -> Result<WorkspaceOutputBaseline, WorkspaceError> {
1611 let mut entries = BTreeMap::new();
1612 walk_output_files(workspace, workspace, &mut entries)?;
1613 Ok(WorkspaceOutputBaseline { entries })
1614}
1615
1616fn validate_writeback_destination(root: &Path, relative: &Path) -> Result<(), WorkspaceError> {
1619 if relative.as_os_str().is_empty()
1620 || relative
1621 .components()
1622 .any(|component| !matches!(component, Component::Normal(_)))
1623 {
1624 return Err(WorkspaceError::UnsafePath(relative.into()));
1625 }
1626 let mut current = root.to_owned();
1627 for component in relative.components() {
1628 current.push(component.as_os_str());
1629 match fs::symlink_metadata(¤t) {
1630 Ok(metadata) if metadata.file_type().is_symlink() => {
1631 return Err(WorkspaceError::UnsafePath(current));
1632 }
1633 Ok(_) => {}
1634 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
1635 Err(error) => return Err(io_error(¤t, error)),
1636 }
1637 }
1638 Ok(())
1639}
1640
1641pub fn sync_command_outputs(
1648 root: &Path,
1649 workspace: &Path,
1650 baseline: &WorkspaceOutputBaseline,
1651 protected: &BTreeSet<PathBuf>,
1652) -> Result<CommandOutputSync, WorkspaceError> {
1653 let mut current = BTreeMap::new();
1654 walk_output_files(workspace, workspace, &mut current)?;
1655 let mut sync = CommandOutputSync::default();
1656 for (relative, state) in ¤t {
1657 if baseline.entries.get(relative) == Some(state) {
1658 continue;
1659 }
1660 if protected.contains(relative) {
1661 sync.skipped_instrumented.push(relative.clone());
1662 continue;
1663 }
1664 let from = workspace.join(relative);
1665 if references_generated_runtime(&from)? {
1672 sync.skipped_instrumented.push(relative.clone());
1673 continue;
1674 }
1675 validate_writeback_destination(root, relative)?;
1676 let to = root.join(relative);
1677 if let Some(parent) = to.parent() {
1678 fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
1679 }
1680 fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
1681 sync.synced += 1;
1682 }
1683 for relative in baseline.entries.keys() {
1684 if !current.contains_key(relative) {
1685 sync.deleted_in_workspace.push(relative.clone());
1686 }
1687 }
1688 Ok(sync)
1689}
1690
1691fn references_generated_runtime(path: &Path) -> Result<bool, WorkspaceError> {
1695 const MARKER: &[u8] = b".supercov/node_modules/";
1696 let contents = match fs::read(path) {
1697 Ok(contents) => contents,
1698 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1699 Err(error) => return Err(io_error(path, error)),
1700 };
1701 Ok(contents
1702 .windows(MARKER.len())
1703 .any(|window| window == MARKER))
1704}
1705
1706pub fn prune_cached_workspace_sources(
1707 root: &Path,
1708 lock: &ProjectLock,
1709) -> Result<Vec<String>, WorkspaceError> {
1710 require_lock(root, lock)?;
1711 let workspace = cached_workspace_path(root)?;
1712 if fs::symlink_metadata(&workspace).is_err() {
1713 return Ok(Vec::new());
1714 }
1715 let mut keep = BTreeSet::from(["node_modules".to_owned(), ".supercov".to_owned()]);
1716 let metadata_path = workspace.join(".supercov/build-cache.json");
1717 if let Ok(bytes) = fs::read(&metadata_path) {
1718 let metadata: BuildCacheMetadata =
1719 serde_json::from_slice(&bytes).map_err(WorkspaceError::InvalidCacheMetadata)?;
1720 retain_cached_artifact_roots(&mut keep, metadata.artifact_paths);
1721 }
1722 let mut removed = Vec::new();
1723 for entry in fs::read_dir(&workspace).map_err(|error| io_error(&workspace, error))? {
1724 let entry = entry.map_err(|error| io_error(&workspace, error))?;
1725 let name = entry
1726 .file_name()
1727 .into_string()
1728 .map_err(|_| WorkspaceError::UnsafePath(entry.path()))?;
1729 if keep.contains(&name) {
1730 continue;
1731 }
1732 remove_stored_tree_deferred(root, &entry.path())?;
1733 removed.push(name);
1734 }
1735 removed.sort();
1736 Ok(removed)
1737}
1738
1739#[cfg(test)]
1740mod tests {
1741 #[test]
1742 fn file_urls_match_what_node_derives_for_every_path_shape() {
1743 use super::file_url_from_slash_path;
1744 assert_eq!(
1745 file_url_from_slash_path("C:/Users/runner/AppData/Local/Temp/x/register.mjs"),
1746 "file:///C:/Users/runner/AppData/Local/Temp/x/register.mjs"
1747 );
1748 assert_eq!(
1749 file_url_from_slash_path("C:/Users/a b/#1/register.mjs"),
1750 "file:///C:/Users/a%20b/%231/register.mjs"
1751 );
1752 assert_eq!(
1753 file_url_from_slash_path("//server/share/dir/register.mjs"),
1754 "file://server/share/dir/register.mjs"
1755 );
1756 assert_eq!(
1757 file_url_from_slash_path("/w/p/register.mjs"),
1758 "file:///w/p/register.mjs"
1759 );
1760 assert_eq!(
1761 file_url_from_slash_path("/w/p x?q/ü.mjs"),
1762 "file:///w/p%20x%3Fq/%C3%BC.mjs"
1763 );
1764 }
1765
1766 #[test]
1767 fn verbatim_prefixes_are_stripped_and_plain_paths_left_alone() {
1768 use super::strip_verbatim;
1769 assert_eq!(strip_verbatim(r"\\?\C:\a\b").as_deref(), Some(r"C:\a\b"));
1770 assert_eq!(strip_verbatim(r"\\?\d:\x").as_deref(), Some(r"d:\x"));
1771 assert_eq!(
1772 strip_verbatim(r"\\?\UNC\server\share\dir").as_deref(),
1773 Some(r"\\server\share\dir")
1774 );
1775 assert_eq!(strip_verbatim(r"C:\a\b"), None);
1776 assert_eq!(strip_verbatim("/w/project"), None);
1777 assert_eq!(strip_verbatim(r"\\server\share"), None);
1778 assert_eq!(strip_verbatim(r"\\?\"), None);
1779 }
1780
1781 use super::*;
1782
1783 fn writeback_fixture(name: &str) -> (PathBuf, PathBuf) {
1784 let base =
1785 std::env::temp_dir().join(format!("supercov-writeback-{}-{name}", std::process::id()));
1786 if base.exists() {
1787 fs::remove_dir_all(&base).unwrap();
1788 }
1789 let root = base.join("project");
1790 let workspace = base.join("workspace");
1791 fs::create_dir_all(root.join("src")).unwrap();
1792 fs::create_dir_all(workspace.join("src")).unwrap();
1793 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1794 fs::write(root.join("src/app.ts"), "original\n").unwrap();
1795 fs::write(workspace.join("src/app.ts"), "instrumented\n").unwrap();
1796 fs::write(workspace.join(".supercov/state.json"), "{}").unwrap();
1797 (root, workspace)
1798 }
1799
1800 #[test]
1801 fn dependency_trees_never_flow_back() {
1802 let (root, workspace) = writeback_fixture("dependencies");
1805 let baseline = workspace_output_baseline(&workspace).unwrap();
1806 fs::create_dir_all(workspace.join("node_modules/.vite")).unwrap();
1807 fs::write(workspace.join("node_modules/.vite/deps.json"), "{}").unwrap();
1808 fs::create_dir_all(workspace.join("packages/app/node_modules/dep")).unwrap();
1809 fs::write(
1810 workspace.join("packages/app/node_modules/dep/index.js"),
1811 "dep",
1812 )
1813 .unwrap();
1814 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1815 assert_eq!(sync.synced, 0);
1816 assert!(sync.deleted_in_workspace.is_empty());
1817 assert!(!root.join("node_modules").exists());
1818 assert!(!root.join("packages").exists());
1819 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1820 }
1821
1822 #[test]
1823 fn output_built_from_instrumented_sources_never_flows_back() {
1824 let (root, workspace) = writeback_fixture("built-output");
1830 let baseline = workspace_output_baseline(&workspace).unwrap();
1831 fs::create_dir_all(workspace.join("dist")).unwrap();
1832 fs::write(
1833 workspace.join("dist/app.js"),
1834 "import { coverageHit } from \"./.supercov/node_modules/runtime.mjs\";\ncoverageHit(0);\n",
1835 )
1836 .unwrap();
1837 fs::write(
1838 workspace.join("dist/app.d.ts"),
1839 "export declare const a: number;\n",
1840 )
1841 .unwrap();
1842
1843 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1844
1845 assert_eq!(
1846 sync.skipped_instrumented,
1847 vec![PathBuf::from("dist/app.js")],
1848 "the instrumented artifact is reported, not copied"
1849 );
1850 assert!(!root.join("dist/app.js").exists());
1851 assert_eq!(sync.synced, 1);
1854 assert!(root.join("dist/app.d.ts").exists());
1855 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1856 }
1857
1858 #[test]
1859 fn command_outputs_flow_back_to_the_project() {
1860 let (root, workspace) = writeback_fixture("outputs");
1861 let baseline = workspace_output_baseline(&workspace).unwrap();
1862 fs::create_dir_all(workspace.join("src/__snapshots__")).unwrap();
1863 fs::write(
1864 workspace.join("src/__snapshots__/app.snap"),
1865 "updated snapshot\n",
1866 )
1867 .unwrap();
1868 fs::write(workspace.join(".supercov/state.json"), "{\"changed\":1}").unwrap();
1869 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1870 assert_eq!(sync.synced, 1);
1871 assert_eq!(
1872 fs::read_to_string(root.join("src/__snapshots__/app.snap")).unwrap(),
1873 "updated snapshot\n"
1874 );
1875 assert!(!root.join(".supercov/state.json").exists());
1876 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1877 }
1878
1879 #[test]
1880 fn instrumented_sources_never_flow_back() {
1881 let (root, workspace) = writeback_fixture("protected");
1882 let baseline = workspace_output_baseline(&workspace).unwrap();
1883 fs::write(workspace.join("src/app.ts"), "instrumented, reformatted\n").unwrap();
1886 let protected = BTreeSet::from([PathBuf::from("src/app.ts")]);
1887 let sync = sync_command_outputs(&root, &workspace, &baseline, &protected).unwrap();
1888 assert_eq!(sync.synced, 0);
1889 assert_eq!(sync.skipped_instrumented, [PathBuf::from("src/app.ts")]);
1890 assert_eq!(
1891 fs::read_to_string(root.join("src/app.ts")).unwrap(),
1892 "original\n"
1893 );
1894 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1895 }
1896
1897 #[test]
1898 fn workspace_deletions_are_reported_but_never_propagated() {
1899 let (root, workspace) = writeback_fixture("deletions");
1900 fs::write(workspace.join("stale.txt"), "old\n").unwrap();
1901 fs::write(root.join("stale.txt"), "old\n").unwrap();
1902 let baseline = workspace_output_baseline(&workspace).unwrap();
1903 fs::remove_file(workspace.join("stale.txt")).unwrap();
1904 let sync = sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap();
1905 assert_eq!(sync.deleted_in_workspace, [PathBuf::from("stale.txt")]);
1906 assert!(root.join("stale.txt").exists());
1907 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1908 }
1909
1910 #[cfg(unix)]
1911 #[test]
1912 fn writeback_refuses_a_symlinked_destination() {
1913 let (root, workspace) = writeback_fixture("symlink");
1914 let outside = root.parent().unwrap().join("outside");
1915 fs::create_dir_all(&outside).unwrap();
1916 std::os::unix::fs::symlink(&outside, root.join("reports")).unwrap();
1917 let baseline = workspace_output_baseline(&workspace).unwrap();
1918 fs::create_dir_all(workspace.join("reports")).unwrap();
1919 fs::write(workspace.join("reports/result.txt"), "output\n").unwrap();
1920 let error =
1921 sync_command_outputs(&root, &workspace, &baseline, &BTreeSet::new()).unwrap_err();
1922 assert!(matches!(error, WorkspaceError::UnsafePath(_)));
1923 assert!(!outside.join("result.txt").exists());
1924 fs::remove_dir_all(root.parent().unwrap()).unwrap();
1925 }
1926
1927 struct OrdinaryCopyOperations;
1928
1929 impl WorkspaceOperations for OrdinaryCopyOperations {
1930 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1931 fs::copy(source, destination)
1932 .map(|_| ())
1933 .map_err(|error| io_error(destination, error))
1934 }
1935
1936 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1937 atomic_rename(source, destination).map_err(WorkspaceError::from)
1938 }
1939 }
1940
1941 struct FaultOperations {
1942 copy_count: usize,
1943 fail_copy_at: Option<usize>,
1944 rename_count: usize,
1945 fail_rename_at: Option<usize>,
1946 }
1947
1948 impl WorkspaceOperations for FaultOperations {
1949 fn copy_file(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1950 self.copy_count += 1;
1951 if self.fail_copy_at == Some(self.copy_count) {
1952 return Err(io_error(
1953 destination,
1954 io::Error::new(io::ErrorKind::StorageFull, "injected disk full"),
1955 ));
1956 }
1957 SystemWorkspaceOperations.copy_file(source, destination)
1958 }
1959
1960 fn rename(&mut self, source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1961 self.rename_count += 1;
1962 if self.fail_rename_at == Some(self.rename_count) {
1963 return Err(io_error(
1964 destination,
1965 io::Error::other("injected publication rename failure"),
1966 ));
1967 }
1968 SystemWorkspaceOperations.rename(source, destination)
1969 }
1970 }
1971
1972 fn transaction_debris(root: &Path) -> Vec<String> {
1973 let workspace = cached_workspace_path(root).unwrap();
1974 let prefix = format!(".{}.", project_name(root).unwrap().to_string_lossy());
1975 fs::read_dir(workspace.parent().unwrap())
1976 .unwrap()
1977 .filter_map(Result::ok)
1978 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1979 .filter(|name| name.starts_with(&prefix))
1980 .collect()
1981 }
1982
1983 fn project() -> PathBuf {
1984 let root = std::env::temp_dir().join(format!("supercov-workspace-rust-{}", unique()));
1985 fs::create_dir_all(root.join("src")).unwrap();
1986 fs::write(root.join("src/index.js"), "one").unwrap();
1987 fs::write(root.join("package.json"), "{}").unwrap();
1988 root
1989 }
1990
1991 #[test]
1992 #[cfg(unix)]
1993 fn root_node_modules_symlink_is_followed() {
1994 let root = project();
1999 let store = std::env::temp_dir().join(format!("supercov-store-{}", unique()));
2000 fs::create_dir_all(store.join("left-pad")).unwrap();
2001 fs::write(store.join("left-pad/package.json"), "{}").unwrap();
2002 std::os::unix::fs::symlink(&store, root.join("node_modules")).unwrap();
2003 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2004 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2005 assert!(
2006 fs::symlink_metadata(workspace.join("node_modules/left-pad"))
2007 .unwrap()
2008 .file_type()
2009 .is_symlink()
2010 );
2011 lock.release().unwrap();
2012 fs::remove_dir_all(root).unwrap();
2013 fs::remove_dir_all(store).unwrap();
2014 }
2015
2016 #[test]
2017 #[cfg(unix)]
2018 fn dangling_symlinks_are_preserved_and_escaping_ones_still_refused() {
2019 let root = project();
2026 let nested = root.join("examples/app/node_modules/@scope");
2027 fs::create_dir_all(&nested).unwrap();
2028 std::os::unix::fs::symlink(
2029 "../../../../packages/react/node_modules/@scope/pkg",
2030 nested.join("pkg"),
2031 )
2032 .unwrap();
2033 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2034 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2035 let mirrored = workspace.join("examples/app/node_modules/@scope/pkg");
2036 let metadata = fs::symlink_metadata(&mirrored).unwrap();
2037 assert!(metadata.file_type().is_symlink());
2038 assert_eq!(
2039 fs::read_link(&mirrored).unwrap(),
2040 PathBuf::from("../../../../packages/react/node_modules/@scope/pkg")
2041 );
2042 lock.release().unwrap();
2043 fs::remove_dir_all(root).unwrap();
2044 }
2045
2046 #[test]
2047 #[cfg(target_os = "macos")]
2048 fn nested_node_modules_are_cloned_not_linked() {
2049 let root = project();
2055 let nested = root.join("packages/app/node_modules");
2056 fs::create_dir_all(nested.join("dep")).unwrap();
2057 fs::create_dir_all(nested.join(".bin")).unwrap();
2058 fs::write(nested.join("dep/index.js"), "dep").unwrap();
2059 std::os::unix::fs::symlink("../dep/index.js", nested.join(".bin/dep")).unwrap();
2060 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2061 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2062 let mirrored = workspace.join("packages/app/node_modules");
2063 assert!(
2064 fs::symlink_metadata(mirrored.join("dep"))
2065 .unwrap()
2066 .file_type()
2067 .is_dir()
2068 );
2069 assert_eq!(
2070 fs::read_to_string(mirrored.join("dep/index.js")).unwrap(),
2071 "dep"
2072 );
2073 assert_eq!(
2074 fs::read_link(mirrored.join(".bin/dep")).unwrap(),
2075 PathBuf::from("../dep/index.js")
2076 );
2077 fs::write(mirrored.join("dep/index.js"), "changed in the workspace").unwrap();
2078 assert_eq!(
2079 fs::read_to_string(nested.join("dep/index.js")).unwrap(),
2080 "dep"
2081 );
2082 lock.release().unwrap();
2083 fs::remove_dir_all(root).unwrap();
2084 }
2085
2086 #[test]
2087 #[cfg(unix)]
2088 fn hard_linked_dependency_tree_is_real_and_replaceable() {
2089 use std::os::unix::fs::MetadataExt;
2093 let base = std::env::temp_dir().join(format!("supercov-hardlink-{}", unique()));
2094 let source = base.join("node_modules");
2095 fs::create_dir_all(source.join("dep")).unwrap();
2096 fs::create_dir_all(source.join(".bin")).unwrap();
2097 fs::write(source.join("dep/index.js"), "dep").unwrap();
2098 std::os::unix::fs::symlink("../dep/index.js", source.join(".bin/dep")).unwrap();
2099 let destination = base.join("workspace/node_modules");
2100 fs::create_dir_all(destination.parent().unwrap()).unwrap();
2101 assert!(hard_link_tree(&source, &destination));
2102 assert_eq!(
2103 fs::metadata(destination.join("dep/index.js"))
2104 .unwrap()
2105 .ino(),
2106 fs::metadata(source.join("dep/index.js")).unwrap().ino()
2107 );
2108 assert_eq!(
2109 fs::read_link(destination.join(".bin/dep")).unwrap(),
2110 PathBuf::from("../dep/index.js")
2111 );
2112 fs::remove_dir_all(destination.join("dep")).unwrap();
2113 fs::create_dir_all(destination.join("dep")).unwrap();
2114 fs::write(destination.join("dep/index.js"), "replaced").unwrap();
2115 assert_eq!(
2116 fs::read_to_string(source.join("dep/index.js")).unwrap(),
2117 "dep"
2118 );
2119 fs::remove_dir_all(base).unwrap();
2120 }
2121
2122 #[test]
2123 #[cfg(unix)]
2124 fn symlink_escaping_the_project_is_omitted_from_the_mirror() {
2125 let root = project();
2131 let nested = root.join("examples/app/lib");
2132 fs::create_dir_all(&nested).unwrap();
2133 std::os::unix::fs::symlink("../../../../../outside-the-project/pkg", nested.join("pkg"))
2134 .unwrap();
2135 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2136 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2137 assert!(fs::symlink_metadata(workspace.join("examples/app/lib/pkg")).is_err());
2138 assert!(workspace.join("src/index.js").exists());
2139 lock.release().unwrap();
2140 fs::remove_dir_all(root).unwrap();
2141 }
2142
2143 #[test]
2144 fn isolated_copy_never_changes_source_and_requires_the_lock() {
2145 let root = project();
2146 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2147 let workspace = prepare_isolated_workspace(&root, "run", &lock).unwrap();
2148 fs::write(workspace.join("src/index.js"), "instrumented").unwrap();
2149 assert_eq!(
2150 fs::read_to_string(root.join("src/index.js")).unwrap(),
2151 "one"
2152 );
2153 lock.release().unwrap();
2154 assert!(matches!(
2155 prepare_isolated_workspace(&root, "other", &lock),
2156 Err(WorkspaceError::MissingLock)
2157 ));
2158 fs::remove_dir_all(root).unwrap();
2159 }
2160
2161 #[test]
2162 fn everything_supercov_writes_lives_under_the_store() {
2163 let root = project();
2164 assert_eq!(
2165 workspace_container(&root),
2166 root.join(".supercov/workspaces")
2167 );
2168 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2169 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2170 assert!(workspace.starts_with(root.join(".supercov")));
2171 assert_eq!(
2172 fs::read_to_string(root.join(".supercov/.gitignore")).unwrap(),
2173 "*\n"
2174 );
2175 lock.release().unwrap();
2176 fs::remove_dir_all(root).unwrap();
2177 }
2178
2179 #[test]
2180 fn an_unowned_container_gets_a_deterministic_fallback() {
2181 let root = project();
2182 fs::create_dir_all(root.join(".supercov/workspaces")).unwrap();
2183 fs::write(root.join(".supercov/workspaces/user-file"), "mine\n").unwrap();
2184 let container = workspace_container(&root);
2185 assert_ne!(container, root.join(".supercov/workspaces"));
2186 let name = container
2187 .file_name()
2188 .unwrap()
2189 .to_string_lossy()
2190 .into_owned();
2191 assert!(name.starts_with("workspaces-"));
2192 fs::remove_dir_all(root).unwrap();
2193 }
2194
2195 #[test]
2196 fn user_supercov_directory_is_copied_and_never_adopted() {
2197 let root = project();
2198 fs::create_dir(root.join("supercov")).unwrap();
2199 fs::write(root.join("supercov/user-module.js"), "export default 1;\n").unwrap();
2200 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2201 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2202 assert_ne!(workspace_container(&root), root.join("supercov"));
2203 assert_eq!(
2204 fs::read_to_string(workspace.join("supercov/user-module.js")).unwrap(),
2205 "export default 1;\n"
2206 );
2207 assert_eq!(
2208 fs::read_to_string(root.join("supercov/user-module.js")).unwrap(),
2209 "export default 1;\n"
2210 );
2211 assert!(!root.join("supercov").join(WORKSPACE_MARKER).exists());
2212 lock.release().unwrap();
2213 fs::remove_dir_all(root).unwrap();
2214 }
2215
2216 #[test]
2217 fn cargo_cache_is_an_owned_same_parent_sibling_and_cleans_exactly() {
2218 let root = project();
2219 fs::create_dir_all(root.join(".cargo")).unwrap();
2220 fs::write(
2221 root.join(".cargo/config.toml"),
2222 "[build]\nrustflags=[\"--cfg\",\"copied-once\"]\n",
2223 )
2224 .unwrap();
2225 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2226 let workspace = prepare_cargo_cached_workspace(&root, &lock).unwrap();
2227 let container = cargo_workspace_container(&root).unwrap();
2228 assert_eq!(
2229 container.parent(),
2230 canonicalize_simplified(&root).unwrap().parent()
2231 );
2232 assert!(!workspace.starts_with(&root));
2233 assert_eq!(workspace.file_name(), root.file_name());
2234 assert_eq!(
2235 fs::read_to_string(workspace.join(".cargo/config.toml")).unwrap(),
2236 "[build]\nrustflags=[\"--cfg\",\"copied-once\"]\n"
2237 );
2238 fs::create_dir_all(workspace.join(".supercov/work/run_1")).unwrap();
2239 assert!(remove_cargo_workspace_run(&root, "run_1").unwrap());
2240 assert!(!workspace.join(".supercov/work/run_1").exists());
2241 assert!(clean_cargo_workspace(&root, true).unwrap());
2242 assert!(container.exists());
2243 assert!(clean_cargo_workspace(&root, false).unwrap());
2244 assert!(!container.exists());
2245 lock.release().unwrap();
2246 fs::remove_dir_all(root).unwrap();
2247 }
2248
2249 #[cfg(unix)]
2250 #[test]
2251 fn read_only_checkout_parent_uses_authenticated_temporary_fallback() {
2252 use std::os::unix::fs::PermissionsExt;
2253
2254 let outer = std::env::temp_dir().join(format!("supercov-read-only-parent-{}", unique()));
2255 let root = outer.join("project");
2256 fs::create_dir_all(root.join("src")).unwrap();
2257 fs::write(root.join("src/index.rs"), "pub fn value() -> usize { 1 }\n").unwrap();
2258 fs::write(
2259 root.join("Cargo.toml"),
2260 "[package]\nname='fallback-fixture'\nversion='0.0.0'\n",
2261 )
2262 .unwrap();
2263 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2264 fs::set_permissions(&outer, fs::Permissions::from_mode(0o555)).unwrap();
2265 let prepared = prepare_cargo_cached_workspace(&root, &lock);
2266 fs::set_permissions(&outer, fs::Permissions::from_mode(0o700)).unwrap();
2267 let workspace = prepared.unwrap();
2268 let locator = read_cargo_locator(&root).unwrap().unwrap();
2269 assert_eq!(locator.placement, CargoWorkspacePlacement::Temporary);
2270 let container = cargo_workspace_container(&root).unwrap();
2271 assert_eq!(workspace, container.join("workspace/root/project"));
2272 assert_eq!(
2273 fs::read_to_string(workspace.join("src/index.rs")).unwrap(),
2274 "pub fn value() -> usize { 1 }\n"
2275 );
2276 assert!(!workspace.starts_with(&root));
2277 assert!(clean_cargo_workspace(&root, false).unwrap());
2278 assert!(!container.exists());
2279 assert!(!cargo_locator_path(&root).exists());
2280 lock.release().unwrap();
2281 fs::remove_dir_all(outer).unwrap();
2282 }
2283
2284 #[test]
2285 fn cargo_locator_and_container_marker_must_share_the_random_token() {
2286 let root = project();
2287 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2288 prepare_cargo_cached_workspace(&root, &lock).unwrap();
2289 let original = read_cargo_locator(&root).unwrap().unwrap();
2290 let mut tampered = original.clone();
2291 tampered.token = "0".repeat(64);
2292 write_cargo_locator(&root, &tampered).unwrap();
2293 assert!(matches!(
2294 prepare_cargo_cached_workspace(&root, &lock),
2295 Err(WorkspaceError::UnsafePath(_))
2296 ));
2297 write_cargo_locator(&root, &original).unwrap();
2298 clean_cargo_workspace(&root, false).unwrap();
2299 lock.release().unwrap();
2300 fs::remove_dir_all(root).unwrap();
2301 }
2302
2303 #[test]
2304 fn cargo_cache_rejects_a_tampered_marker_without_deleting_it() {
2305 let root = project();
2306 let container = cargo_workspace_container(&root).unwrap();
2307 fs::create_dir(&container).unwrap();
2308 fs::write(container.join(WORKSPACE_MARKER), "{}\n").unwrap();
2309 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2310 assert!(matches!(
2311 prepare_cargo_cached_workspace(&root, &lock),
2312 Err(WorkspaceError::InvalidCacheMetadata(_)) | Err(WorkspaceError::UnsafePath(_))
2313 ));
2314 assert!(container.exists());
2315 lock.release().unwrap();
2316 fs::remove_dir_all(container).unwrap();
2317 fs::remove_dir_all(root).unwrap();
2318 }
2319
2320 #[test]
2321 fn cargo_cache_copy_and_rename_failures_preserve_the_complete_generation() {
2322 let root = project();
2323 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2324 let workspace = prepare_cargo_cached_workspace(&root, &lock).unwrap();
2325 assert_eq!(
2326 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2327 "one"
2328 );
2329 fs::write(root.join("src/index.js"), "two").unwrap();
2330
2331 let mut copy_failure = FaultOperations {
2332 copy_count: 0,
2333 fail_copy_at: Some(1),
2334 rename_count: 0,
2335 fail_rename_at: None,
2336 };
2337 assert!(matches!(
2338 prepare_cargo_cached_workspace_with_operations(&root, &lock, &mut copy_failure),
2339 Err(WorkspaceError::Io { .. })
2340 ));
2341 assert_eq!(
2342 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2343 "one"
2344 );
2345
2346 let mut rename_failure = FaultOperations {
2347 copy_count: 0,
2348 fail_copy_at: None,
2349 rename_count: 0,
2350 fail_rename_at: Some(2),
2351 };
2352 assert!(matches!(
2353 prepare_cargo_cached_workspace_with_operations(&root, &lock, &mut rename_failure),
2354 Err(WorkspaceError::Io { .. })
2355 ));
2356 assert_eq!(
2357 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2358 "one"
2359 );
2360
2361 let container = cargo_workspace_container(&root).unwrap();
2362 let prefix = format!(".{}.", project_name(&root).unwrap().to_string_lossy());
2363 assert!(
2364 fs::read_dir(&container)
2365 .unwrap()
2366 .filter_map(Result::ok)
2367 .all(|entry| !entry.file_name().to_string_lossy().starts_with(&prefix))
2368 );
2369 clean_cargo_workspace(&root, false).unwrap();
2370 lock.release().unwrap();
2371 fs::remove_dir_all(root).unwrap();
2372 }
2373
2374 #[test]
2375 fn stable_cache_refreshes_atomically_and_reuses_only_explicit_artifacts() {
2376 let root = project();
2377 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2378 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2379 fs::create_dir_all(first.join("build")).unwrap();
2380 fs::write(first.join("build/output.js"), "instrumented").unwrap();
2381 fs::write(first.join("stale.txt"), "stale").unwrap();
2382 fs::write(root.join("src/index.js"), "two").unwrap();
2383 let second = prepare_cached_workspace(&root, &lock, &[PathBuf::from("build")]).unwrap();
2384 assert_eq!(first, second);
2385 assert_eq!(
2386 fs::read_to_string(second.join("src/index.js")).unwrap(),
2387 "two"
2388 );
2389 assert_eq!(
2390 fs::read_to_string(second.join("build/output.js")).unwrap(),
2391 "instrumented"
2392 );
2393 assert!(!second.join("stale.txt").exists());
2394 lock.release().unwrap();
2395 fs::remove_dir_all(root).unwrap();
2396 }
2397
2398 #[test]
2399 fn reused_artifact_replaces_the_mirrored_stale_copy_wholesale() {
2400 let root = project();
2401 fs::create_dir_all(root.join("packages/app/dist")).unwrap();
2402 fs::write(root.join("packages/app/dist/stale.js"), "uninstrumented").unwrap();
2403 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2404 let first = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2405 fs::remove_file(first.join("packages/app/dist/stale.js")).unwrap();
2406 fs::write(first.join("packages/app/dist/built.js"), "instrumented").unwrap();
2407 let second =
2408 prepare_cached_workspace(&root, &lock, &[PathBuf::from("packages/app/dist")]).unwrap();
2409 assert_eq!(
2410 fs::read_to_string(second.join("packages/app/dist/built.js")).unwrap(),
2411 "instrumented"
2412 );
2413 assert!(!second.join("packages/app/dist/stale.js").exists());
2414 lock.release().unwrap();
2415 fs::remove_dir_all(root).unwrap();
2416 }
2417
2418 #[test]
2419 fn terminal_workspace_keeps_flat_frontend_cache_without_discoverable_test_sources() {
2420 let root = project();
2421 fs::create_dir_all(root.join("tests/e2e")).unwrap();
2422 fs::write(root.join("tests/e2e/app.spec.js"), "test source").unwrap();
2423 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2424 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2425 let artifacts = workspace.join(".supercov/frontend-cache-artifacts");
2426 fs::create_dir_all(&artifacts).unwrap();
2427 fs::write(artifacts.join("digest"), "instrumented test").unwrap();
2428 fs::write(
2429 workspace.join(".supercov/frontend-cache.json"),
2430 "{\"schemaVersion\":2}",
2431 )
2432 .unwrap();
2433 prune_cached_workspace_sources(&root, &lock).unwrap();
2434 assert!(!workspace.join("tests/e2e/app.spec.js").exists());
2435 assert_eq!(
2436 fs::read_to_string(artifacts.join("digest")).unwrap(),
2437 "instrumented test"
2438 );
2439 lock.release().unwrap();
2440 fs::remove_dir_all(root).unwrap();
2441 }
2442
2443 #[test]
2444 fn ordinary_copy_fallback_preserves_workspace_semantics() {
2445 let root = project();
2446 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2447 let mut operations = OrdinaryCopyOperations;
2448 let workspace =
2449 prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations).unwrap();
2450 assert_eq!(
2451 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2452 "one"
2453 );
2454 assert_eq!(
2455 fs::read_to_string(root.join("src/index.js")).unwrap(),
2456 "one"
2457 );
2458 assert!(transaction_debris(&root).is_empty());
2459 lock.release().unwrap();
2460 fs::remove_dir_all(root).unwrap();
2461 }
2462
2463 #[test]
2464 fn enospc_during_copy_preserves_the_complete_generation() {
2465 let root = project();
2466 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2467 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2468 fs::write(workspace.join("generation"), "complete").unwrap();
2469 fs::write(root.join("src/index.js"), "new source").unwrap();
2470 let mut operations = FaultOperations {
2471 copy_count: 0,
2472 fail_copy_at: Some(1),
2473 rename_count: 0,
2474 fail_rename_at: None,
2475 };
2476 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2477 .unwrap_err();
2478 assert!(matches!(
2479 error,
2480 WorkspaceError::Io { ref source, .. }
2481 if source.kind() == io::ErrorKind::StorageFull
2482 ));
2483 assert_eq!(
2484 fs::read_to_string(workspace.join("generation")).unwrap(),
2485 "complete"
2486 );
2487 assert_eq!(
2488 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2489 "one"
2490 );
2491 assert!(transaction_debris(&root).is_empty());
2492 lock.release().unwrap();
2493 fs::remove_dir_all(root).unwrap();
2494 }
2495
2496 #[test]
2497 fn failed_publication_rename_restores_the_complete_generation() {
2498 let root = project();
2499 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2500 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2501 fs::write(workspace.join("generation"), "complete").unwrap();
2502 fs::write(root.join("src/index.js"), "new source").unwrap();
2503 let mut operations = FaultOperations {
2504 copy_count: 0,
2505 fail_copy_at: None,
2506 rename_count: 0,
2507 fail_rename_at: Some(2),
2508 };
2509 let error = prepare_cached_workspace_with_operations(&root, &lock, &[], &mut operations)
2510 .unwrap_err();
2511 assert!(matches!(
2512 error,
2513 WorkspaceError::Io { ref source, .. }
2514 if source.kind() == io::ErrorKind::Other
2515 ));
2516 assert_eq!(
2517 operations.rename_count, 3,
2518 "prior generation was not restored"
2519 );
2520 assert_eq!(
2521 fs::read_to_string(workspace.join("generation")).unwrap(),
2522 "complete"
2523 );
2524 assert_eq!(
2525 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2526 "one"
2527 );
2528 assert!(transaction_debris(&root).is_empty());
2529 lock.release().unwrap();
2530 fs::remove_dir_all(root).unwrap();
2531 }
2532
2533 #[cfg(unix)]
2534 #[test]
2535 fn relocates_internal_links_and_rejects_external_links() {
2536 use std::os::unix::fs::symlink;
2537
2538 let root = project();
2539 fs::create_dir_all(root.join("shared")).unwrap();
2540 fs::write(root.join("shared/value"), "inside").unwrap();
2541 symlink("../shared/value", root.join("src/value-link")).unwrap();
2542 let external = project();
2543 fs::write(external.join("outside"), "outside").unwrap();
2544 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2545 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2546 assert_eq!(
2547 fs::read_to_string(workspace.join("src/value-link")).unwrap(),
2548 "inside"
2549 );
2550 symlink(external.join("outside"), root.join("src/external-link")).unwrap();
2551 let refreshed = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2552 assert!(fs::symlink_metadata(refreshed.join("src/external-link")).is_err());
2553 assert_eq!(
2554 fs::read_to_string(workspace.join("src/index.js")).unwrap(),
2555 "one"
2556 );
2557 lock.release().unwrap();
2558 fs::remove_dir_all(root).unwrap();
2559 fs::remove_dir_all(external).unwrap();
2560 }
2561
2562 #[cfg(windows)]
2563 #[test]
2564 fn relocates_internal_junctions_without_symlink_privileges() {
2565 let root = project();
2566 fs::create_dir_all(root.join("shared")).unwrap();
2567 fs::write(root.join("shared/value"), "inside").unwrap();
2568 junction::create(root.join("shared"), root.join("linked-shared")).unwrap();
2569 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2570 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2571 let isolated_link = workspace.join("linked-shared");
2572 assert!(junction::exists(&isolated_link).unwrap());
2573 assert_eq!(
2574 canonicalize_simplified(junction::get_target(&isolated_link).unwrap()).unwrap(),
2575 canonicalize_simplified(workspace.join("shared")).unwrap()
2576 );
2577 assert_eq!(
2578 fs::read_to_string(isolated_link.join("value")).unwrap(),
2579 "inside"
2580 );
2581 lock.release().unwrap();
2582 fs::remove_dir_all(root).unwrap();
2583 }
2584
2585 #[cfg(windows)]
2586 #[test]
2587 fn mounts_node_modules_with_junctions_and_copies_metadata_files() {
2588 let root = project();
2589 fs::create_dir_all(root.join("node_modules/example")).unwrap();
2590 fs::write(root.join("node_modules/example/index.js"), "module").unwrap();
2591 fs::write(root.join("node_modules/.package-lock.json"), "lock").unwrap();
2592 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2593 let workspace = prepare_cached_workspace(&root, &lock, &[]).unwrap();
2594 let package = workspace.join("node_modules/example");
2595 assert!(junction::exists(&package).unwrap());
2596 assert_eq!(
2597 canonicalize_simplified(junction::get_target(&package).unwrap()).unwrap(),
2598 canonicalize_simplified(root.join("node_modules/example")).unwrap()
2599 );
2600 let metadata = workspace.join("node_modules/.package-lock.json");
2601 assert!(fs::symlink_metadata(&metadata).unwrap().is_file());
2602 assert_eq!(fs::read_to_string(metadata).unwrap(), "lock");
2603 lock.release().unwrap();
2604 fs::remove_dir_all(root).unwrap();
2605 }
2606
2607 #[test]
2608 fn recovery_restores_the_newest_previous_generation_and_discards_staging() {
2609 let root = project();
2610 let mut lock = ProjectLock::acquire(&root, "run", "now").unwrap();
2611 ensure_container(&root).unwrap();
2612 let workspace = cached_workspace_path(&root).unwrap();
2613 let parent = workspace.parent().unwrap();
2614 fs::create_dir_all(parent).unwrap();
2615 let previous = parent.join(format!(
2616 "{}old",
2617 transaction_prefix(&root, "previous").unwrap()
2618 ));
2619 let staging = parent.join(format!(
2620 "{}new",
2621 transaction_prefix(&root, "staging").unwrap()
2622 ));
2623 fs::create_dir_all(&previous).unwrap();
2624 fs::write(previous.join("complete"), "yes").unwrap();
2625 fs::create_dir_all(&staging).unwrap();
2626 let result = recover_cached_workspace(&root, &lock).unwrap();
2627 assert!(result.restored_previous);
2628 assert_eq!(result.removed_staging, 1);
2629 assert_eq!(
2630 fs::read_to_string(workspace.join("complete")).unwrap(),
2631 "yes"
2632 );
2633 lock.release().unwrap();
2634 fs::remove_dir_all(root).unwrap();
2635 }
2636}