diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 88fd4a8..571b715 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -570,8 +570,25 @@ impl std::error::Error for SeedLinkRefused {} /// the same target, never followed into the copy. A link is refused with /// [`SeedLinkRefused`] unless its target is relative and, resolved through /// every link on its way in the source, lands under the canonical seed root. +/// The source check refuses early; but a link's text resolves from where the +/// copy sits, so once the copy is whole every reproduced link is resolved again +/// in `to`, and one that lands outside it is refused the same way. fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { - copy_level(&from.canonicalize()?, from, to) + copy_level(&from.canonicalize()?, from, to)?; + check_links(&to.canonicalize()?, to) +} + +fn check_links(root: &Path, dir: &Path) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_dir() { + check_links(root, &entry.path())?; + } else if kind.is_symlink() && !entry.path().canonicalize().is_ok_and(|resolved| resolved.starts_with(root)) { + return Err(std::io::Error::other(SeedLinkRefused { link: entry.path(), target: std::fs::read_link(entry.path())? })); + } + } + Ok(()) } fn copy_level(root: &Path, from: &Path, to: &Path) -> std::io::Result<()> { diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs index 72686c9..f265d44 100644 --- a/crates/mind-body/src/self_run/tests.rs +++ b/crates/mind-body/src/self_run/tests.rs @@ -585,6 +585,41 @@ mod seed_links { let refused = error.get_ref().and_then(|inner| inner.downcast_ref::()); assert_eq!(refused.map(|r| r.target.as_path()), Some(Path::new("c/..")), "{error}"); } + + /// A seed whose parent is not the config dir's parent, and a sibling of the + /// config dir named `seed` holding the same file name: the copy's link text + /// resolves there, though in the source it lands under the seed root. + fn refused_in_the_copy(plant: impl Fn(&Path), expected: &str) { + let source = tempfile::tempdir().unwrap(); + let seed = source.path().join("seed"); + std::fs::create_dir(&seed).unwrap(); + std::fs::write(seed.join("f"), "seed").unwrap(); + plant(&seed); + let runtime = tempfile::tempdir().unwrap(); + std::fs::create_dir(runtime.path().join("seed")).unwrap(); + std::fs::write(runtime.path().join("seed/f"), "planted").unwrap(); + let out = runtime.path().join("claude-config"); + std::fs::create_dir(&out).unwrap(); + let error = copy_tree(&seed, &out).unwrap_err(); + let refused = error.get_ref().and_then(|inner| inner.downcast_ref::()); + assert_eq!(refused.map(|r| r.target.as_path()), Some(Path::new(expected)), "{error}"); + } + + #[test] + fn a_link_that_leaves_the_seed_and_returns_by_its_name_is_refused() { + refused_in_the_copy(|seed| symlink("../seed/f", seed.join("x")).unwrap(), "../seed/f"); + } + + #[test] + fn a_link_through_a_dot_link_to_the_seeds_name_is_refused() { + refused_in_the_copy( + |seed| { + symlink(".", seed.join("c")).unwrap(); + symlink("c/../seed/f", seed.join("a")).unwrap(); + }, + "c/../seed/f", + ); + } } // ---- what is read -----------------------------------------------------------