use globset::{Glob, GlobMatcher};
use serde::{Deserialize, Serialize};
use crate::error::{CapabilityError, CapabilityResult};
#[derive(Debug, Clone)]
pub struct ResourcePattern {
pattern: String,
matcher: Option<GlobMatcher>,
}
impl ResourcePattern {
pub fn new(pattern: impl Into<String>) -> CapabilityResult<Self> {
let pattern = pattern.into();
if Self::contains_path_traversal(&pattern) {
return Err(CapabilityError::InvalidPattern {
pattern,
reason: "path traversal detected: pattern contains '..' segment".to_string(),
});
}
let is_glob = pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
let matcher = if is_glob {
let glob = Glob::new(&pattern).map_err(|e| CapabilityError::InvalidPattern {
pattern: pattern.clone(),
reason: e.to_string(),
})?;
Some(glob.compile_matcher())
} else {
None
};
Ok(Self { pattern, matcher })
}
pub fn exact(pattern: impl Into<String>) -> CapabilityResult<Self> {
let pattern = pattern.into();
if Self::contains_path_traversal(&pattern) {
return Err(CapabilityError::InvalidPattern {
pattern,
reason: "path traversal detected: pattern contains '..' segment".to_string(),
});
}
Ok(Self {
pattern,
matcher: None,
})
}
pub fn file_dir(path: impl Into<String>) -> CapabilityResult<Self> {
let path = path.into();
let pattern = format!("file://{path}/**");
Self::new(pattern)
}
pub fn file_exact(path: impl Into<String>) -> CapabilityResult<Self> {
let path = path.into();
Self::exact(format!("file://{path}"))
}
pub fn mcp_tool(server: impl Into<String>, tool: impl Into<String>) -> CapabilityResult<Self> {
Self::exact(format!("mcp://{}:{}", server.into(), tool.into()))
}
pub fn mcp_server(server: impl Into<String>) -> CapabilityResult<Self> {
Self::new(format!("mcp://{}:*", server.into()))
}
#[must_use]
pub fn matches(&self, resource: &str) -> bool {
if Self::contains_path_traversal(resource) {
return false;
}
match &self.matcher {
Some(matcher) => matcher.is_match(resource),
None => self.pattern == resource,
}
}
fn contains_path_traversal(s: &str) -> bool {
let path = s.split_once("://").map_or(s, |(_, rest)| rest);
path.split('/').any(|segment| segment == "..")
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.pattern
}
#[must_use]
pub fn is_glob(&self) -> bool {
self.matcher.is_some()
}
#[must_use]
pub fn parse_uri(resource: &str) -> Option<ResourceUri> {
let (scheme, rest) = resource.split_once("://")?;
if scheme == "file" {
return Some(ResourceUri {
scheme: scheme.to_string(),
server: None,
tool: None,
path: Some(rest.to_string()),
});
}
if let Some((server, tool)) = rest.split_once(':') {
Some(ResourceUri {
scheme: scheme.to_string(),
server: Some(server.to_string()),
tool: Some(tool.to_string()),
path: None,
})
} else {
Some(ResourceUri {
scheme: scheme.to_string(),
server: Some(rest.to_string()),
tool: None,
path: None,
})
}
}
}
impl std::fmt::Display for ResourcePattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.pattern)
}
}
impl Serialize for ResourcePattern {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.pattern.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ResourcePattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let pattern = String::deserialize(deserializer)?;
Self::new(pattern).map_err(serde::de::Error::custom)
}
}
impl PartialEq for ResourcePattern {
fn eq(&self, other: &Self) -> bool {
self.pattern == other.pattern
}
}
impl Eq for ResourcePattern {}
impl std::hash::Hash for ResourcePattern {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.pattern.hash(state);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceUri {
pub scheme: String,
pub server: Option<String>,
pub tool: Option<String>,
pub path: Option<String>,
}
impl ResourceUri {
#[must_use]
pub fn mcp(server: impl Into<String>, tool: impl Into<String>) -> Self {
Self {
scheme: "mcp".to_string(),
server: Some(server.into()),
tool: Some(tool.into()),
path: None,
}
}
#[must_use]
pub fn file(path: impl Into<String>) -> Self {
Self {
scheme: "file".to_string(),
server: None,
tool: None,
path: Some(path.into()),
}
}
#[must_use]
pub fn to_uri(&self) -> String {
match (&self.server, &self.tool, &self.path) {
(Some(server), Some(tool), _) => format!("{}://{}:{}", self.scheme, server, tool),
(Some(server), None, _) => format!("{}://{}", self.scheme, server),
(_, _, Some(path)) => format!("{}://{}", self.scheme, path),
_ => format!("{}://", self.scheme),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let pattern = ResourcePattern::exact("mcp://filesystem:read_file").unwrap();
assert!(pattern.matches("mcp://filesystem:read_file"));
assert!(!pattern.matches("mcp://filesystem:write_file"));
}
#[test]
fn test_glob_single_wildcard() {
let pattern = ResourcePattern::new("mcp://filesystem:*").unwrap();
assert!(pattern.matches("mcp://filesystem:read_file"));
assert!(pattern.matches("mcp://filesystem:write_file"));
assert!(!pattern.matches("mcp://memory:read"));
}
#[test]
fn test_glob_double_wildcard() {
let pattern = ResourcePattern::new("file:///home/user/**").unwrap();
assert!(pattern.matches("file:///home/user/file.txt"));
assert!(pattern.matches("file:///home/user/deep/nested/file.txt"));
assert!(!pattern.matches("file:///etc/passwd"));
}
#[test]
fn test_glob_server_wildcard() {
let pattern = ResourcePattern::new("mcp://*:read_*").unwrap();
assert!(pattern.matches("mcp://filesystem:read_file"));
assert!(pattern.matches("mcp://memory:read_graph"));
assert!(!pattern.matches("mcp://filesystem:write_file"));
}
#[test]
fn test_parse_mcp_uri() {
let uri = ResourcePattern::parse_uri("mcp://filesystem:read_file").unwrap();
assert_eq!(uri.scheme, "mcp");
assert_eq!(uri.server, Some("filesystem".to_string()));
assert_eq!(uri.tool, Some("read_file".to_string()));
}
#[test]
fn test_parse_file_uri() {
let uri = ResourcePattern::parse_uri("file:///home/user/file.txt").unwrap();
assert_eq!(uri.scheme, "file");
assert_eq!(uri.path, Some("/home/user/file.txt".to_string()));
}
#[test]
fn test_resource_uri_round_trip() {
let uri = ResourceUri::mcp("filesystem", "read_file");
assert_eq!(uri.to_uri(), "mcp://filesystem:read_file");
let uri = ResourceUri::file("/home/user/file.txt");
assert_eq!(uri.to_uri(), "file:///home/user/file.txt");
}
#[test]
fn test_invalid_pattern() {
let result = ResourcePattern::new("mcp://[invalid");
assert!(result.is_err());
}
#[test]
fn test_pattern_serialization() {
let pattern = ResourcePattern::new("mcp://filesystem:*").unwrap();
let json = serde_json::to_string(&pattern).unwrap();
let decoded: ResourcePattern = serde_json::from_str(&json).unwrap();
assert_eq!(pattern, decoded);
}
#[test]
fn test_file_dir() {
let pattern = ResourcePattern::file_dir("/home/user").unwrap();
assert!(pattern.matches("file:///home/user/file.txt"));
assert!(pattern.matches("file:///home/user/deep/nested/file.txt"));
assert!(!pattern.matches("file:///etc/passwd"));
}
#[test]
fn test_file_exact() {
let pattern = ResourcePattern::file_exact("/home/user/file.txt").unwrap();
assert!(pattern.matches("file:///home/user/file.txt"));
assert!(!pattern.matches("file:///home/user/other.txt"));
}
#[test]
fn test_mcp_tool() {
let pattern = ResourcePattern::mcp_tool("filesystem", "read_file").unwrap();
assert!(pattern.matches("mcp://filesystem:read_file"));
assert!(!pattern.matches("mcp://filesystem:write_file"));
assert!(!pattern.matches("mcp://other:read_file"));
}
#[test]
fn test_mcp_server() {
let pattern = ResourcePattern::mcp_server("filesystem").unwrap();
assert!(pattern.matches("mcp://filesystem:read_file"));
assert!(pattern.matches("mcp://filesystem:write_file"));
assert!(!pattern.matches("mcp://memory:read"));
}
#[test]
fn test_reject_path_traversal_in_pattern() {
assert!(ResourcePattern::new("file:///home/user/../../../etc/passwd").is_err());
assert!(ResourcePattern::new("file:///home/user/..").is_err());
assert!(ResourcePattern::new("file://../etc/passwd").is_err());
assert!(ResourcePattern::new("file:///home/user/../../**").is_err());
}
#[test]
fn test_reject_path_traversal_in_exact() {
assert!(ResourcePattern::exact("file:///home/user/../../../etc/passwd").is_err());
assert!(ResourcePattern::exact("file:///home/user/..").is_err());
assert!(ResourcePattern::exact("file://../etc/passwd").is_err());
}
#[test]
fn test_reject_path_traversal_in_resource_match() {
let pattern = ResourcePattern::new("file:///home/user/**").unwrap();
assert!(!pattern.matches("file:///home/user/../../../etc/passwd"));
assert!(!pattern.matches("file:///home/user/subdir/../../etc/shadow"));
assert!(!pattern.matches("file:///home/user/.."));
}
#[test]
fn test_reject_path_traversal_exact_match() {
let pattern = ResourcePattern::exact("mcp://filesystem:read_file").unwrap();
assert!(!pattern.matches("mcp://filesystem:read_file/../../../etc/passwd"));
}
#[test]
fn test_allow_double_dots_in_non_segment() {
let pattern = ResourcePattern::new("file:///home/user/**").unwrap();
assert!(pattern.matches("file:///home/user/file..txt"));
assert!(pattern.matches("file:///home/user/a...b"));
let pattern = ResourcePattern::exact("file:///home/user/file..bak").unwrap();
assert!(pattern.matches("file:///home/user/file..bak"));
}
#[test]
fn test_reject_path_traversal_in_file_dir() {
assert!(ResourcePattern::file_dir("/home/user/../../etc").is_err());
}
#[test]
fn test_reject_path_traversal_in_file_exact() {
assert!(ResourcePattern::file_exact("/home/../etc/passwd").is_err());
assert!(ResourcePattern::file_exact("/../etc/shadow").is_err());
}
}