Skip to main content

forge/
workspace.rs

1//! Shared utilities for creating isolated project workspaces.
2//!
3//! Used by both mutation testing and brutalization to copy a project
4//! to a temporary directory for safe source-level modifications.
5
6use 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
18/// Check if a path is safe for use as a relative path within a workspace.
19/// Rejects absolute paths, parent directory components (..), and other unsafe patterns.
20pub 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
25/// Validates that `rel` is a safe relative path. Returns an error mentioning `label` and `orig`
26/// if the path contains `..`, is absolute, or otherwise escapes the project root.
27pub 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
34/// Compute relative path of `path` under `root`, or return the path unchanged if not under root.
35pub 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
62/// Build a config for a copied temp workspace from an already materialized config.
63///
64/// This preserves CLI/env overrides and runtime normalization while rebasing
65/// project-local paths from the original root to `temp_path`.
66pub 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
221/// Verify that `candidate` resolves (after following symlinks) to a path that lives
222/// inside `allowed_root`. Protects against `src`/`test`/`lib`/etc. being symlinks
223/// that escape the project root.
224///
225/// `label` and `orig` are only used for error messages.
226fn ensure_within_root(
227    allowed_root: &Path,
228    candidate: &Path,
229    label: &str,
230    orig: &Path,
231) -> Result<()> {
232    // If the path doesn't exist yet, lexical containment is the best we can do.
233    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
253/// Copy essential project files to a temp workspace.
254///
255/// Copies src and test directories, symlinks library directories (read-only),
256/// and copies config files (foundry.toml, remappings.txt).
257pub 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    // Copy `script/` too when present and distinct from src/test. Many real
358    // projects keep helper contracts, deployment scripts, or fixtures under
359    // `script/` and reference them from tests via relative imports. Without
360    // this, baselines that compile fine produce a sea of `Invalid` mutants
361    // for purely-environmental reasons.
362    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            // Reject if the project-root entry is a symlink that escapes the root.
397            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
589/// Create a symlink to a directory (cross-platform).
590pub 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
602/// Maximum recursion depth for nested lib symlinks to prevent infinite loops.
603const MAX_SYMLINK_DEPTH: usize = 10;
604
605/// Recursively symlink nested lib directories within a library.
606fn 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        // A dependency's foundry.toml is untrusted input. Reject any nested lib
620        // path that is absolute or contains `..`, then verify the resolved path
621        // doesn't escape the dependency root via symlink.
622        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        // Use symlink_metadata so we don't follow a symlinked nested lib root.
630        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        // Use file_type() (does not follow symlinks) so a symlinked entry in a
660        // dependency's lib dir cannot be silently followed and re-symlinked
661        // outside the workspace.
662        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
684/// Recursively copy a directory, following symlinked directories only within the allowed root.
685pub 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        // src is a symlink that points outside the project root.
1609        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        // src -> real_src is fine: stays inside the project root.
1624        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        // Pretend lib_src is a malicious dependency whose foundry.toml says
1635        // libs = ["../../escape"]. We can't easily write foundry.toml here, so
1636        // exercise the lexical guard directly via is_safe_relative_path: any
1637        // path containing `..` must be rejected before being joined with
1638        // `lib_src`.
1639        let malicious: PathBuf = PathBuf::from("../../escape");
1640        assert!(!is_safe_relative_path(&malicious));
1641
1642        // Sanity check: a benign relative path is still accepted.
1643        let benign: PathBuf = PathBuf::from("lib");
1644        assert!(is_safe_relative_path(&benign));
1645
1646        // And the function returns Ok when there is nothing to do.
1647        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        // A dep that is a symlink pointing outside the lib root.
1665        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        // The symlinked entry must not have been followed into the destination.
1673        assert!(!lib_dst.join("lib/evil").exists(), "symlinked dep was followed");
1674    }
1675}