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:
parent
d0e58221aa
commit
d98a9db5fe
2 changed files with 53 additions and 1 deletions
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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 -----------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue