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