use std::io;
use std::path::{Path, PathBuf};
use cap_std::ambient_authority;
use cap_std::fs::Dir;
#[derive(Debug)]
#[allow(dead_code)]
pub(crate) struct BoundedDir {
canonical_path: PathBuf,
_dir: Dir,
}
#[allow(dead_code)]
impl BoundedDir {
pub(crate) fn open(parent: &Path, child_relative: &Path) -> io::Result<Self> {
if child_relative.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"BoundedDir: child path must be relative, got absolute {:?}",
child_relative
),
));
}
use std::path::Component;
for component in child_relative.components() {
if component == Component::ParentDir {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"BoundedDir: child path must not contain `..`, got {:?}",
child_relative
),
));
}
}
let parent_dir = Dir::open_ambient_dir(parent, ambient_authority())?;
let child_dir = if child_relative.as_os_str().is_empty() || child_relative == Path::new(".")
{
Dir::open_ambient_dir(parent, ambient_authority())?
} else {
parent_dir.open_dir(child_relative)?
};
let canonical_path = match parent.canonicalize() {
Ok(canon) => canon.join(child_relative),
Err(_) => parent.join(child_relative),
};
Ok(Self { canonical_path, _dir: child_dir })
}
pub(crate) fn path(&self) -> &Path {
&self.canonical_path
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn bounded_dir_opens_child_within_parent() {
let parent = tempdir().unwrap();
let child = parent.path().join("child");
fs::create_dir(&child).unwrap();
let bd = BoundedDir::open(parent.path(), Path::new("child"))
.expect("open should succeed for an in-tree child");
let canon_parent = parent.path().canonicalize().unwrap();
assert!(
bd.path().starts_with(&canon_parent),
"bound path {:?} must be under canonicalized parent {:?}",
bd.path(),
canon_parent,
);
assert_eq!(bd.path().file_name().and_then(|s| s.to_str()), Some("child"));
}
#[test]
fn bounded_dir_rejects_dotdot_in_child() {
let parent = tempdir().unwrap();
let err =
BoundedDir::open(parent.path(), Path::new("..")).expect_err("`..` must be rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
let err = BoundedDir::open(parent.path(), Path::new("child/../escape"))
.expect_err("buried `..` must be rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn bounded_dir_rejects_absolute_child_path() {
let parent = tempdir().unwrap();
#[cfg(unix)]
let abs = PathBuf::from("/etc");
#[cfg(windows)]
let abs = {
parent.path().to_path_buf()
};
assert!(abs.is_absolute(), "fixture should be absolute");
let err = BoundedDir::open(parent.path(), &abs)
.expect_err("absolute child path must be rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[cfg(unix)]
#[test]
fn bounded_dir_rejects_symlink_escape_unix() {
let outer = tempdir().unwrap();
let inner = outer.path().join("workspace");
fs::create_dir(&inner).unwrap();
let escape_target = outer.path().join("attacker");
fs::create_dir(&escape_target).unwrap();
let link = inner.join("escape");
std::os::unix::fs::symlink(&escape_target, &link).unwrap();
let result = BoundedDir::open(&inner, Path::new("escape"));
assert!(
result.is_err(),
"opening a symlink that escapes the parent must fail, got {:?}",
result.map(|b| b.path().to_path_buf()),
);
}
#[cfg(windows)]
#[test]
fn bounded_dir_rejects_symlink_escape_windows() {
let outer = tempdir().unwrap();
let inner = outer.path().join("workspace");
fs::create_dir(&inner).unwrap();
let escape_target = outer.path().join("attacker");
fs::create_dir(&escape_target).unwrap();
let link = inner.join("escape");
if std::os::windows::fs::symlink_dir(&escape_target, &link).is_err() {
return;
}
let result = BoundedDir::open(&inner, Path::new("escape"));
assert!(
result.is_err(),
"opening a symlink_dir that escapes the parent must fail, got {:?}",
result.map(|b| b.path().to_path_buf()),
);
}
#[test]
fn bounded_dir_path_is_subpath_of_parent() {
let parent = tempdir().unwrap();
let child = parent.path().join("nested");
fs::create_dir(&child).unwrap();
let bd = BoundedDir::open(parent.path(), Path::new("nested")).unwrap();
let canon_parent = parent.path().canonicalize().unwrap();
assert_eq!(bd.path(), canon_parent.join("nested"));
}
#[test]
fn bounded_dir_resists_post_open_swap() {
let outer = tempdir().unwrap();
let real_parent = outer.path().join("real-parent");
fs::create_dir(&real_parent).unwrap();
let real_child = real_parent.join("child");
fs::create_dir(&real_child).unwrap();
let bd =
BoundedDir::open(&real_parent, Path::new("child")).expect("setup: child should open");
let bound_path = bd.path().to_path_buf();
let attacker = outer.path().join("attacker");
fs::create_dir(&attacker).unwrap();
let _ = fs::remove_dir(&real_child);
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(&attacker, &real_child);
#[cfg(windows)]
let _ = std::os::windows::fs::symlink_dir(&attacker, &real_child);
let canon_real_parent = real_parent.canonicalize().unwrap_or(real_parent.clone());
assert!(
bound_path.starts_with(&canon_real_parent) || bound_path.starts_with(&real_parent),
"bound path {:?} must remain under real parent {:?} after a post-open swap",
bound_path,
real_parent,
);
}
#[test]
fn bounded_dir_rejects_nonexistent_child() {
let parent = tempdir().unwrap();
let result = BoundedDir::open(parent.path(), Path::new("does-not-exist"));
assert!(result.is_err(), "opening a missing child must fail");
}
}