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