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