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