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