self-run seed: validate the copied links in the destination, so a link that leaves the seed and returns by its own name is refused

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 02:47:34 +01:00
commit d98a9db5fe
2 changed files with 53 additions and 1 deletions

View file

@ -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<()> {

View file

@ -585,6 +585,41 @@ mod seed_links {
let refused = error.get_ref().and_then(|inner| inner.downcast_ref::<SeedLinkRefused>());
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::<SeedLinkRefused>());
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 -----------------------------------------------------------