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