use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct WorkspaceRoot {
markers: Vec<String>,
}
impl WorkspaceRoot {
pub fn with_markers(markers: &[&str]) -> Self {
Self {
markers: markers.iter().map(|s| s.to_string()).collect(),
}
}
pub fn rust_defaults() -> Self {
Self::with_markers(&["Cargo.toml", "Cargo.lock", ".git"])
}
pub fn find_from(&self, start: impl AsRef<Path>) -> Option<PathBuf> {
let mut current = start.as_ref().to_path_buf();
loop {
for marker in &self.markers {
let candidate = current.join(marker);
if candidate.exists() {
return Some(current);
}
}
if !current.pop() {
break;
}
}
None
}
pub fn markers(&self) -> &[String] {
&self.markers
}
}
impl Default for WorkspaceRoot {
fn default() -> Self {
Self::rust_defaults()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountPoint {
path: PathBuf,
}
impl MountPoint {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn is_root(&self) -> bool {
self.path == Path::new("/")
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn contains(&self, path: impl AsRef<Path>) -> bool {
path.as_ref().starts_with(&self.path)
}
pub fn parent(&self) -> Option<MountPoint> {
self.path.parent().map(|p| MountPoint::new(p.to_path_buf()))
}
pub fn children(&self) -> Result<Vec<PathBuf>, std::io::Error> {
let mut children = Vec::new();
for entry in std::fs::read_dir(&self.path)? {
let entry = entry?;
children.push(entry.path());
}
children.sort();
Ok(children)
}
}
#[derive(Debug, Clone)]
pub struct PathAncestry {
components: Vec<PathBuf>,
}
impl PathAncestry {
pub fn new(path: impl AsRef<Path>) -> Self {
let mut components = Vec::new();
let mut current = Some(path.as_ref().to_path_buf());
while let Some(p) = current {
components.push(p.clone());
current = p.parent().map(|pp| pp.to_path_buf());
}
Self { components }
}
pub fn depth(&self) -> usize {
self.components.len()
}
pub fn leaf(&self) -> &Path {
if self.components.is_empty() {
Path::new("")
} else {
self.components[0]
.file_name()
.map(|n| Path::new(n))
.unwrap_or(Path::new(""))
}
}
pub fn ancestor(&self, depth: usize) -> &Path {
if depth >= self.components.len() {
Path::new("")
} else {
&self.components[depth]
}
}
pub fn is_absolute(&self) -> bool {
self.components
.last()
.map(|p| p.is_absolute())
.unwrap_or(false)
}
pub fn root(&self) -> &Path {
self.components
.last()
.map(|p| p.as_path())
.unwrap_or(Path::new(""))
}
}
#[derive(Debug, Clone)]
pub struct DefensiveBoundary {
root: PathBuf,
}
impl DefensiveBoundary {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn contains(&self, path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
if path.is_absolute() {
path.starts_with(&self.root)
} else {
true
}
}
pub fn resolve_safe(&self, relative: impl AsRef<Path>) -> Result<PathBuf, BoundaryError> {
let relative = relative.as_ref();
let candidate = self.root.join(relative);
let canonical = self.canonicalize_or_clean(&candidate);
if !canonical.starts_with(&self.root) {
return Err(BoundaryError::Escape {
root: self.root.display().to_string(),
path: relative.display().to_string(),
});
}
Ok(canonical)
}
pub fn ancestry(&self, relative: impl AsRef<Path>) -> Result<PathAncestry, BoundaryError> {
let resolved = self.resolve_safe(relative)?;
Ok(PathAncestry::new(resolved))
}
fn canonicalize_or_clean(&self, path: &Path) -> PathBuf {
if let Ok(c) = path.canonicalize() {
return c;
}
let mut components = Vec::new();
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {}
other => components.push(other),
}
}
components.iter().collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BoundaryError {
Escape { root: String, path: String },
}
impl std::fmt::Display for BoundaryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BoundaryError::Escape { root, path } => {
write!(f, "path '{}' escapes boundary root '{}'", path, root)
}
}
}
}
impl std::error::Error for BoundaryError {}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("motte_test_{}", name));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn workspace_root_default_markers() {
let wr = WorkspaceRoot::default();
assert!(wr.markers().contains(&"Cargo.toml".to_string()));
}
#[test]
fn workspace_root_custom_markers() {
let wr = WorkspaceRoot::with_markers(&["my-marker"]);
assert_eq!(wr.markers(), &["my-marker"]);
}
#[test]
fn mount_point_root() {
let mp = MountPoint::new("/");
assert!(mp.is_root());
}
#[test]
fn mount_point_not_root() {
let mp = MountPoint::new("/home");
assert!(!mp.is_root());
}
#[test]
fn mount_point_contains() {
let mp = MountPoint::new("/tmp");
assert!(mp.contains("/tmp/file.txt"));
assert!(!mp.contains("/etc/passwd"));
}
#[test]
fn mount_point_parent() {
let mp = MountPoint::new("/tmp/sandbox");
let parent = mp.parent().unwrap();
assert_eq!(parent.path(), Path::new("/tmp"));
}
#[test]
fn mount_point_children() {
let dir = temp_dir("mount_children");
fs::write(dir.join("a.txt"), "a").unwrap();
fs::write(dir.join("b.txt"), "b").unwrap();
let mp = MountPoint::new(&dir);
let children = mp.children().unwrap();
assert_eq!(children.len(), 2);
}
#[test]
fn path_ancestry_depth() {
let a = PathAncestry::new("/a/b/c/d");
assert_eq!(a.depth(), 5);
}
#[test]
fn path_ancestry_leaf() {
let a = PathAncestry::new("/a/b/c/d");
assert_eq!(a.leaf(), Path::new("d"));
}
#[test]
fn path_ancestry_ancestor() {
let a = PathAncestry::new("/a/b/c/d");
assert_eq!(a.ancestor(1), Path::new("/a/b/c"));
assert_eq!(a.ancestor(3), Path::new("/a"));
}
#[test]
fn path_ancestry_absolute() {
let a = PathAncestry::new("/abs/path");
assert!(a.is_absolute());
let a = PathAncestry::new("rel/path");
assert!(!a.is_absolute());
}
#[test]
fn defensive_boundary_contains() {
let b = DefensiveBoundary::new("/tmp/project");
assert!(b.contains("/tmp/project/src/main.rs"));
assert!(!b.contains("/etc/passwd"));
assert!(b.contains("relative/path"));
}
#[test]
fn defensive_boundary_resolve_safe() {
let b = DefensiveBoundary::new("/tmp/project");
let resolved = b.resolve_safe("src/main.rs").unwrap();
assert!(resolved.starts_with("/tmp/project"));
}
#[test]
fn defensive_boundary_escape() {
let b = DefensiveBoundary::new("/tmp/project");
let result = b.resolve_safe("../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn defensive_boundary_ancestry() {
let b = DefensiveBoundary::new("/tmp/project");
let a = b.ancestry("src/main.rs").unwrap();
assert!(a.depth() > 0);
}
#[test]
fn boundary_error_display() {
let err = BoundaryError::Escape {
root: "/tmp".into(),
path: "../etc/passwd".into(),
};
assert!(err.to_string().contains("etc/passwd"));
}
}