1use std::{
7 fs,
8 path::{Component, MAIN_SEPARATOR, Path, PathBuf},
9};
10
11use alloy_primitives::keccak256;
12use eyre::Result;
13use foundry_common::fs::{canonicalize_path, normalize_path};
14use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping};
15use foundry_config::{
16 Config, fs_permissions::FsAccessKind, providers::relative_remapping_preserving_context_boundary,
17};
18
19#[cfg(windows)]
20use std::os::windows::fs::FileTypeExt;
21
22pub fn is_safe_relative_path(p: &Path) -> bool {
25 !p.is_absolute()
26 && p.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
27}
28
29pub fn ensure_safe_relative_path(rel: &Path, label: &str, orig: &Path) -> Result<()> {
32 if !is_safe_relative_path(rel) {
33 eyre::bail!("requires {label} directory under project root, got: {}", orig.display());
34 }
35 Ok(())
36}
37
38pub fn relative_to_root(root: &Path, path: &Path) -> PathBuf {
40 let root = normalize_existing_ancestor(root);
41 let path = normalize_existing_ancestor(path);
42 path.strip_prefix(root).map(|p| p.to_path_buf()).unwrap_or(path)
43}
44
45fn resolve_against_root(root: &Path, path: &Path) -> PathBuf {
46 let path = if path.is_absolute() { path.to_path_buf() } else { root.join(path) };
47 normalize_existing_ancestor(&path)
48}
49
50pub fn rebase_config_paths(config: &Config, temp_path: &Path) -> Config {
55 let temp_root = normalize_existing_ancestor(temp_path);
58 let temp_path = temp_root.as_path();
59 let mut temp_config = config.clone();
60 temp_config.root = temp_path.to_path_buf();
61 temp_config.src = rebase_project_path(&config.root, temp_path, &config.src);
62 temp_config.test = rebase_project_path(&config.root, temp_path, &config.test);
63 temp_config.script = rebase_project_path(&config.root, temp_path, &config.script);
64 temp_config.out = rebase_project_path(&config.root, temp_path, &config.out);
65 temp_config.cache_path = rebase_project_path(&config.root, temp_path, &config.cache_path);
66 temp_config.snapshots = rebase_project_path(&config.root, temp_path, &config.snapshots);
67 temp_config.broadcast = rebase_project_path(&config.root, temp_path, &config.broadcast);
68 temp_config.mutation_dir = rebase_project_path(&config.root, temp_path, &config.mutation_dir);
69 temp_config.test_failures_file =
70 rebase_project_path(&config.root, temp_path, &config.test_failures_file);
71 temp_config.build_info_path = config
72 .build_info_path
73 .as_ref()
74 .map(|path| rebase_project_path(&config.root, temp_path, path));
75 temp_config.libs =
76 config.libs.iter().map(|lib| rebase_project_path(&config.root, temp_path, lib)).collect();
77 temp_config.remappings = config
78 .remappings
79 .iter()
80 .map(|remapping| rebase_remapping(&config.root, temp_path, remapping))
81 .collect();
82 temp_config.include_paths = config
83 .include_paths
84 .iter()
85 .map(|path| rebase_project_path(&config.root, temp_path, path))
86 .collect();
87 temp_config.allow_paths = config
88 .allow_paths
89 .iter()
90 .map(|path| rebase_project_path(&config.root, temp_path, path))
91 .collect();
92 temp_config.ignored_error_codes_from = config
93 .ignored_error_codes_from
94 .iter()
95 .map(|(path, codes)| (rebase_project_path(&config.root, temp_path, path), codes.clone()))
96 .collect();
97 temp_config.ignored_file_paths = config
98 .ignored_file_paths
99 .iter()
100 .map(|path| rebase_project_path(&config.root, temp_path, path))
101 .collect();
102
103 if let Some(path) = &config.fuzz.failure_persist_dir {
104 temp_config.fuzz.failure_persist_dir =
105 Some(rebase_mutable_project_path(config, temp_path, path));
106 }
107 if let Some(path) = &config.fuzz.corpus.corpus_dir {
108 temp_config.fuzz.corpus.corpus_dir =
109 Some(rebase_mutable_project_path(config, temp_path, path));
110 }
111 if let Some(path) = &config.fuzz.corpus.frontier_dir {
112 temp_config.fuzz.corpus.frontier_dir =
113 Some(rebase_mutable_project_path(config, temp_path, path));
114 }
115 if let Some(path) = &config.invariant.failure_persist_dir {
116 temp_config.invariant.failure_persist_dir =
117 Some(rebase_mutable_project_path(config, temp_path, path));
118 }
119 if let Some(path) = &config.invariant.corpus.corpus_dir {
120 temp_config.invariant.corpus.corpus_dir =
121 Some(rebase_mutable_project_path(config, temp_path, path));
122 }
123 if let Some(path) = &config.invariant.corpus.frontier_dir {
124 temp_config.invariant.corpus.frontier_dir =
125 Some(rebase_mutable_project_path(config, temp_path, path));
126 }
127 for permission in &mut temp_config.fs_permissions.permissions {
128 let path = rebase_project_path(&config.root, temp_path, &permission.path);
129 permission.path = normalize_existing_ancestor(&path);
130 }
131 if let Some(model_checker) = &mut temp_config.model_checker {
132 model_checker.contracts = std::mem::take(&mut model_checker.contracts)
133 .into_iter()
134 .map(|(path, contracts)| {
135 let path = rebase_project_path(&config.root, temp_path, Path::new(&path));
136 (path.display().to_string(), contracts)
137 })
138 .collect();
139 }
140
141 temp_config
142}
143
144fn rebase_project_path(root: &Path, temp_path: &Path, path: &Path) -> PathBuf {
145 let resolved = resolve_against_root(root, path);
146 let rel = relative_to_root(root, &resolved);
147 if rel.is_absolute() { resolved } else { temp_path.join(rel) }
148}
149
150fn rebase_mutable_project_path(config: &Config, temp_path: &Path, path: &Path) -> PathBuf {
151 let resolved = resolve_against_root(&config.root, path);
152 let rel = relative_to_root(&config.root, &resolved);
153 if rel.is_absolute() || is_covered_by_symlinked_project_root(config, &rel) {
154 return temp_path.join(isolated_mutable_path_rel(&resolved));
155 }
156 temp_path.join(rel)
157}
158
159fn isolated_mutable_path_rel(resolved: &Path) -> PathBuf {
160 PathBuf::from(".foundry_mutable")
161 .join(format!("{:x}", keccak256(resolved.as_os_str().as_encoded_bytes())))
162}
163
164fn is_covered_by_symlinked_project_root(config: &Config, rel: &Path) -> bool {
165 config.libs.iter().any(|path| {
166 let resolved = resolve_against_root(&config.root, path);
167 let lib_rel = relative_to_root(&config.root, &resolved);
168 !lib_rel.is_absolute() && !lib_rel.as_os_str().is_empty() && rel.starts_with(lib_rel)
169 }) || ["node_modules", "dependencies"].iter().any(|dep_dir| rel.starts_with(dep_dir))
170}
171
172fn normalize_existing_ancestor(path: &Path) -> PathBuf {
173 if path.exists() {
174 return canonicalize_path(path).unwrap_or_else(|_| path.to_path_buf());
175 }
176
177 let mut ancestor = path;
178 let mut missing = Vec::new();
179 while let Some(parent) = ancestor.parent() {
180 if ancestor.exists() {
181 break;
182 }
183 missing.push(ancestor.strip_prefix(parent).unwrap().to_path_buf());
184 ancestor = parent;
185 }
186
187 let mut normalized = canonicalize_path(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
188 for component in missing.iter().rev() {
189 normalized.push(component);
190 }
191 normalize_path(&normalized)
192}
193
194fn rebase_remapping(
195 root: &Path,
196 temp_path: &Path,
197 remapping: &RelativeRemapping,
198) -> RelativeRemapping {
199 let context_has_boundary =
200 remapping.context.as_deref().is_some_and(|context| context.ends_with(['/', '\\']));
201 let mut remapping: Remapping = remapping.clone().into();
202 remapping.path =
203 rebase_project_path(root, temp_path, Path::new(&remapping.path)).display().to_string();
204 if let Some(context) = &mut remapping.context {
205 *context = rebase_project_path(root, temp_path, Path::new(context)).display().to_string();
206 if context_has_boundary && !context.ends_with(['/', '\\']) {
207 context.push(MAIN_SEPARATOR);
208 }
209 }
210 relative_remapping_preserving_context_boundary(remapping, temp_path)
211}
212
213fn ensure_within_root(
219 allowed_root: &Path,
220 candidate: &Path,
221 label: &str,
222 orig: &Path,
223) -> Result<()> {
224 if !candidate.exists() {
226 return Ok(());
227 }
228 let canon_root = allowed_root.canonicalize().map_err(|e| {
229 eyre::eyre!("failed to canonicalize project root {}: {e}", allowed_root.display())
230 })?;
231 let canon_candidate = candidate.canonicalize().map_err(|e| {
232 eyre::eyre!("failed to canonicalize {label} path {}: {e}", candidate.display())
233 })?;
234 if !canon_candidate.starts_with(&canon_root) {
235 eyre::bail!(
236 "{label} path {} escapes project root {} (resolved to {})",
237 orig.display(),
238 allowed_root.display(),
239 canon_candidate.display()
240 );
241 }
242 Ok(())
243}
244
245pub fn copy_project(config: &Config, temp_dir: &Path) -> Result<()> {
250 let src_rel = relative_to_root(&config.root, &config.src);
251 ensure_safe_relative_path(&src_rel, "src", &config.src)?;
252 ensure_within_root(&config.root, &config.src, "src", &config.src)?;
253
254 let test_rel = relative_to_root(&config.root, &config.test);
255 ensure_safe_relative_path(&test_rel, "test", &config.test)?;
256 ensure_within_root(&config.root, &config.test, "test", &config.test)?;
257
258 copy_project_dir_recursive(&config.root, &config.src, &temp_dir.join(&src_rel))?;
259
260 if config.test != config.src {
261 copy_project_dir_recursive(&config.root, &config.test, &temp_dir.join(&test_rel))?;
262 }
263
264 let handled_extra_roots = handled_project_roots(config)?;
265 for extra_path in config.include_paths.iter().chain(config.allow_paths.iter()) {
266 copy_extra_project_path(
267 &config.root,
268 temp_dir,
269 extra_path,
270 &handled_extra_roots,
271 "include/allow",
272 )?;
273 }
274 for remapping in &config.remappings {
275 let remapping: Remapping = remapping.clone().into();
276 copy_extra_project_path(
277 &config.root,
278 temp_dir,
279 Path::new(&remapping.path),
280 &handled_extra_roots,
281 "remapping",
282 )?;
283 if let Some(context) = remapping.context {
284 copy_extra_project_path(
285 &config.root,
286 temp_dir,
287 Path::new(&context),
288 &handled_extra_roots,
289 "remapping context",
290 )?;
291 }
292 }
293 for permission in &config.fs_permissions.permissions {
294 if permission.is_granted(FsAccessKind::Read) {
295 copy_project_local_permission_path(
296 &config.root,
297 temp_dir,
298 &permission.path,
299 &handled_extra_roots,
300 )?;
301 }
302 if permission.is_granted(FsAccessKind::Write) {
303 create_project_local_permission_dir(
304 &config.root,
305 temp_dir,
306 &permission.path,
307 &handled_extra_roots,
308 )?;
309 }
310 }
311
312 copy_project_local_optional_path(
313 &config.root,
314 temp_dir,
315 &config.fuzz.failure_persist_dir,
316 &handled_extra_roots,
317 )?;
318 copy_project_local_optional_path(
319 &config.root,
320 temp_dir,
321 &config.fuzz.corpus.corpus_dir,
322 &handled_extra_roots,
323 )?;
324 copy_project_local_optional_path(
325 &config.root,
326 temp_dir,
327 &config.fuzz.corpus.frontier_dir,
328 &handled_extra_roots,
329 )?;
330 copy_project_local_optional_path(
331 &config.root,
332 temp_dir,
333 &config.invariant.failure_persist_dir,
334 &handled_extra_roots,
335 )?;
336 copy_project_local_optional_path(
337 &config.root,
338 temp_dir,
339 &config.invariant.corpus.corpus_dir,
340 &handled_extra_roots,
341 )?;
342 copy_project_local_optional_path(
343 &config.root,
344 temp_dir,
345 &config.invariant.corpus.frontier_dir,
346 &handled_extra_roots,
347 )?;
348
349 if config.script.exists() && config.script != config.src && config.script != config.test {
355 let script_rel = relative_to_root(&config.root, &config.script);
356 ensure_safe_relative_path(&script_rel, "script", &config.script)?;
357 ensure_within_root(&config.root, &config.script, "script", &config.script)?;
358 copy_project_dir_recursive(&config.root, &config.script, &temp_dir.join(&script_rel))?;
359 }
360
361 for lib_path in &config.libs {
362 let resolved = resolve_against_root(&config.root, lib_path);
363 if resolved.exists() {
364 let lib_rel = relative_to_root(&config.root, &resolved);
365 if lib_rel.is_absolute() {
366 continue;
367 }
368 ensure_safe_relative_path(&lib_rel, "lib", lib_path)?;
369 ensure_within_root(&config.root, &resolved, "lib", lib_path)?;
370 let target = temp_dir.join(&lib_rel);
371
372 if !target.exists() {
373 if let Some(parent) = target.parent() {
374 fs::create_dir_all(parent)?;
375 }
376 if symlink_dir(&resolved, &target).is_err() {
377 copy_dir_recursive(&resolved, &target)?;
378 }
379 }
380
381 symlink_nested_libs(&resolved, &target, 0)?;
382 }
383 }
384
385 for dep_dir in ["node_modules", "dependencies"] {
386 let dep_path = config.root.join(dep_dir);
387 if dep_path.exists() && dep_path.is_dir() {
388 ensure_within_root(&config.root, &dep_path, dep_dir, &dep_path)?;
390 let target = temp_dir.join(dep_dir);
391 if !target.exists() && symlink_dir(&dep_path, &target).is_err() {
392 copy_dir_recursive(&dep_path, &target)?;
393 }
394 }
395 }
396
397 let foundry_toml = config.root.join("foundry.toml");
398 if foundry_toml.exists() {
399 fs::copy(&foundry_toml, temp_dir.join("foundry.toml"))?;
400 }
401
402 let remappings = config.root.join("remappings.txt");
403 if remappings.exists() {
404 fs::copy(&remappings, temp_dir.join("remappings.txt"))?;
405 }
406
407 Ok(())
408}
409
410pub(crate) fn handled_project_roots(config: &Config) -> Result<Vec<PathBuf>> {
411 let mut roots = Vec::new();
412 push_handled_project_root(&mut roots, &config.root, &config.src, "src")?;
413 push_handled_project_root(&mut roots, &config.root, &config.test, "test")?;
414
415 if config.script.exists() && config.script != config.src && config.script != config.test {
416 push_handled_project_root(&mut roots, &config.root, &config.script, "script")?;
417 }
418
419 for lib_path in &config.libs {
420 let resolved = resolve_against_root(&config.root, lib_path);
421 if resolved.exists() {
422 let lib_rel = relative_to_root(&config.root, &resolved);
423 if lib_rel.is_absolute() {
424 continue;
425 }
426 push_handled_project_root(&mut roots, &config.root, &resolved, "lib")?;
427 }
428 }
429
430 for dep_dir in ["node_modules", "dependencies"] {
431 let dep_path = config.root.join(dep_dir);
432 if dep_path.exists() && dep_path.is_dir() {
433 roots.push(PathBuf::from(dep_dir));
434 }
435 }
436
437 Ok(roots)
438}
439
440fn push_handled_project_root(
441 roots: &mut Vec<PathBuf>,
442 root: &Path,
443 path: &Path,
444 label: &str,
445) -> Result<()> {
446 let rel = relative_to_root(root, path);
447 ensure_safe_relative_path(&rel, label, path)?;
448 ensure_within_root(root, path, label, path)?;
449 roots.push(rel);
450 Ok(())
451}
452
453fn is_covered_by_handled_root(rel: &Path, handled_roots: &[PathBuf]) -> bool {
454 handled_roots.iter().any(|root| !root.as_os_str().is_empty() && rel.starts_with(root))
455}
456
457fn copy_extra_project_path(
458 root: &Path,
459 temp_dir: &Path,
460 path: &Path,
461 handled_roots: &[PathBuf],
462 label: &str,
463) -> Result<()> {
464 let resolved = resolve_against_root(root, path);
465 let rel = relative_to_root(root, &resolved);
466 if rel.is_absolute() {
467 return Ok(());
468 }
469 ensure_safe_relative_path(&rel, label, path)?;
470 ensure_within_root(root, &resolved, label, path)?;
471
472 if is_covered_by_handled_root(&rel, handled_roots) {
473 return Ok(());
474 }
475
476 if !resolved.exists() {
477 return Ok(());
478 }
479
480 let target = temp_dir.join(rel);
481 if resolved.is_dir() {
482 copy_project_dir_recursive(root, &resolved, &target)
483 } else {
484 if let Some(parent) = target.parent() {
485 fs::create_dir_all(parent)?;
486 }
487 fs::copy(&resolved, target)?;
488 Ok(())
489 }
490}
491
492fn copy_project_local_permission_path(
493 root: &Path,
494 temp_dir: &Path,
495 path: &Path,
496 handled_roots: &[PathBuf],
497) -> Result<()> {
498 let resolved = resolve_against_root(root, path);
499 let rel = relative_to_root(root, &resolved);
500 if rel.is_absolute() || rel.as_os_str().is_empty() {
501 return Ok(());
502 }
503 copy_extra_project_path(root, temp_dir, path, handled_roots, "fs permission")
504}
505
506fn create_project_local_permission_dir(
507 root: &Path,
508 temp_dir: &Path,
509 path: &Path,
510 handled_roots: &[PathBuf],
511) -> Result<()> {
512 let resolved = resolve_against_root(root, path);
513 let rel = relative_to_root(root, &resolved);
514 if rel.is_absolute() || rel.as_os_str().is_empty() {
515 return Ok(());
516 }
517 ensure_safe_relative_path(&rel, "fs permission", path)?;
518 ensure_within_root(root, &resolved, "fs permission", path)?;
519
520 if resolved.exists() && resolved.is_dir() {
521 if !is_covered_by_handled_root(&rel, handled_roots) {
522 fs::create_dir_all(temp_dir.join(rel))?;
523 }
524 return Ok(());
525 }
526
527 let Some(parent) = rel.parent() else { return Ok(()) };
528 let resolved_parent = root.join(parent);
529 if !resolved_parent.exists() || !resolved_parent.is_dir() {
530 return Ok(());
531 }
532 ensure_within_root(root, &resolved_parent, "fs permission", path)?;
533
534 if parent.as_os_str().is_empty() || is_covered_by_handled_root(parent, handled_roots) {
535 return Ok(());
536 }
537
538 fs::create_dir_all(temp_dir.join(parent))?;
539 if resolved.exists() && resolved.is_file() {
540 fs::copy(&resolved, temp_dir.join(rel))?;
541 }
542
543 Ok(())
544}
545
546fn copy_project_local_optional_path(
547 root: &Path,
548 temp_dir: &Path,
549 path: &Option<PathBuf>,
550 handled_roots: &[PathBuf],
551) -> Result<()> {
552 let Some(path) = path else { return Ok(()) };
553 let resolved = resolve_against_root(root, path);
554 let rel = relative_to_root(root, &resolved);
555 if rel.as_os_str().is_empty() || !resolved.exists() {
556 return Ok(());
557 }
558 let is_external = rel.is_absolute();
559 let target = if is_external || is_covered_by_handled_root(&rel, handled_roots) {
560 temp_dir.join(isolated_mutable_path_rel(&resolved))
561 } else {
562 ensure_safe_relative_path(&rel, "corpus/frontier", path)?;
563 ensure_within_root(root, &resolved, "corpus/frontier", path)?;
564 temp_dir.join(rel)
565 };
566 if resolved.is_dir() {
567 if is_external {
568 copy_dir_recursive(&resolved, &target)
569 } else {
570 copy_project_dir_recursive(root, &resolved, &target)
571 }
572 } else {
573 if let Some(parent) = target.parent() {
574 fs::create_dir_all(parent)?;
575 }
576 fs::copy(&resolved, target)?;
577 Ok(())
578 }
579}
580
581pub fn symlink_dir(src: &Path, dst: &Path) -> Result<()> {
583 #[cfg(unix)]
584 {
585 std::os::unix::fs::symlink(src, dst)?;
586 }
587 #[cfg(windows)]
588 {
589 std::os::windows::fs::symlink_dir(src, dst)?;
590 }
591 Ok(())
592}
593
594const MAX_SYMLINK_DEPTH: usize = 10;
596
597fn symlink_nested_libs(lib_src: &Path, lib_dst: &Path, depth: usize) -> Result<()> {
599 if depth >= MAX_SYMLINK_DEPTH {
600 return Ok(());
601 }
602
603 let nested_lib_dirs: Vec<PathBuf> =
604 if let Ok(config) = Config::load_with_root_and_fallback(lib_src) {
605 config.libs
606 } else {
607 vec![PathBuf::from("lib")]
608 };
609
610 for nested_lib_dir in nested_lib_dirs {
611 if !is_safe_relative_path(&nested_lib_dir) {
615 continue;
616 }
617 let nested_lib = lib_src.join(&nested_lib_dir);
618 if !nested_lib.exists() {
619 continue;
620 }
621 let Ok(meta) = fs::symlink_metadata(&nested_lib) else { continue };
623 if meta.file_type().is_symlink() || !meta.is_dir() {
624 continue;
625 }
626 if ensure_within_root(lib_src, &nested_lib, "nested lib", &nested_lib).is_err() {
627 continue;
628 }
629 process_nested_lib_dir(&nested_lib, lib_dst, &nested_lib_dir, depth)?;
630 }
631
632 Ok(())
633}
634
635fn process_nested_lib_dir(
636 nested_lib: &Path,
637 lib_dst: &Path,
638 lib_rel: &Path,
639 depth: usize,
640) -> Result<()> {
641 if !nested_lib.exists() || !nested_lib.is_dir() {
642 return Ok(());
643 }
644
645 let entries = match fs::read_dir(nested_lib) {
646 Ok(e) => e,
647 Err(_) => return Ok(()),
648 };
649
650 for entry in entries.flatten() {
651 let Ok(file_type) = entry.file_type() else { continue };
655 if file_type.is_symlink() || !file_type.is_dir() {
656 continue;
657 }
658
659 let entry_path = entry.path();
660 let entry_name = entry.file_name();
661 let nested_dst = lib_dst.join(lib_rel).join(&entry_name);
662
663 if !nested_dst.exists() {
664 if let Some(parent) = nested_dst.parent() {
665 let _ = fs::create_dir_all(parent);
666 }
667 let _ = symlink_dir(&entry_path, &nested_dst);
668 }
669
670 symlink_nested_libs(&entry_path, &nested_dst, depth + 1)?;
671 }
672
673 Ok(())
674}
675
676pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
678 let mut visited = Vec::new();
679 copy_dir_recursive_inner(src, dst, src, &mut visited)
680}
681
682fn copy_project_dir_recursive(root: &Path, src: &Path, dst: &Path) -> Result<()> {
683 let mut visited = Vec::new();
684 copy_dir_recursive_inner(src, dst, root, &mut visited)
685}
686
687fn copy_dir_recursive_inner(
688 src: &Path,
689 dst: &Path,
690 allowed_root: &Path,
691 visited: &mut Vec<PathBuf>,
692) -> Result<()> {
693 if !src.exists() {
694 return Ok(());
695 }
696 ensure_within_root(allowed_root, src, "copied directory", src)?;
697 let canonical = src.canonicalize()?;
698 if visited.contains(&canonical) {
699 return Ok(());
700 }
701 visited.push(canonical);
702
703 let result = (|| {
704 fs::create_dir_all(dst)?;
705
706 for entry in fs::read_dir(src)? {
707 let entry = entry?;
708 let path = entry.path();
709 let dest_path = dst.join(entry.file_name());
710
711 let meta = fs::symlink_metadata(&path)?;
712
713 if meta.file_type().is_symlink() {
714 if path.is_dir() {
715 if ensure_within_root(allowed_root, &path, "symlinked directory", &path).is_ok()
716 {
717 copy_dir_recursive_inner(&path, &dest_path, allowed_root, visited)?;
718 }
719 } else {
720 fs::copy(&path, &dest_path)?;
721 }
722 } else if meta.is_dir() {
723 copy_dir_recursive_inner(&path, &dest_path, allowed_root, visited)?;
724 } else {
725 fs::copy(&path, &dest_path)?;
726 }
727 }
728
729 Ok(())
730 })();
731
732 visited.pop();
733 result
734}
735
736pub fn unshare_path(project_root: &Path, root: &Path, rel: &Path) -> Result<()> {
742 let root = canonicalize_path(root)?;
743 let mut current = root.clone();
744 let mut components = rel.components().peekable();
745 while let Some(component) = components.next() {
746 current.push(component);
747 let Ok(metadata) = fs::symlink_metadata(¤t) else { return Ok(()) };
748 if !metadata.file_type().is_symlink() {
749 continue;
750 }
751 if components.peek().is_none() {
752 fs::remove_file(¤t)?;
753 return Ok(());
754 }
755 let source = canonicalize_path(¤t)?;
756 eyre::ensure!(
759 !root.starts_with(&source),
760 "mutation workspace {} is inside linked dependency {}",
761 root.display(),
762 source.display()
763 );
764 #[cfg(unix)]
765 fs::remove_file(¤t)?;
766 #[cfg(windows)]
767 fs::remove_dir(¤t)?;
768 copy_unshared_dir(project_root, &root, ¤t, &source, ¤t)?;
769 }
770 Ok(())
771}
772
773fn copy_unshared_dir(
774 project_root: &Path,
775 root: &Path,
776 isolated_root: &Path,
777 src: &Path,
778 dst: &Path,
779) -> Result<()> {
780 fs::create_dir_all(dst)?;
781 for entry in fs::read_dir(src)? {
782 let entry = entry?;
783 let destination = dst.join(entry.file_name());
784 let file_type = entry.file_type()?;
785 if file_type.is_symlink() {
786 let target = resolve_against_root(src, &fs::read_link(entry.path())?);
788 let rebased = rebase_project_path(project_root, root, &target);
789 let target = if rebased.starts_with(isolated_root) || rebased.is_file() {
792 rebased
793 } else {
794 target
795 };
796 symlink_entry(&target, &destination, file_type)?;
797 } else if file_type.is_dir() {
798 copy_unshared_dir(project_root, root, isolated_root, &entry.path(), &destination)?;
799 } else {
800 fs::copy(entry.path(), &destination)?;
801 }
802 }
803 Ok(())
804}
805
806fn symlink_entry(target: &Path, dst: &Path, _file_type: fs::FileType) -> Result<()> {
808 #[cfg(unix)]
809 std::os::unix::fs::symlink(target, dst)?;
810 #[cfg(windows)]
811 {
812 if _file_type.is_symlink_dir() {
813 std::os::windows::fs::symlink_dir(target, dst)?;
814 } else {
815 std::os::windows::fs::symlink_file(target, dst)?;
816 }
817 }
818 Ok(())
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824 use std::{collections::BTreeMap, str::FromStr};
825
826 use foundry_compilers::artifacts::ModelCheckerSettings;
827 use foundry_config::fs_permissions::PathPermission;
828 use tempfile::TempDir;
829
830 #[cfg(unix)]
831 use std::os::unix::fs::symlink as symlink_file;
832 #[cfg(windows)]
833 use std::os::windows::fs::symlink_file;
834
835 fn create_test_dir_structure(base: &Path, structure: &[&str]) {
836 for path in structure {
837 let full_path = base.join(path);
838 if path.ends_with('/') {
839 fs::create_dir_all(&full_path).unwrap();
840 } else {
841 if let Some(parent) = full_path.parent() {
842 fs::create_dir_all(parent).unwrap();
843 }
844 fs::write(&full_path, format!("// {path}")).unwrap();
845 }
846 }
847 }
848
849 #[test]
850 fn test_unshare_path_preserves_nested_aliases_and_external_links() {
851 let temp = TempDir::new().unwrap();
852 let root = temp.path().join("project");
853 let workspace = temp.path().join("deeper/workspace");
854 let dependency = root.join("lib/bucket");
855 create_test_dir_structure(
856 &root,
857 &[
858 "lib/bucket/Arithmetic.sol",
859 "lib/bucket/nested/",
860 "src/Local.sol",
861 "uncopied/Local.sol",
862 ],
863 );
864 create_test_dir_structure(temp.path(), &["shared/Helper.sol"]);
865 fs::create_dir_all(workspace.join("src")).unwrap();
866 fs::copy(root.join("src/Local.sol"), workspace.join("src/Local.sol")).unwrap();
867 symlink_dir(&root.join("lib"), &workspace.join("lib")).unwrap();
868 symlink_file("Arithmetic.sol", dependency.join("Alias.sol")).unwrap();
869 symlink_file("../Arithmetic.sol", dependency.join("nested/Alias.sol")).unwrap();
870 symlink_file(dependency.join("Arithmetic.sol"), dependency.join("Absolute.sol")).unwrap();
871 symlink_file("../../../shared/Helper.sol", dependency.join("Helper.sol")).unwrap();
872 symlink_file("../../src/Local.sol", dependency.join("Local.sol")).unwrap();
873 symlink_file("../../uncopied/Local.sol", dependency.join("Uncopied.sol")).unwrap();
874 symlink_file("nonexistent", dependency.join("unused")).unwrap();
875 symlink_dir(Path::new("nested"), &dependency.join("a_alias")).unwrap();
876 symlink_dir(Path::new("missing-directory"), &dependency.join("unused_dir")).unwrap();
877
878 unshare_path(&root, &workspace, Path::new("lib/bucket/Arithmetic.sol")).unwrap();
879 fs::write(workspace.join("lib/bucket/Arithmetic.sol"), "mutant").unwrap();
880 fs::write(workspace.join("src/Local.sol"), "workspace local").unwrap();
881
882 for alias in ["Alias.sol", "nested/Alias.sol", "Absolute.sol", "a_alias/Alias.sol"] {
883 assert_eq!(
884 fs::read_to_string(workspace.join("lib/bucket").join(alias)).unwrap(),
885 "mutant",
886 "{alias}",
887 );
888 }
889 assert_eq!(
890 fs::read_to_string(dependency.join("Arithmetic.sol")).unwrap(),
891 "// lib/bucket/Arithmetic.sol",
892 );
893 assert_eq!(
894 fs::read_to_string(workspace.join("lib/bucket/Helper.sol")).unwrap(),
895 "// shared/Helper.sol",
896 );
897 assert_eq!(
898 fs::read_to_string(workspace.join("lib/bucket/Local.sol")).unwrap(),
899 "workspace local",
900 );
901 assert_eq!(
902 fs::read_to_string(workspace.join("lib/bucket/Uncopied.sol")).unwrap(),
903 "// uncopied/Local.sol",
904 );
905 for link in ["unused", "unused_dir"] {
906 let path = workspace.join("lib/bucket").join(link);
907 assert!(fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
908 assert!(!path.exists());
909 }
910 #[cfg(windows)]
911 assert!(
912 fs::symlink_metadata(workspace.join("lib/bucket/unused_dir"))
913 .unwrap()
914 .file_type()
915 .is_symlink_dir()
916 );
917 }
918
919 #[test]
920 fn test_unshare_path_keeps_links_to_partially_copied_dirs() {
921 let temp = TempDir::new().unwrap();
922 let root = temp.path().join("project");
923 let workspace = temp.path().join("workspace");
924 create_test_dir_structure(
925 &root,
926 &["lib/bucket/Arithmetic.sol", "shared/visible/V.sol", "shared/hidden/H.sol"],
927 );
928 create_test_dir_structure(&workspace, &["shared/visible/V.sol"]);
930 symlink_dir(&root.join("lib"), &workspace.join("lib")).unwrap();
931 symlink_dir(Path::new("../../shared"), &root.join("lib/bucket/helpers")).unwrap();
932
933 unshare_path(&root, &workspace, Path::new("lib/bucket/Arithmetic.sol")).unwrap();
934
935 assert_eq!(
936 fs::read_to_string(workspace.join("lib/bucket/helpers/hidden/H.sol")).unwrap(),
937 "// shared/hidden/H.sol",
938 );
939 }
940
941 #[test]
942 fn test_unshare_path_rejects_workspace_inside_linked_tree() {
943 let temp = TempDir::new().unwrap();
944 let root = temp.path().join("project");
945 let workspace = root.join("lib/.tmp/workspace");
946 create_test_dir_structure(&root, &["lib/bucket/Arithmetic.sol", "lib/.tmp/workspace/"]);
947 symlink_dir(&root.join("lib"), &workspace.join("lib")).unwrap();
948
949 let err = unshare_path(&root, &workspace, Path::new("lib/bucket/Arithmetic.sol"))
950 .unwrap_err()
951 .to_string();
952 assert!(err.contains("is inside linked dependency"), "{err}");
953 }
954
955 #[cfg(windows)]
956 #[test]
957 fn test_symlink_entry_preserves_directory_type_before_target_exists() {
958 let temp = TempDir::new().unwrap();
959 let original = temp.path().join("original");
960 symlink_dir(Path::new("z_real"), &original).unwrap();
961 let file_type = fs::symlink_metadata(original).unwrap().file_type();
962 let alias = temp.path().join("a_alias");
963 symlink_entry(Path::new("z_real"), &alias, file_type).unwrap();
964 assert!(fs::symlink_metadata(&alias).unwrap().file_type().is_symlink_dir());
965 fs::create_dir(temp.path().join("z_real")).unwrap();
966 assert!(alias.is_dir());
967 }
968
969 #[test]
970 fn test_rebase_remapping_preserves_context_directory_boundary() {
971 let remapping = RelativeRemapping::from(Remapping {
972 context: Some(format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}")),
973 name: "inner/".to_string(),
974 path: format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}lib{MAIN_SEPARATOR}inner"),
975 });
976
977 let remapping = rebase_remapping(Path::new("project"), Path::new("workspace"), &remapping);
978 assert!(remapping.context.unwrap().ends_with(MAIN_SEPARATOR));
979 }
980
981 #[cfg(unix)]
982 #[test]
983 fn test_rebase_config_paths_canonicalizes_workspace_alias() {
984 let temp = TempDir::new().unwrap();
985 let root = temp.path().join("project");
986 let workspace = temp.path().join("workspace");
987 let alias = temp.path().join("alias");
988 create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]);
989 fs::create_dir(&workspace).unwrap();
990 symlink_dir(&workspace, &alias).unwrap();
991
992 let config = Config {
993 root: root.clone(),
994 src: root.join("src"),
995 test: root.join("test"),
996 remappings: vec![Remapping::from_str("@src/=src/").unwrap().into()],
997 ..Default::default()
998 };
999 copy_project(&config, &alias).unwrap();
1000 let config = rebase_config_paths(&config, &alias).sanitized();
1001 let canonical_root = canonicalize_path(&workspace).unwrap();
1002 assert_eq!(config.root, canonical_root);
1003 assert_eq!(config.src, canonical_root.join("src"));
1004 assert_eq!(config.test, canonical_root.join("test"));
1005 let remapping = Remapping::from(config.remappings[0].clone());
1006 assert_eq!(Path::new(&remapping.path), canonical_root.join("src"));
1007 }
1008
1009 #[test]
1010 fn test_rebase_config_paths_rebases_materialized_project_paths() {
1011 let temp = TempDir::new().unwrap();
1012 let root = temp.path().join("project");
1013 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1014 let external = temp.path().join("external");
1015
1016 let mut contracts = BTreeMap::new();
1017 contracts.insert(root.join("src/Target.sol").display().to_string(), vec!["Target".into()]);
1018 contracts
1019 .insert(external.join("External.sol").display().to_string(), vec!["External".into()]);
1020
1021 let config = Config {
1022 root: root.clone(),
1023 src: root.join("contracts"),
1024 test: root.join("checks"),
1025 script: root.join("deploy"),
1026 out: root.join("custom-out"),
1027 cache_path: root.join("custom-cache"),
1028 snapshots: root.join("custom-snapshots"),
1029 broadcast: root.join("custom-broadcast"),
1030 mutation_dir: root.join("custom-cache/mutation"),
1031 test_failures_file: root.join("custom-cache/test-failures"),
1032 build_info_path: Some(root.join("custom-build-info")),
1033 libs: vec![root.join("vendor"), external.join("lib")],
1034 include_paths: vec![root.join("shared"), PathBuf::from("../external/include")],
1035 allow_paths: vec![root.join("fixtures"), PathBuf::from("../external/fixtures")],
1036 ignored_error_codes_from: vec![
1037 (
1038 root.join("contracts"),
1039 vec![foundry_config::SolidityErrorCode::UnusedLocalVariable],
1040 ),
1041 (
1042 external.join("contracts"),
1043 vec![foundry_config::SolidityErrorCode::UnusedFunctionParameter],
1044 ),
1045 ],
1046 ignored_file_paths: vec![
1047 root.join("contracts/Ignored.sol"),
1048 external.join("Ignored.sol"),
1049 ],
1050 remappings: vec![
1051 Remapping::from_str(&format!("@src/={}/", root.join("src").display()))
1052 .unwrap()
1053 .into(),
1054 Remapping::from_str(&format!("@ext/={}/", external.join("src").display()))
1055 .unwrap()
1056 .into(),
1057 ],
1058 fs_permissions: foundry_config::FsPermissions::new([
1059 PathPermission::read(root.join("fixtures")),
1060 PathPermission::read("../external/fixtures"),
1061 ]),
1062 fuzz: foundry_config::FuzzConfig {
1063 corpus: foundry_config::FuzzCorpusConfig {
1064 corpus_dir: Some(root.join("fuzz-corpus")),
1065 frontier_dir: Some(root.join("fuzz-frontier")),
1066 ..Default::default()
1067 },
1068 ..Default::default()
1069 },
1070 invariant: foundry_config::InvariantConfig {
1071 corpus: foundry_config::FuzzCorpusConfig {
1072 corpus_dir: Some(PathBuf::from("invariant-corpus")),
1073 frontier_dir: Some(PathBuf::from("invariant-frontier")),
1074 ..Default::default()
1075 },
1076 ..Default::default()
1077 },
1078 model_checker: Some(ModelCheckerSettings {
1079 contracts,
1080 engine: None,
1081 timeout: None,
1082 targets: None,
1083 invariants: None,
1084 show_unproved: None,
1085 div_mod_with_slacks: None,
1086 solvers: None,
1087 show_unsupported: None,
1088 show_proved_safe: None,
1089 }),
1090 ..Default::default()
1091 };
1092
1093 let temp_config = rebase_config_paths(&config, &workspace);
1094 let external = normalize_existing_ancestor(&external);
1095
1096 assert_eq!(temp_config.root, workspace);
1097 assert_eq!(temp_config.src, workspace.join("contracts"));
1098 assert_eq!(temp_config.test, workspace.join("checks"));
1099 assert_eq!(temp_config.script, workspace.join("deploy"));
1100 assert_eq!(temp_config.out, workspace.join("custom-out"));
1101 assert_eq!(temp_config.cache_path, workspace.join("custom-cache"));
1102 assert_eq!(temp_config.snapshots, workspace.join("custom-snapshots"));
1103 assert_eq!(temp_config.broadcast, workspace.join("custom-broadcast"));
1104 assert_eq!(temp_config.mutation_dir, workspace.join("custom-cache/mutation"));
1105 assert_eq!(temp_config.test_failures_file, workspace.join("custom-cache/test-failures"));
1106 assert_eq!(temp_config.build_info_path, Some(workspace.join("custom-build-info")));
1107 assert_eq!(temp_config.libs, vec![workspace.join("vendor"), external.join("lib")]);
1108 assert_eq!(
1109 temp_config.include_paths,
1110 vec![workspace.join("shared"), external.join("include")]
1111 );
1112 assert_eq!(
1113 temp_config.allow_paths,
1114 vec![workspace.join("fixtures"), external.join("fixtures")]
1115 );
1116 assert_eq!(
1117 temp_config.ignored_error_codes_from,
1118 vec![
1119 (
1120 workspace.join("contracts"),
1121 vec![foundry_config::SolidityErrorCode::UnusedLocalVariable]
1122 ),
1123 (
1124 external.join("contracts"),
1125 vec![foundry_config::SolidityErrorCode::UnusedFunctionParameter]
1126 )
1127 ]
1128 );
1129 assert_eq!(
1130 temp_config.ignored_file_paths,
1131 vec![workspace.join("contracts/Ignored.sol"), external.join("Ignored.sol")]
1132 );
1133
1134 let remappings =
1135 temp_config.remappings.into_iter().map(Remapping::from).collect::<Vec<_>>();
1136 assert_eq!(remappings[0].path, format!("{}/", workspace.join("src").display()));
1137 assert_eq!(remappings[1].path, format!("{}/", external.join("src").display()));
1138
1139 assert_eq!(
1140 temp_config.fs_permissions.permissions[0].path,
1141 normalize_existing_ancestor(&workspace.join("fixtures"))
1142 );
1143 assert_eq!(
1144 temp_config.fs_permissions.permissions[1].path,
1145 normalize_existing_ancestor(&external.join("fixtures"))
1146 );
1147 assert_eq!(temp_config.fuzz.corpus.corpus_dir, Some(workspace.join("fuzz-corpus")));
1148 assert_eq!(temp_config.fuzz.corpus.frontier_dir, Some(workspace.join("fuzz-frontier")));
1149 assert_eq!(
1150 temp_config.invariant.corpus.corpus_dir,
1151 Some(workspace.join("invariant-corpus"))
1152 );
1153 assert_eq!(
1154 temp_config.invariant.corpus.frontier_dir,
1155 Some(workspace.join("invariant-frontier"))
1156 );
1157
1158 let contracts = temp_config.model_checker.unwrap().contracts;
1159 assert!(
1160 contracts.contains_key(&workspace.join("src").join("Target.sol").display().to_string())
1161 );
1162 assert!(contracts.contains_key(&external.join("External.sol").display().to_string()));
1163 }
1164
1165 #[test]
1166 fn test_copy_project_preserves_external_read_only_paths() {
1167 let temp = TempDir::new().unwrap();
1168 let root = temp.path().join("project");
1169 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1170 let external = temp.path().join("shared-solidity");
1171 create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]);
1172 create_test_dir_structure(&external, &["Shared.sol"]);
1173
1174 let config = Config {
1175 root: root.clone(),
1176 src: root.join("src"),
1177 test: root.join("test"),
1178 include_paths: vec![PathBuf::from("../shared-solidity")],
1179 allow_paths: vec![PathBuf::from("../shared-solidity")],
1180 remappings: vec![Remapping::from_str("shared/=../shared-solidity/").unwrap().into()],
1181 ..Default::default()
1182 };
1183
1184 copy_project(&config, &workspace).unwrap();
1185 let temp_config = rebase_config_paths(&config, &workspace);
1186 let external = normalize_existing_ancestor(&external);
1187 let remappings =
1188 temp_config.remappings.into_iter().map(Remapping::from).collect::<Vec<_>>();
1189
1190 assert!(workspace.join("src/Target.sol").exists());
1191 assert!(!workspace.join("shared-solidity/Shared.sol").exists());
1192 assert_eq!(temp_config.include_paths, vec![external.clone()]);
1193 assert_eq!(temp_config.allow_paths, vec![external.clone()]);
1194 assert_eq!(remappings[0].path, format!("{}/", external.display()));
1195 }
1196
1197 #[test]
1198 fn test_copy_project_copies_project_local_corpus_and_frontier_paths() {
1199 let temp = TempDir::new().unwrap();
1200 let root = temp.path().join("project");
1201 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1202 create_test_dir_structure(
1203 &root,
1204 &[
1205 "src/Target.sol",
1206 "test/Target.t.sol",
1207 "fuzz-corpus/seed.json",
1208 "fuzz-frontier/frontier.json",
1209 "invariant-corpus/seed.json",
1210 "invariant-frontier/frontier.json",
1211 ],
1212 );
1213
1214 let config = Config {
1215 root: root.clone(),
1216 src: root.join("src"),
1217 test: root.join("test"),
1218 fuzz: foundry_config::FuzzConfig {
1219 corpus: foundry_config::FuzzCorpusConfig {
1220 corpus_dir: Some(PathBuf::from("fuzz-corpus")),
1221 frontier_dir: Some(PathBuf::from("fuzz-frontier")),
1222 ..Default::default()
1223 },
1224 ..Default::default()
1225 },
1226 invariant: foundry_config::InvariantConfig {
1227 corpus: foundry_config::FuzzCorpusConfig {
1228 corpus_dir: Some(PathBuf::from("invariant-corpus")),
1229 frontier_dir: Some(PathBuf::from("invariant-frontier")),
1230 ..Default::default()
1231 },
1232 ..Default::default()
1233 },
1234 ..Default::default()
1235 };
1236
1237 copy_project(&config, &workspace).unwrap();
1238 let temp_config = rebase_config_paths(&config, &workspace);
1239
1240 assert!(workspace.join("fuzz-corpus/seed.json").exists());
1241 assert!(workspace.join("fuzz-frontier/frontier.json").exists());
1242 assert!(workspace.join("invariant-corpus/seed.json").exists());
1243 assert!(workspace.join("invariant-frontier/frontier.json").exists());
1244 assert_eq!(temp_config.fuzz.corpus.corpus_dir, Some(workspace.join("fuzz-corpus")));
1245 assert_eq!(temp_config.fuzz.corpus.frontier_dir, Some(workspace.join("fuzz-frontier")));
1246 assert_eq!(
1247 temp_config.invariant.corpus.corpus_dir,
1248 Some(workspace.join("invariant-corpus"))
1249 );
1250 assert_eq!(
1251 temp_config.invariant.corpus.frontier_dir,
1252 Some(workspace.join("invariant-frontier"))
1253 );
1254 }
1255
1256 #[test]
1257 fn test_copy_project_isolates_external_corpus_and_frontier_paths() {
1258 let temp = TempDir::new().unwrap();
1259 let root = temp.path().join("project");
1260 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1261 let corpus = temp.path().join("shared-corpus");
1262 let frontier = temp.path().join("shared-frontier");
1263 create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]);
1264 create_test_dir_structure(&corpus, &["seed.json"]);
1265 create_test_dir_structure(&frontier, &["frontier.json"]);
1266
1267 let config = Config {
1268 root: root.clone(),
1269 src: root.join("src"),
1270 test: root.join("test"),
1271 invariant: foundry_config::InvariantConfig {
1272 corpus: foundry_config::FuzzCorpusConfig {
1273 corpus_dir: Some(PathBuf::from("../shared-corpus")),
1274 frontier_dir: Some(PathBuf::from("../shared-frontier")),
1275 ..Default::default()
1276 },
1277 ..Default::default()
1278 },
1279 ..Default::default()
1280 };
1281
1282 copy_project(&config, &workspace).unwrap();
1283 let temp_config = rebase_config_paths(&config, &workspace);
1284 let rebased_corpus = temp_config.invariant.corpus.corpus_dir.unwrap();
1285 let rebased_frontier = temp_config.invariant.corpus.frontier_dir.unwrap();
1286
1287 assert!(rebased_corpus.starts_with(workspace.join(".foundry_mutable")));
1288 assert!(rebased_frontier.starts_with(workspace.join(".foundry_mutable")));
1289 assert!(rebased_corpus.join("seed.json").exists());
1290 assert!(rebased_frontier.join("frontier.json").exists());
1291 }
1292
1293 #[test]
1294 fn test_copy_project_merges_overlapping_mutable_paths() {
1295 let temp = TempDir::new().unwrap();
1296 let root = temp.path().join("project");
1297 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1298 create_test_dir_structure(
1299 &root,
1300 &[
1301 "src/Target.sol",
1302 "test/Target.t.sol",
1303 "state/corpus/seed.json",
1304 "state/frontier.json",
1305 ],
1306 );
1307
1308 let config = Config {
1309 root: root.clone(),
1310 src: root.join("src"),
1311 test: root.join("test"),
1312 fuzz: foundry_config::FuzzConfig {
1313 corpus: foundry_config::FuzzCorpusConfig {
1314 corpus_dir: Some(PathBuf::from("state/corpus")),
1315 frontier_dir: Some(PathBuf::from("state")),
1316 ..Default::default()
1317 },
1318 ..Default::default()
1319 },
1320 ..Default::default()
1321 };
1322
1323 copy_project(&config, &workspace).unwrap();
1324
1325 assert!(workspace.join("state/corpus/seed.json").exists());
1326 assert!(workspace.join("state/frontier.json").exists());
1327 }
1328
1329 #[test]
1330 fn test_copy_project_isolates_external_failure_persist_dirs() {
1331 let temp = TempDir::new().unwrap();
1332 let root = temp.path().join("project");
1333 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1334 let fuzz_failures = temp.path().join("shared-fuzz-failures");
1335 let invariant_failures = temp.path().join("shared-invariant-failures");
1336 create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]);
1337 create_test_dir_structure(&fuzz_failures, &["Target/test/fuzz-failure"]);
1338 create_test_dir_structure(&invariant_failures, &["Target/invariant/invariant-failure"]);
1339
1340 let config = Config {
1341 root: root.clone(),
1342 src: root.join("src"),
1343 test: root.join("test"),
1344 fuzz: foundry_config::FuzzConfig {
1345 failure_persist_dir: Some(fuzz_failures),
1346 ..Default::default()
1347 },
1348 invariant: foundry_config::InvariantConfig {
1349 failure_persist_dir: Some(invariant_failures),
1350 ..Default::default()
1351 },
1352 ..Default::default()
1353 };
1354
1355 copy_project(&config, &workspace).unwrap();
1356 let temp_config = rebase_config_paths(&config, &workspace);
1357 let fuzz_failure_dir = temp_config.fuzz.failure_persist_dir.unwrap();
1358 let invariant_failure_dir = temp_config.invariant.failure_persist_dir.unwrap();
1359
1360 assert!(
1361 fuzz_failure_dir.starts_with(workspace.join(".foundry_mutable")),
1362 "{}",
1363 fuzz_failure_dir.display()
1364 );
1365 assert!(
1366 invariant_failure_dir.starts_with(workspace.join(".foundry_mutable")),
1367 "{}",
1368 invariant_failure_dir.display()
1369 );
1370 assert!(fuzz_failure_dir.join("Target/test/fuzz-failure").exists());
1371 assert!(invariant_failure_dir.join("Target/invariant/invariant-failure").exists());
1372 }
1373
1374 #[cfg(not(target_os = "windows"))]
1375 #[test]
1376 fn test_copy_project_isolates_corpus_under_symlinked_lib_root() {
1377 let temp = TempDir::new().unwrap();
1378 let root = temp.path().join("project");
1379 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1380 create_test_dir_structure(
1381 &root,
1382 &[
1383 "src/Target.sol",
1384 "test/Target.t.sol",
1385 ".real-lib/mycorpus/seed.json",
1386 ".real-lib/Dependency.sol",
1387 ],
1388 );
1389 symlink_dir(Path::new(".real-lib"), &root.join("lib")).unwrap();
1390
1391 let config = Config {
1392 root: root.clone(),
1393 src: root.join("src"),
1394 test: root.join("test"),
1395 libs: vec![root.join("lib")],
1396 invariant: foundry_config::InvariantConfig {
1397 corpus: foundry_config::FuzzCorpusConfig {
1398 corpus_dir: Some(PathBuf::from("lib/mycorpus")),
1399 ..Default::default()
1400 },
1401 ..Default::default()
1402 },
1403 ..Default::default()
1404 };
1405
1406 copy_project(&config, &workspace).unwrap();
1407 let temp_config = rebase_config_paths(&config, &workspace);
1408 let rebased_corpus = temp_config.invariant.corpus.corpus_dir.unwrap();
1409
1410 assert!(rebased_corpus.starts_with(workspace.join(".foundry_mutable")));
1411 assert!(rebased_corpus.join("seed.json").exists());
1412 assert!(!rebased_corpus.starts_with(workspace.join("lib")));
1413 }
1414
1415 #[cfg(not(target_os = "windows"))]
1416 #[test]
1417 fn test_rebase_config_paths_preserves_symlink_parent_semantics() {
1418 let temp = TempDir::new().unwrap();
1419 let root = temp.path().join("project");
1420 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1421 let external = temp.path().join("external");
1422 create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]);
1423 create_test_dir_structure(
1424 &external,
1425 &["pkg/Dependency.sol", "include/Shared.sol", "pkg/corpus/seed.json"],
1426 );
1427 symlink_dir(&external.join("pkg"), &root.join("link")).unwrap();
1428
1429 let config = Config {
1430 root: root.clone(),
1431 src: root.join("src"),
1432 test: root.join("test"),
1433 libs: vec![PathBuf::from("link")],
1434 include_paths: vec![PathBuf::from("link/../include")],
1435 invariant: foundry_config::InvariantConfig {
1436 corpus: foundry_config::FuzzCorpusConfig {
1437 corpus_dir: Some(PathBuf::from("link/corpus")),
1438 ..Default::default()
1439 },
1440 ..Default::default()
1441 },
1442 ..Default::default()
1443 };
1444
1445 copy_project(&config, &workspace).unwrap();
1446 let temp_config = rebase_config_paths(&config, &workspace);
1447 let rebased_corpus = temp_config.invariant.corpus.corpus_dir.unwrap();
1448 let external = normalize_existing_ancestor(&external);
1449
1450 assert_eq!(temp_config.libs, vec![external.join("pkg")]);
1451 assert_eq!(temp_config.include_paths, vec![external.join("include")]);
1452 assert!(rebased_corpus.starts_with(workspace.join(".foundry_mutable")));
1453 assert!(rebased_corpus.join("seed.json").exists());
1454 }
1455
1456 #[cfg(windows)]
1457 #[test]
1458 fn test_isolated_mutable_paths_include_windows_prefix_in_identity() {
1459 let c_drive = isolated_mutable_path_rel(Path::new(r"C:\shared\state"));
1460 let d_drive = isolated_mutable_path_rel(Path::new(r"D:\shared\state"));
1461
1462 assert_ne!(c_drive, d_drive);
1463 }
1464
1465 #[test]
1466 fn test_copy_project_copies_project_local_remapping_targets() {
1467 let temp = TempDir::new().unwrap();
1468 let root = temp.path().join("project");
1469 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1470 create_test_dir_structure(
1471 &root,
1472 &["src/Target.sol", "test/Target.t.sol", "packages/shared/src/Shared.sol"],
1473 );
1474
1475 let config = Config {
1476 root: root.clone(),
1477 src: root.join("src"),
1478 test: root.join("test"),
1479 remappings: vec![Remapping::from_str("shared/=packages/shared/src/").unwrap().into()],
1480 ..Default::default()
1481 };
1482
1483 copy_project(&config, &workspace).unwrap();
1484 let temp_config = rebase_config_paths(&config, &workspace);
1485 let remappings =
1486 temp_config.remappings.into_iter().map(Remapping::from).collect::<Vec<_>>();
1487
1488 assert!(workspace.join("packages/shared/src/Shared.sol").exists());
1489 assert_eq!(
1490 remappings[0].path,
1491 format!("{}/", workspace.join("packages").join("shared").join("src").display())
1492 );
1493 }
1494
1495 #[test]
1496 fn test_copy_project_preserves_external_libs() {
1497 let temp = TempDir::new().unwrap();
1498 let root = temp.path().join("project");
1499 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1500 let external = temp.path().join("shared-lib");
1501 create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol", "lib/Local.sol"]);
1502 create_test_dir_structure(&external, &["External.sol"]);
1503
1504 let config = Config {
1505 root: root.clone(),
1506 src: root.join("src"),
1507 test: root.join("test"),
1508 libs: vec![PathBuf::from("lib"), PathBuf::from("../shared-lib")],
1509 ..Default::default()
1510 };
1511
1512 copy_project(&config, &workspace).unwrap();
1513 let temp_config = rebase_config_paths(&config, &workspace);
1514 let external = normalize_existing_ancestor(&external);
1515
1516 assert!(workspace.join("lib/Local.sol").exists());
1517 assert!(!workspace.join("shared-lib/External.sol").exists());
1518 assert_eq!(temp_config.libs, vec![workspace.join("lib"), external]);
1519 }
1520
1521 #[test]
1522 fn test_rebase_config_paths_rebases_relative_fs_permissions() {
1523 let temp = TempDir::new().unwrap();
1524 let root = temp.path().join("project");
1525 let workspace = normalize_existing_ancestor(&temp.path().join("workspace"));
1526 fs::create_dir_all(root.join("writes")).unwrap();
1527 fs::create_dir_all(workspace.join("writes")).unwrap();
1528 fs::create_dir_all(workspace.join("logs/sub")).unwrap();
1529
1530 let config = Config {
1531 root,
1532 fs_permissions: foundry_config::FsPermissions::new([
1533 PathPermission::write("./writes"),
1534 PathPermission::read_write("./logs/sub/a.txt"),
1535 ]),
1536 ..Default::default()
1537 };
1538
1539 let temp_config = rebase_config_paths(&config, &workspace).sanitized();
1540
1541 assert_eq!(temp_config.root, workspace);
1542 assert_eq!(
1543 temp_config.fs_permissions.permissions[0].path,
1544 canonicalize_path(workspace.join("writes")).unwrap()
1545 );
1546 assert_eq!(
1547 temp_config.fs_permissions.permissions[1].path,
1548 canonicalize_path(workspace.join("logs/sub")).unwrap().join("a.txt")
1549 );
1550 }
1551
1552 #[test]
1553 fn test_symlink_dir_creates_symlink() {
1554 let temp = TempDir::new().unwrap();
1555 let src = temp.path().join("source_dir");
1556 let dst = temp.path().join("target_link");
1557
1558 fs::create_dir(&src).unwrap();
1559 fs::write(src.join("file.txt"), "content").unwrap();
1560
1561 symlink_dir(&src, &dst).unwrap();
1562
1563 assert!(dst.exists());
1564 assert!(dst.is_symlink());
1565 assert!(dst.join("file.txt").exists());
1566 }
1567
1568 #[test]
1569 fn test_symlink_nested_libs_single_level() {
1570 let temp = TempDir::new().unwrap();
1571
1572 let lib_src = temp.path().join("lib_src");
1573 create_test_dir_structure(
1574 &lib_src,
1575 &[
1576 "src/Contract.sol",
1577 "lib/",
1578 "lib/openzeppelin/contracts/token/ERC20.sol",
1579 "lib/solmate/src/tokens/ERC20.sol",
1580 ],
1581 );
1582
1583 let lib_dst = temp.path().join("lib_dst");
1584 fs::create_dir(&lib_dst).unwrap();
1585
1586 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
1587
1588 assert!(lib_dst.join("lib/openzeppelin").exists());
1589 assert!(lib_dst.join("lib/solmate").exists());
1590 assert!(lib_dst.join("lib/openzeppelin/contracts/token/ERC20.sol").exists());
1591 assert!(lib_dst.join("lib/solmate/src/tokens/ERC20.sol").exists());
1592 }
1593
1594 #[test]
1595 fn test_symlink_nested_libs_deeply_nested() {
1596 let temp = TempDir::new().unwrap();
1597
1598 let lib_src = temp.path().join("lib_src");
1599 create_test_dir_structure(
1600 &lib_src,
1601 &[
1602 "src/Main.sol",
1603 "lib/",
1604 "lib/dep-a/src/A.sol",
1605 "lib/dep-a/lib/",
1606 "lib/dep-a/lib/dep-b/src/B.sol",
1607 "lib/dep-a/lib/dep-b/lib/",
1608 "lib/dep-a/lib/dep-b/lib/dep-c/src/C.sol",
1609 ],
1610 );
1611
1612 let lib_dst = temp.path().join("lib_dst");
1613 fs::create_dir(&lib_dst).unwrap();
1614
1615 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
1616
1617 assert!(lib_dst.join("lib/dep-a").exists());
1618 assert!(lib_dst.join("lib/dep-a/lib/dep-b").exists());
1619 assert!(lib_dst.join("lib/dep-a/lib/dep-b/lib/dep-c").exists());
1620 assert!(lib_dst.join("lib/dep-a/lib/dep-b/lib/dep-c/src/C.sol").exists());
1621 }
1622
1623 #[test]
1624 fn test_symlink_nested_libs_no_nested_lib_dir() {
1625 let temp = TempDir::new().unwrap();
1626
1627 let lib_src = temp.path().join("lib_src");
1628 create_test_dir_structure(&lib_src, &["src/Contract.sol", "test/Test.sol"]);
1629
1630 let lib_dst = temp.path().join("lib_dst");
1631 fs::create_dir(&lib_dst).unwrap();
1632
1633 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
1634
1635 assert!(!lib_dst.join("lib").exists());
1636 }
1637
1638 #[test]
1639 fn test_symlink_nested_libs_skips_existing() {
1640 let temp = TempDir::new().unwrap();
1641
1642 let lib_src = temp.path().join("lib_src");
1643 create_test_dir_structure(&lib_src, &["lib/", "lib/existing/src/File.sol"]);
1644
1645 let lib_dst = temp.path().join("lib_dst");
1646 fs::create_dir_all(lib_dst.join("lib/existing")).unwrap();
1647 fs::write(lib_dst.join("lib/existing/marker.txt"), "pre-existing").unwrap();
1648
1649 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
1650
1651 assert!(lib_dst.join("lib/existing/marker.txt").exists());
1652 }
1653
1654 #[test]
1655 fn test_copy_dir_recursive_basic() {
1656 let temp = TempDir::new().unwrap();
1657
1658 let src = temp.path().join("src");
1659 create_test_dir_structure(
1660 &src,
1661 &["file1.sol", "subdir/file2.sol", "subdir/nested/file3.sol"],
1662 );
1663
1664 let dst = temp.path().join("dst");
1665 copy_dir_recursive(&src, &dst).unwrap();
1666
1667 assert!(dst.join("file1.sol").exists());
1668 assert!(dst.join("subdir/file2.sol").exists());
1669 assert!(dst.join("subdir/nested/file3.sol").exists());
1670 }
1671
1672 #[cfg(not(target_os = "windows"))]
1673 #[test]
1674 fn test_copy_project_dir_recursive_preserves_in_root_symlink_aliases() {
1675 let temp = TempDir::new().unwrap();
1676
1677 let root = temp.path().join("project");
1678 let src = root.join("src");
1679 let shared = root.join(".shared/pkg");
1680 fs::create_dir_all(&shared).unwrap();
1681 fs::create_dir_all(src.join("nested")).unwrap();
1682 fs::write(shared.join("Y.sol"), "contract Y {}").unwrap();
1683 symlink_dir(Path::new("../.shared/pkg"), &src.join("first")).unwrap();
1684 symlink_dir(Path::new("../../.shared/pkg"), &src.join("nested/second")).unwrap();
1685
1686 let dst = temp.path().join("dst/src");
1687 copy_project_dir_recursive(&root, &src, &dst).unwrap();
1688
1689 assert!(dst.join("first/Y.sol").exists());
1690 assert!(dst.join("nested/second/Y.sol").exists());
1691 }
1692
1693 #[test]
1694 fn test_copy_dir_recursive_skips_symlinked_dirs() {
1695 let temp = TempDir::new().unwrap();
1696
1697 let src = temp.path().join("src");
1698 let external = temp.path().join("external");
1699
1700 fs::create_dir_all(&external).unwrap();
1701 fs::write(external.join("secret.txt"), "should not be copied").unwrap();
1702
1703 fs::create_dir_all(&src).unwrap();
1704 fs::write(src.join("file.sol"), "content").unwrap();
1705
1706 symlink_dir(&external, &src.join("external_link")).unwrap();
1707
1708 let dst = temp.path().join("dst");
1709 copy_dir_recursive(&src, &dst).unwrap();
1710
1711 assert!(dst.join("file.sol").exists());
1712 assert!(!dst.join("external_link").exists());
1713 }
1714
1715 #[test]
1716 fn test_copy_dir_recursive_nonexistent_src() {
1717 let temp = TempDir::new().unwrap();
1718
1719 let src = temp.path().join("nonexistent");
1720 let dst = temp.path().join("dst");
1721
1722 copy_dir_recursive(&src, &dst).unwrap();
1723 assert!(!dst.exists());
1724 }
1725
1726 #[test]
1727 fn test_copy_project_copies_include_paths_under_root() {
1728 let temp = TempDir::new().unwrap();
1729 let root = temp.path().join("project");
1730 let out = temp.path().join("workspace");
1731 create_test_dir_structure(
1732 &root,
1733 &["src/Counter.sol", "test/Counter.t.sol", "include/Shared.sol"],
1734 );
1735
1736 let config = Config {
1737 root: root.clone(),
1738 src: root.join("src"),
1739 test: root.join("test"),
1740 script: root.join("script"),
1741 include_paths: vec![root.join("include")],
1742 ..Default::default()
1743 };
1744
1745 copy_project(&config, &out).unwrap();
1746
1747 assert!(out.join("include/Shared.sol").exists());
1748 }
1749
1750 #[test]
1751 fn test_copy_project_skips_include_paths_covered_by_libs() {
1752 let temp = TempDir::new().unwrap();
1753 let root = temp.path().join("project");
1754 let out = temp.path().join("workspace");
1755 create_test_dir_structure(
1756 &root,
1757 &["src/Counter.sol", "test/Counter.t.sol", "lib/foo/Foo.sol", "lib/bar/Bar.sol"],
1758 );
1759
1760 let config = Config {
1761 root: root.clone(),
1762 src: root.join("src"),
1763 test: root.join("test"),
1764 script: root.join("script"),
1765 libs: vec![root.join("lib")],
1766 include_paths: vec![root.join("lib/foo")],
1767 ..Default::default()
1768 };
1769
1770 copy_project(&config, &out).unwrap();
1771
1772 assert!(out.join("lib/foo/Foo.sol").exists());
1773 assert!(out.join("lib/bar/Bar.sol").exists());
1774 }
1775
1776 #[test]
1777 fn test_copy_project_preserves_absolute_external_include_paths() {
1778 let temp = TempDir::new().unwrap();
1779 let root = temp.path().join("project");
1780 let outside = temp.path().join("outside");
1781 let out = temp.path().join("workspace");
1782 create_test_dir_structure(&root, &["src/Counter.sol", "test/Counter.t.sol"]);
1783 create_test_dir_structure(&outside, &["Shared.sol"]);
1784
1785 let config = Config {
1786 root: root.clone(),
1787 src: root.join("src"),
1788 test: root.join("test"),
1789 script: root.join("script"),
1790 include_paths: vec![outside.clone()],
1791 ..Default::default()
1792 };
1793
1794 copy_project(&config, &out).unwrap();
1795 let temp_config = rebase_config_paths(&config, &out);
1796 let outside = normalize_existing_ancestor(&outside);
1797
1798 assert_eq!(temp_config.include_paths, vec![outside]);
1799 assert!(!out.join("outside/Shared.sol").exists());
1800 }
1801
1802 #[test]
1803 fn test_relative_to_root_basic() {
1804 let root = PathBuf::from("/project");
1805 let path = PathBuf::from("/project/src/contracts");
1806
1807 let rel = relative_to_root(&root, &path);
1808 assert_eq!(rel, PathBuf::from("src/contracts"));
1809 }
1810
1811 #[test]
1812 fn test_relative_to_root_same_path() {
1813 let root = PathBuf::from("/project");
1814 let path = PathBuf::from("/project");
1815
1816 let rel = relative_to_root(&root, &path);
1817 assert_eq!(rel, PathBuf::from(""));
1818 }
1819
1820 #[test]
1821 fn test_relative_to_root_outside_root() {
1822 let root = PathBuf::from("/project");
1823 let path = PathBuf::from("/other/location");
1824
1825 let rel = relative_to_root(&root, &path);
1826 assert_eq!(rel, normalize_existing_ancestor(&path));
1827 }
1828
1829 #[test]
1830 fn test_ensure_within_root_rejects_symlink_escape() {
1831 let temp = TempDir::new().unwrap();
1832 let root = temp.path().join("project");
1833 let outside = temp.path().join("outside");
1834 fs::create_dir_all(&root).unwrap();
1835 fs::create_dir_all(&outside).unwrap();
1836 fs::write(outside.join("secret.txt"), "shhh").unwrap();
1837
1838 let src = root.join("src");
1840 symlink_dir(&outside, &src).unwrap();
1841
1842 let err = ensure_within_root(&root, &src, "src", &src).unwrap_err();
1843 assert!(err.to_string().contains("escapes project root"), "unexpected error: {err}");
1844 }
1845
1846 #[test]
1847 fn test_ensure_within_root_accepts_in_root_symlink() {
1848 let temp = TempDir::new().unwrap();
1849 let root = temp.path().join("project");
1850 let real_src = root.join("real_src");
1851 fs::create_dir_all(&real_src).unwrap();
1852
1853 let src_link = root.join("src");
1855 symlink_dir(&real_src, &src_link).unwrap();
1856
1857 ensure_within_root(&root, &src_link, "src", &src_link).unwrap();
1858 }
1859
1860 #[test]
1861 fn test_symlink_nested_libs_rejects_traversal_in_dependency_config() {
1862 let temp = TempDir::new().unwrap();
1863
1864 let malicious: PathBuf = PathBuf::from("../../escape");
1870 assert!(!is_safe_relative_path(&malicious));
1871
1872 let benign: PathBuf = PathBuf::from("lib");
1874 assert!(is_safe_relative_path(&benign));
1875
1876 let lib_src = temp.path().join("lib_src");
1878 let lib_dst = temp.path().join("lib_dst");
1879 fs::create_dir_all(&lib_src).unwrap();
1880 fs::create_dir_all(&lib_dst).unwrap();
1881 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
1882 }
1883
1884 #[test]
1885 fn test_process_nested_lib_dir_skips_symlinks() {
1886 let temp = TempDir::new().unwrap();
1887 let outside = temp.path().join("outside");
1888 fs::create_dir_all(outside.join("secret_pkg/src")).unwrap();
1889 fs::write(outside.join("secret_pkg/src/Secret.sol"), "secret").unwrap();
1890
1891 let lib_src = temp.path().join("lib_src");
1892 let nested = lib_src.join("lib");
1893 fs::create_dir_all(&nested).unwrap();
1894 symlink_dir(&outside.join("secret_pkg"), &nested.join("evil")).unwrap();
1896
1897 let lib_dst = temp.path().join("lib_dst");
1898 fs::create_dir_all(&lib_dst).unwrap();
1899
1900 process_nested_lib_dir(&nested, &lib_dst, Path::new("lib"), 0).unwrap();
1901
1902 assert!(!lib_dst.join("lib/evil").exists(), "symlinked dep was followed");
1904 }
1905}