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