1use std::{
7 fs,
8 path::{Component, Path, PathBuf},
9};
10
11use eyre::Result;
12use foundry_config::Config;
13
14pub fn is_safe_relative_path(p: &Path) -> bool {
17 !p.is_absolute()
18 && p.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
19}
20
21pub fn ensure_safe_relative_path(rel: &Path, label: &str, orig: &Path) -> Result<()> {
24 if !is_safe_relative_path(rel) {
25 eyre::bail!("requires {label} directory under project root, got: {}", orig.display());
26 }
27 Ok(())
28}
29
30pub fn relative_to_root(root: &Path, path: &Path) -> PathBuf {
32 path.strip_prefix(root).map(|p| p.to_path_buf()).unwrap_or_else(|_| path.to_path_buf())
33}
34
35fn ensure_within_root(
41 allowed_root: &Path,
42 candidate: &Path,
43 label: &str,
44 orig: &Path,
45) -> Result<()> {
46 if !candidate.exists() {
48 return Ok(());
49 }
50 let canon_root = allowed_root.canonicalize().map_err(|e| {
51 eyre::eyre!("failed to canonicalize project root {}: {e}", allowed_root.display())
52 })?;
53 let canon_candidate = candidate.canonicalize().map_err(|e| {
54 eyre::eyre!("failed to canonicalize {label} path {}: {e}", candidate.display())
55 })?;
56 if !canon_candidate.starts_with(&canon_root) {
57 eyre::bail!(
58 "{label} path {} escapes project root {} (resolved to {})",
59 orig.display(),
60 allowed_root.display(),
61 canon_candidate.display()
62 );
63 }
64 Ok(())
65}
66
67pub fn copy_project(config: &Config, temp_dir: &Path) -> Result<()> {
72 let src_rel = relative_to_root(&config.root, &config.src);
73 ensure_safe_relative_path(&src_rel, "src", &config.src)?;
74 ensure_within_root(&config.root, &config.src, "src", &config.src)?;
75
76 let test_rel = relative_to_root(&config.root, &config.test);
77 ensure_safe_relative_path(&test_rel, "test", &config.test)?;
78 ensure_within_root(&config.root, &config.test, "test", &config.test)?;
79
80 copy_dir_recursive(&config.src, &temp_dir.join(&src_rel))?;
81
82 if config.test != config.src {
83 copy_dir_recursive(&config.test, &temp_dir.join(&test_rel))?;
84 }
85
86 let handled_extra_roots = handled_project_roots(config)?;
87 for extra_path in config.include_paths.iter().chain(config.allow_paths.iter()) {
88 copy_extra_project_path(&config.root, temp_dir, extra_path, &handled_extra_roots)?;
89 }
90
91 if config.script.exists() && config.script != config.src && config.script != config.test {
97 let script_rel = relative_to_root(&config.root, &config.script);
98 ensure_safe_relative_path(&script_rel, "script", &config.script)?;
99 ensure_within_root(&config.root, &config.script, "script", &config.script)?;
100 copy_dir_recursive(&config.script, &temp_dir.join(&script_rel))?;
101 }
102
103 for lib_path in &config.libs {
104 if lib_path.exists() {
105 let lib_rel = relative_to_root(&config.root, lib_path);
106 ensure_safe_relative_path(&lib_rel, "lib", lib_path)?;
107 ensure_within_root(&config.root, lib_path, "lib", lib_path)?;
108 let target = temp_dir.join(&lib_rel);
109
110 if !target.exists() {
111 if let Some(parent) = target.parent() {
112 fs::create_dir_all(parent)?;
113 }
114 if symlink_dir(lib_path, &target).is_err() {
115 copy_dir_recursive(lib_path, &target)?;
116 }
117 }
118
119 symlink_nested_libs(lib_path, &target, 0)?;
120 }
121 }
122
123 for dep_dir in ["node_modules", "dependencies"] {
124 let dep_path = config.root.join(dep_dir);
125 if dep_path.exists() && dep_path.is_dir() {
126 ensure_within_root(&config.root, &dep_path, dep_dir, &dep_path)?;
128 let target = temp_dir.join(dep_dir);
129 if !target.exists() && symlink_dir(&dep_path, &target).is_err() {
130 copy_dir_recursive(&dep_path, &target)?;
131 }
132 }
133 }
134
135 let foundry_toml = config.root.join("foundry.toml");
136 if foundry_toml.exists() {
137 fs::copy(&foundry_toml, temp_dir.join("foundry.toml"))?;
138 }
139
140 let remappings = config.root.join("remappings.txt");
141 if remappings.exists() {
142 fs::copy(&remappings, temp_dir.join("remappings.txt"))?;
143 }
144
145 Ok(())
146}
147
148fn handled_project_roots(config: &Config) -> Result<Vec<PathBuf>> {
149 let mut roots = Vec::new();
150 push_handled_project_root(&mut roots, &config.root, &config.src, "src")?;
151 push_handled_project_root(&mut roots, &config.root, &config.test, "test")?;
152
153 if config.script.exists() && config.script != config.src && config.script != config.test {
154 push_handled_project_root(&mut roots, &config.root, &config.script, "script")?;
155 }
156
157 for lib_path in &config.libs {
158 if lib_path.exists() {
159 push_handled_project_root(&mut roots, &config.root, lib_path, "lib")?;
160 }
161 }
162
163 for dep_dir in ["node_modules", "dependencies"] {
164 let dep_path = config.root.join(dep_dir);
165 if dep_path.exists() && dep_path.is_dir() {
166 roots.push(PathBuf::from(dep_dir));
167 }
168 }
169
170 Ok(roots)
171}
172
173fn push_handled_project_root(
174 roots: &mut Vec<PathBuf>,
175 root: &Path,
176 path: &Path,
177 label: &str,
178) -> Result<()> {
179 let rel = relative_to_root(root, path);
180 ensure_safe_relative_path(&rel, label, path)?;
181 ensure_within_root(root, path, label, path)?;
182 roots.push(rel);
183 Ok(())
184}
185
186fn is_covered_by_handled_root(rel: &Path, handled_roots: &[PathBuf]) -> bool {
187 handled_roots.iter().any(|root| !root.as_os_str().is_empty() && rel.starts_with(root))
188}
189
190fn copy_extra_project_path(
191 root: &Path,
192 temp_dir: &Path,
193 path: &Path,
194 handled_roots: &[PathBuf],
195) -> Result<()> {
196 let resolved = if path.is_absolute() { path.to_path_buf() } else { root.join(path) };
197 let rel = relative_to_root(root, &resolved);
198 ensure_safe_relative_path(&rel, "include/allow", path)?;
199 ensure_within_root(root, &resolved, "include/allow", path)?;
200
201 if is_covered_by_handled_root(&rel, handled_roots) {
202 return Ok(());
203 }
204
205 if !resolved.exists() {
206 return Ok(());
207 }
208
209 let target = temp_dir.join(rel);
210 if resolved.is_dir() {
211 copy_dir_recursive(&resolved, &target)
212 } else {
213 if let Some(parent) = target.parent() {
214 fs::create_dir_all(parent)?;
215 }
216 fs::copy(&resolved, target)?;
217 Ok(())
218 }
219}
220
221pub fn symlink_dir(src: &Path, dst: &Path) -> Result<()> {
223 #[cfg(unix)]
224 {
225 std::os::unix::fs::symlink(src, dst)?;
226 }
227 #[cfg(windows)]
228 {
229 std::os::windows::fs::symlink_dir(src, dst)?;
230 }
231 Ok(())
232}
233
234const MAX_SYMLINK_DEPTH: usize = 10;
236
237fn symlink_nested_libs(lib_src: &Path, lib_dst: &Path, depth: usize) -> Result<()> {
239 if depth >= MAX_SYMLINK_DEPTH {
240 return Ok(());
241 }
242
243 let nested_lib_dirs: Vec<PathBuf> =
244 if let Ok(config) = Config::load_with_root_and_fallback(lib_src) {
245 config.libs
246 } else {
247 vec![PathBuf::from("lib")]
248 };
249
250 for nested_lib_dir in nested_lib_dirs {
251 if !is_safe_relative_path(&nested_lib_dir) {
255 continue;
256 }
257 let nested_lib = lib_src.join(&nested_lib_dir);
258 if !nested_lib.exists() {
259 continue;
260 }
261 let Ok(meta) = fs::symlink_metadata(&nested_lib) else { continue };
263 if meta.file_type().is_symlink() || !meta.is_dir() {
264 continue;
265 }
266 if ensure_within_root(lib_src, &nested_lib, "nested lib", &nested_lib).is_err() {
267 continue;
268 }
269 process_nested_lib_dir(&nested_lib, lib_dst, &nested_lib_dir, depth)?;
270 }
271
272 Ok(())
273}
274
275fn process_nested_lib_dir(
276 nested_lib: &Path,
277 lib_dst: &Path,
278 lib_rel: &Path,
279 depth: usize,
280) -> Result<()> {
281 if !nested_lib.exists() || !nested_lib.is_dir() {
282 return Ok(());
283 }
284
285 let entries = match fs::read_dir(nested_lib) {
286 Ok(e) => e,
287 Err(_) => return Ok(()),
288 };
289
290 for entry in entries.flatten() {
291 let Ok(file_type) = entry.file_type() else { continue };
295 if file_type.is_symlink() || !file_type.is_dir() {
296 continue;
297 }
298
299 let entry_path = entry.path();
300 let entry_name = entry.file_name();
301 let nested_dst = lib_dst.join(lib_rel).join(&entry_name);
302
303 if !nested_dst.exists() {
304 if let Some(parent) = nested_dst.parent() {
305 let _ = fs::create_dir_all(parent);
306 }
307 let _ = symlink_dir(&entry_path, &nested_dst);
308 }
309
310 symlink_nested_libs(&entry_path, &nested_dst, depth + 1)?;
311 }
312
313 Ok(())
314}
315
316pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
318 if !src.exists() {
319 return Ok(());
320 }
321
322 fs::create_dir_all(dst)?;
323
324 for entry in fs::read_dir(src)? {
325 let entry = entry?;
326 let path = entry.path();
327 let dest_path = dst.join(entry.file_name());
328
329 let meta = fs::symlink_metadata(&path)?;
330
331 if meta.file_type().is_symlink() {
332 if path.is_dir() {
333 continue;
334 }
335 fs::copy(&path, &dest_path)?;
336 } else if meta.is_dir() {
337 copy_dir_recursive(&path, &dest_path)?;
338 } else {
339 fs::copy(&path, &dest_path)?;
340 }
341 }
342
343 Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use tempfile::TempDir;
350
351 fn create_test_dir_structure(base: &Path, structure: &[&str]) {
352 for path in structure {
353 let full_path = base.join(path);
354 if path.ends_with('/') {
355 fs::create_dir_all(&full_path).unwrap();
356 } else {
357 if let Some(parent) = full_path.parent() {
358 fs::create_dir_all(parent).unwrap();
359 }
360 fs::write(&full_path, format!("// {path}")).unwrap();
361 }
362 }
363 }
364
365 #[test]
366 fn test_symlink_dir_creates_symlink() {
367 let temp = TempDir::new().unwrap();
368 let src = temp.path().join("source_dir");
369 let dst = temp.path().join("target_link");
370
371 fs::create_dir(&src).unwrap();
372 fs::write(src.join("file.txt"), "content").unwrap();
373
374 symlink_dir(&src, &dst).unwrap();
375
376 assert!(dst.exists());
377 assert!(dst.is_symlink());
378 assert!(dst.join("file.txt").exists());
379 }
380
381 #[test]
382 fn test_symlink_nested_libs_single_level() {
383 let temp = TempDir::new().unwrap();
384
385 let lib_src = temp.path().join("lib_src");
386 create_test_dir_structure(
387 &lib_src,
388 &[
389 "src/Contract.sol",
390 "lib/",
391 "lib/openzeppelin/contracts/token/ERC20.sol",
392 "lib/solmate/src/tokens/ERC20.sol",
393 ],
394 );
395
396 let lib_dst = temp.path().join("lib_dst");
397 fs::create_dir(&lib_dst).unwrap();
398
399 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
400
401 assert!(lib_dst.join("lib/openzeppelin").exists());
402 assert!(lib_dst.join("lib/solmate").exists());
403 assert!(lib_dst.join("lib/openzeppelin/contracts/token/ERC20.sol").exists());
404 assert!(lib_dst.join("lib/solmate/src/tokens/ERC20.sol").exists());
405 }
406
407 #[test]
408 fn test_symlink_nested_libs_deeply_nested() {
409 let temp = TempDir::new().unwrap();
410
411 let lib_src = temp.path().join("lib_src");
412 create_test_dir_structure(
413 &lib_src,
414 &[
415 "src/Main.sol",
416 "lib/",
417 "lib/dep-a/src/A.sol",
418 "lib/dep-a/lib/",
419 "lib/dep-a/lib/dep-b/src/B.sol",
420 "lib/dep-a/lib/dep-b/lib/",
421 "lib/dep-a/lib/dep-b/lib/dep-c/src/C.sol",
422 ],
423 );
424
425 let lib_dst = temp.path().join("lib_dst");
426 fs::create_dir(&lib_dst).unwrap();
427
428 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
429
430 assert!(lib_dst.join("lib/dep-a").exists());
431 assert!(lib_dst.join("lib/dep-a/lib/dep-b").exists());
432 assert!(lib_dst.join("lib/dep-a/lib/dep-b/lib/dep-c").exists());
433 assert!(lib_dst.join("lib/dep-a/lib/dep-b/lib/dep-c/src/C.sol").exists());
434 }
435
436 #[test]
437 fn test_symlink_nested_libs_no_nested_lib_dir() {
438 let temp = TempDir::new().unwrap();
439
440 let lib_src = temp.path().join("lib_src");
441 create_test_dir_structure(&lib_src, &["src/Contract.sol", "test/Test.sol"]);
442
443 let lib_dst = temp.path().join("lib_dst");
444 fs::create_dir(&lib_dst).unwrap();
445
446 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
447
448 assert!(!lib_dst.join("lib").exists());
449 }
450
451 #[test]
452 fn test_symlink_nested_libs_skips_existing() {
453 let temp = TempDir::new().unwrap();
454
455 let lib_src = temp.path().join("lib_src");
456 create_test_dir_structure(&lib_src, &["lib/", "lib/existing/src/File.sol"]);
457
458 let lib_dst = temp.path().join("lib_dst");
459 fs::create_dir_all(lib_dst.join("lib/existing")).unwrap();
460 fs::write(lib_dst.join("lib/existing/marker.txt"), "pre-existing").unwrap();
461
462 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
463
464 assert!(lib_dst.join("lib/existing/marker.txt").exists());
465 }
466
467 #[test]
468 fn test_copy_dir_recursive_basic() {
469 let temp = TempDir::new().unwrap();
470
471 let src = temp.path().join("src");
472 create_test_dir_structure(
473 &src,
474 &["file1.sol", "subdir/file2.sol", "subdir/nested/file3.sol"],
475 );
476
477 let dst = temp.path().join("dst");
478 copy_dir_recursive(&src, &dst).unwrap();
479
480 assert!(dst.join("file1.sol").exists());
481 assert!(dst.join("subdir/file2.sol").exists());
482 assert!(dst.join("subdir/nested/file3.sol").exists());
483 }
484
485 #[test]
486 fn test_copy_dir_recursive_skips_symlinked_dirs() {
487 let temp = TempDir::new().unwrap();
488
489 let src = temp.path().join("src");
490 let external = temp.path().join("external");
491
492 fs::create_dir_all(&external).unwrap();
493 fs::write(external.join("secret.txt"), "should not be copied").unwrap();
494
495 fs::create_dir_all(&src).unwrap();
496 fs::write(src.join("file.sol"), "content").unwrap();
497
498 symlink_dir(&external, &src.join("external_link")).unwrap();
499
500 let dst = temp.path().join("dst");
501 copy_dir_recursive(&src, &dst).unwrap();
502
503 assert!(dst.join("file.sol").exists());
504 assert!(!dst.join("external_link").exists());
505 }
506
507 #[test]
508 fn test_copy_dir_recursive_nonexistent_src() {
509 let temp = TempDir::new().unwrap();
510
511 let src = temp.path().join("nonexistent");
512 let dst = temp.path().join("dst");
513
514 copy_dir_recursive(&src, &dst).unwrap();
515 assert!(!dst.exists());
516 }
517
518 #[test]
519 fn test_copy_project_copies_include_paths_under_root() {
520 let temp = TempDir::new().unwrap();
521 let root = temp.path().join("project");
522 let out = temp.path().join("workspace");
523 create_test_dir_structure(
524 &root,
525 &["src/Counter.sol", "test/Counter.t.sol", "include/Shared.sol"],
526 );
527
528 let config = Config {
529 root: root.clone(),
530 src: root.join("src"),
531 test: root.join("test"),
532 script: root.join("script"),
533 include_paths: vec![root.join("include")],
534 ..Default::default()
535 };
536
537 copy_project(&config, &out).unwrap();
538
539 assert!(out.join("include/Shared.sol").exists());
540 }
541
542 #[test]
543 fn test_copy_project_skips_include_paths_covered_by_libs() {
544 let temp = TempDir::new().unwrap();
545 let root = temp.path().join("project");
546 let out = temp.path().join("workspace");
547 create_test_dir_structure(
548 &root,
549 &["src/Counter.sol", "test/Counter.t.sol", "lib/foo/Foo.sol", "lib/bar/Bar.sol"],
550 );
551
552 let config = Config {
553 root: root.clone(),
554 src: root.join("src"),
555 test: root.join("test"),
556 script: root.join("script"),
557 libs: vec![root.join("lib")],
558 include_paths: vec![root.join("lib/foo")],
559 ..Default::default()
560 };
561
562 copy_project(&config, &out).unwrap();
563
564 assert!(out.join("lib/foo/Foo.sol").exists());
565 assert!(out.join("lib/bar/Bar.sol").exists());
566 }
567
568 #[test]
569 fn test_copy_project_rejects_external_include_paths() {
570 let temp = TempDir::new().unwrap();
571 let root = temp.path().join("project");
572 let outside = temp.path().join("outside");
573 let out = temp.path().join("workspace");
574 create_test_dir_structure(&root, &["src/Counter.sol", "test/Counter.t.sol"]);
575 create_test_dir_structure(&outside, &["Shared.sol"]);
576
577 let config = Config {
578 root: root.clone(),
579 src: root.join("src"),
580 test: root.join("test"),
581 script: root.join("script"),
582 include_paths: vec![outside],
583 ..Default::default()
584 };
585
586 let err = copy_project(&config, &out).unwrap_err();
587
588 assert!(
589 err.to_string().contains("requires include/allow directory under project root"),
590 "unexpected error: {err}"
591 );
592 }
593
594 #[test]
595 fn test_relative_to_root_basic() {
596 let root = PathBuf::from("/project");
597 let path = PathBuf::from("/project/src/contracts");
598
599 let rel = relative_to_root(&root, &path);
600 assert_eq!(rel, PathBuf::from("src/contracts"));
601 }
602
603 #[test]
604 fn test_relative_to_root_same_path() {
605 let root = PathBuf::from("/project");
606 let path = PathBuf::from("/project");
607
608 let rel = relative_to_root(&root, &path);
609 assert_eq!(rel, PathBuf::from(""));
610 }
611
612 #[test]
613 fn test_relative_to_root_outside_root() {
614 let root = PathBuf::from("/project");
615 let path = PathBuf::from("/other/location");
616
617 let rel = relative_to_root(&root, &path);
618 assert_eq!(rel, path);
619 }
620
621 #[test]
622 fn test_ensure_within_root_rejects_symlink_escape() {
623 let temp = TempDir::new().unwrap();
624 let root = temp.path().join("project");
625 let outside = temp.path().join("outside");
626 fs::create_dir_all(&root).unwrap();
627 fs::create_dir_all(&outside).unwrap();
628 fs::write(outside.join("secret.txt"), "shhh").unwrap();
629
630 let src = root.join("src");
632 symlink_dir(&outside, &src).unwrap();
633
634 let err = ensure_within_root(&root, &src, "src", &src).unwrap_err();
635 assert!(err.to_string().contains("escapes project root"), "unexpected error: {err}");
636 }
637
638 #[test]
639 fn test_ensure_within_root_accepts_in_root_symlink() {
640 let temp = TempDir::new().unwrap();
641 let root = temp.path().join("project");
642 let real_src = root.join("real_src");
643 fs::create_dir_all(&real_src).unwrap();
644
645 let src_link = root.join("src");
647 symlink_dir(&real_src, &src_link).unwrap();
648
649 ensure_within_root(&root, &src_link, "src", &src_link).unwrap();
650 }
651
652 #[test]
653 fn test_symlink_nested_libs_rejects_traversal_in_dependency_config() {
654 let temp = TempDir::new().unwrap();
655
656 let malicious: PathBuf = PathBuf::from("../../escape");
662 assert!(!is_safe_relative_path(&malicious));
663
664 let benign: PathBuf = PathBuf::from("lib");
666 assert!(is_safe_relative_path(&benign));
667
668 let lib_src = temp.path().join("lib_src");
670 let lib_dst = temp.path().join("lib_dst");
671 fs::create_dir_all(&lib_src).unwrap();
672 fs::create_dir_all(&lib_dst).unwrap();
673 symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap();
674 }
675
676 #[test]
677 fn test_process_nested_lib_dir_skips_symlinks() {
678 let temp = TempDir::new().unwrap();
679 let outside = temp.path().join("outside");
680 fs::create_dir_all(outside.join("secret_pkg/src")).unwrap();
681 fs::write(outside.join("secret_pkg/src/Secret.sol"), "secret").unwrap();
682
683 let lib_src = temp.path().join("lib_src");
684 let nested = lib_src.join("lib");
685 fs::create_dir_all(&nested).unwrap();
686 symlink_dir(&outside.join("secret_pkg"), &nested.join("evil")).unwrap();
688
689 let lib_dst = temp.path().join("lib_dst");
690 fs::create_dir_all(&lib_dst).unwrap();
691
692 process_nested_lib_dir(&nested, &lib_dst, Path::new("lib"), 0).unwrap();
693
694 assert!(!lib_dst.join("lib/evil").exists(), "symlinked dep was followed");
696 }
697}