use std::path::{Path, PathBuf};
use camino::Utf8PathBuf;
use super::error::McpToolError;
#[derive(Clone, Debug)]
pub struct AllowedRoots {
roots: Vec<PathBuf>,
}
impl AllowedRoots {
#[must_use]
pub fn from_config(server_cwd: &Path, extra: &[String]) -> Self {
let mut roots = Vec::with_capacity(extra.len() + 1);
if let Some(p) = canonicalise(server_cwd) {
roots.push(p);
}
for raw in extra {
let candidate = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
server_cwd.join(raw)
};
if let Some(p) = canonicalise(&candidate) {
if !roots.iter().any(|existing| existing == &p) {
roots.push(p);
}
}
}
Self { roots }
}
#[must_use]
pub fn roots(&self) -> &[PathBuf] {
&self.roots
}
fn permits(&self, candidate: &Path) -> bool {
self.roots
.iter()
.any(|root| candidate == root || candidate.starts_with(root))
}
}
pub fn validate_target(raw: &str, allowed: &AllowedRoots) -> Result<Utf8PathBuf, McpToolError> {
if raw.is_empty() {
return Err(McpToolError::InvalidPathSyntax(
"target is empty".to_string(),
));
}
if raw.as_bytes().contains(&0) {
return Err(McpToolError::InvalidPathSyntax(
"target contains NUL byte".to_string(),
));
}
let base = allowed
.roots
.first()
.ok_or_else(|| McpToolError::Internal("allow-list has no server root".to_string()))?;
let absolute = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
base.join(raw)
};
let canonical = canonicalise(&absolute).ok_or_else(|| {
tracing::warn!(
raw = %raw,
base = %base.display(),
"mcp validation: target failed to canonicalise"
);
McpToolError::TargetCanonicalisationFailed
})?;
if !allowed.permits(&canonical) {
tracing::warn!(
raw = %raw,
canonical = %canonical.display(),
allowed_roots = ?allowed.roots,
"mcp validation: target rejected by allow-list"
);
return Err(McpToolError::TargetOutsideAllowedRoots);
}
Utf8PathBuf::from_path_buf(canonical).map_err(|p| {
tracing::warn!(
raw = %raw,
canonical = %p.display(),
"mcp validation: canonical path is not valid UTF-8"
);
McpToolError::InvalidPathSyntax("canonical path is not valid UTF-8".to_string())
})
}
fn canonicalise(p: &Path) -> Option<PathBuf> {
dunce::canonicalize(p).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_cwd() -> std::path::PathBuf {
let dir = tempfile::tempdir().expect("tempdir");
dir.keep()
}
#[test]
fn dot_resolves_to_cwd() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let resolved = validate_target(".", &allowed).expect("`.` should be allowed");
assert!(resolved.starts_with(
Utf8PathBuf::from_path_buf(canonicalise(&cwd).expect("canon")).expect("utf8 cwd"),
));
}
#[test]
fn empty_target_rejected() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let err = validate_target("", &allowed).expect_err("empty should error");
match err {
McpToolError::InvalidPathSyntax(_) => {}
other => panic!("expected InvalidPathSyntax, got {other:?}"),
}
}
#[test]
fn nul_byte_rejected() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let err = validate_target("foo\0bar", &allowed).expect_err("NUL should error");
match err {
McpToolError::InvalidPathSyntax(msg) => assert!(msg.contains("NUL")),
other => panic!("expected InvalidPathSyntax, got {other:?}"),
}
}
#[test]
fn nonexistent_target_rejected() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let err = validate_target("definitely-does-not-exist-xyz", &allowed)
.expect_err("nonexistent should error");
match err {
McpToolError::TargetCanonicalisationFailed => {}
other => panic!("expected TargetCanonicalisationFailed, got {other:?}"),
}
}
#[test]
fn parent_escape_rejected() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let parent = cwd
.parent()
.expect("tempdir always has a parent")
.to_path_buf();
let parent_str = parent.to_str().expect("parent path should be valid UTF-8");
let err = validate_target(parent_str, &allowed)
.expect_err("parent of cwd must be outside allow-list");
match err {
McpToolError::TargetOutsideAllowedRoots => {}
other => panic!("expected TargetOutsideAllowedRoots, got {other:?}"),
}
}
#[test]
fn dot_dot_escape_rejected() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let err = validate_target("../..", &allowed)
.expect_err("`../..` should escape the cwd allow-list");
match err {
McpToolError::TargetOutsideAllowedRoots => {}
other => panic!("expected TargetOutsideAllowedRoots, got {other:?}"),
}
}
#[test]
fn validation_error_display_omits_raw_input() {
let cwd = fresh_cwd();
let allowed = AllowedRoots::from_config(&cwd, &[]);
let raw = "/etc/passwd-marker-xyz";
let err = validate_target(raw, &allowed).expect_err("must reject");
let rendered = err.to_string();
assert!(
!rendered.contains("/etc"),
"error Display leaked input path: {rendered:?}"
);
assert!(
!rendered.contains("passwd-marker-xyz"),
"error Display leaked input fragment: {rendered:?}"
);
assert!(
!rendered.contains(cwd.to_str().expect("cwd utf8")),
"error Display leaked cwd: {rendered:?}"
);
}
#[test]
fn allow_list_can_widen_to_sibling() {
let parent = fresh_cwd();
let child = parent.join("child");
std::fs::create_dir_all(&child).expect("mkdir child");
let sibling = parent.join("sibling");
std::fs::create_dir_all(&sibling).expect("mkdir sibling");
let allowed = AllowedRoots::from_config(
&child,
&[sibling
.to_str()
.expect("sibling path should be valid UTF-8")
.to_string()],
);
let sib_str = sibling
.to_str()
.expect("sibling path should be valid UTF-8");
validate_target(sib_str, &allowed).expect("sibling should be reachable when allow-listed");
}
#[test]
fn allow_list_with_no_server_root_is_fail_closed() {
let allowed = AllowedRoots { roots: vec![] };
let err = validate_target(".", &allowed).expect_err("empty allow-list must reject");
match err {
McpToolError::Internal(_) => {}
other => panic!("expected Internal, got {other:?}"),
}
}
}