Skip to main content

astrid_capabilities/
pattern.rs

1//! Resource patterns for capability matching.
2//!
3//! Resource patterns use a URI-like format with glob support:
4//! - `mcp://filesystem:read_file` - Exact match
5//! - `mcp://filesystem:*` - Any tool in filesystem server
6//! - `mcp://*:read_*` - Any read tool in any server
7//! - `file:///home/user/**` - Any file under /home/user
8
9use globset::{Glob, GlobMatcher};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{CapabilityError, CapabilityResult};
13
14/// A pattern that matches resources.
15///
16/// Supports exact matches and glob patterns (*, **, ?).
17#[derive(Debug, Clone)]
18pub struct ResourcePattern {
19    /// The original pattern string.
20    pattern: String,
21    /// Compiled glob matcher (None for exact matches).
22    matcher: Option<GlobMatcher>,
23}
24
25impl ResourcePattern {
26    /// Create a new resource pattern.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`CapabilityError::InvalidPattern`] if the glob pattern is invalid
31    /// or contains path traversal sequences (`..`).
32    pub fn new(pattern: impl Into<String>) -> CapabilityResult<Self> {
33        let pattern = pattern.into();
34
35        // Reject path traversal attempts
36        if Self::contains_path_traversal(&pattern) {
37            return Err(CapabilityError::InvalidPattern {
38                pattern,
39                reason: "path traversal detected: pattern contains '..' segment".to_string(),
40            });
41        }
42
43        // Check if it contains glob characters
44        let is_glob = pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
45
46        let matcher = if is_glob {
47            let glob = Glob::new(&pattern).map_err(|e| CapabilityError::InvalidPattern {
48                pattern: pattern.clone(),
49                reason: e.to_string(),
50            })?;
51            Some(glob.compile_matcher())
52        } else {
53            None
54        };
55
56        Ok(Self { pattern, matcher })
57    }
58
59    /// Create an exact match pattern.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`CapabilityError::InvalidPattern`] if the pattern contains
64    /// path traversal sequences (`..`).
65    pub fn exact(pattern: impl Into<String>) -> CapabilityResult<Self> {
66        let pattern = pattern.into();
67
68        if Self::contains_path_traversal(&pattern) {
69            return Err(CapabilityError::InvalidPattern {
70                pattern,
71                reason: "path traversal detected: pattern contains '..' segment".to_string(),
72            });
73        }
74
75        Ok(Self {
76            pattern,
77            matcher: None,
78        })
79    }
80
81    /// Create a pattern matching a file directory and all contents beneath it.
82    ///
83    /// Example: `file_dir("/home/user")` matches `file:///home/user/any/nested/file`.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`CapabilityError::InvalidPattern`] if the path contains path traversal.
88    pub fn file_dir(path: impl Into<String>) -> CapabilityResult<Self> {
89        let path = path.into();
90        let pattern = format!("file://{path}/**");
91        Self::new(pattern)
92    }
93
94    /// Create a pattern matching an exact file path.
95    ///
96    /// Example: `file_exact("/home/user/file.txt")` matches only `file:///home/user/file.txt`.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`CapabilityError::InvalidPattern`] if the path contains path traversal.
101    pub fn file_exact(path: impl Into<String>) -> CapabilityResult<Self> {
102        let path = path.into();
103        Self::exact(format!("file://{path}"))
104    }
105
106    /// Create a pattern matching a specific MCP tool on a specific server.
107    ///
108    /// Example: `mcp_tool("filesystem", "read_file")` matches `mcp://filesystem:read_file`.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`CapabilityError::InvalidPattern`] if server or tool names
113    /// contain path traversal sequences.
114    pub fn mcp_tool(server: impl Into<String>, tool: impl Into<String>) -> CapabilityResult<Self> {
115        Self::exact(format!("mcp://{}:{}", server.into(), tool.into()))
116    }
117
118    /// Create a pattern matching all tools on an MCP server.
119    ///
120    /// Example: `mcp_server("filesystem")` matches `mcp://filesystem:read_file`,
121    /// `mcp://filesystem:write_file`, etc.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`CapabilityError::InvalidPattern`] if the glob compilation fails.
126    pub fn mcp_server(server: impl Into<String>) -> CapabilityResult<Self> {
127        Self::new(format!("mcp://{}:*", server.into()))
128    }
129
130    /// Check if this pattern matches a resource.
131    ///
132    /// Resources containing path traversal sequences (`..`) are always rejected.
133    #[must_use]
134    pub fn matches(&self, resource: &str) -> bool {
135        // Reject path traversal in the resource being matched
136        if Self::contains_path_traversal(resource) {
137            return false;
138        }
139
140        match &self.matcher {
141            Some(matcher) => matcher.is_match(resource),
142            None => self.pattern == resource,
143        }
144    }
145
146    /// Check if a string contains path traversal sequences.
147    ///
148    /// Detects `..` as a path segment: `/../`, `/..` at end, `../` at start, or bare `..`.
149    fn contains_path_traversal(s: &str) -> bool {
150        // Strip the scheme to check the path portion
151        let path = s.split_once("://").map_or(s, |(_, rest)| rest);
152
153        path.split('/').any(|segment| segment == "..")
154    }
155
156    /// Get the pattern string.
157    #[must_use]
158    pub fn as_str(&self) -> &str {
159        &self.pattern
160    }
161
162    /// Check if this is a glob pattern.
163    #[must_use]
164    pub fn is_glob(&self) -> bool {
165        self.matcher.is_some()
166    }
167
168    /// Parse a resource URI into components.
169    ///
170    /// Format: `scheme://server:tool` or `scheme://path`
171    #[cfg(test)]
172    #[must_use]
173    pub(crate) fn parse_uri(resource: &str) -> Option<ResourceUri> {
174        let (scheme, rest) = resource.split_once("://")?;
175
176        // For file:// URIs, the rest is the path
177        if scheme == "file" {
178            return Some(ResourceUri {
179                scheme: scheme.to_string(),
180                server: None,
181                tool: None,
182                path: Some(rest.to_string()),
183            });
184        }
185
186        // For mcp:// URIs, parse server:tool
187        if let Some((server, tool)) = rest.split_once(':') {
188            Some(ResourceUri {
189                scheme: scheme.to_string(),
190                server: Some(server.to_string()),
191                tool: Some(tool.to_string()),
192                path: None,
193            })
194        } else {
195            Some(ResourceUri {
196                scheme: scheme.to_string(),
197                server: Some(rest.to_string()),
198                tool: None,
199                path: None,
200            })
201        }
202    }
203}
204
205impl std::fmt::Display for ResourcePattern {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        write!(f, "{}", self.pattern)
208    }
209}
210
211impl Serialize for ResourcePattern {
212    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
213    where
214        S: serde::Serializer,
215    {
216        self.pattern.serialize(serializer)
217    }
218}
219
220impl<'de> Deserialize<'de> for ResourcePattern {
221    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
222    where
223        D: serde::Deserializer<'de>,
224    {
225        let pattern = String::deserialize(deserializer)?;
226        Self::new(pattern).map_err(serde::de::Error::custom)
227    }
228}
229
230impl PartialEq for ResourcePattern {
231    fn eq(&self, other: &Self) -> bool {
232        self.pattern == other.pattern
233    }
234}
235
236impl Eq for ResourcePattern {}
237
238impl std::hash::Hash for ResourcePattern {
239    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
240        self.pattern.hash(state);
241    }
242}
243
244/// Parsed components of a resource URI.
245#[cfg(test)]
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub(crate) struct ResourceUri {
248    /// URI scheme (mcp, file, http, etc.)
249    pub(crate) scheme: String,
250    /// Server name (for MCP resources)
251    pub(crate) server: Option<String>,
252    /// Tool name (for MCP resources)
253    pub(crate) tool: Option<String>,
254    /// Path (for file resources)
255    pub(crate) path: Option<String>,
256}
257
258#[cfg(test)]
259impl ResourceUri {
260    /// Create an MCP resource URI.
261    #[must_use]
262    pub(crate) fn mcp(server: impl Into<String>, tool: impl Into<String>) -> Self {
263        Self {
264            scheme: "mcp".to_string(),
265            server: Some(server.into()),
266            tool: Some(tool.into()),
267            path: None,
268        }
269    }
270
271    /// Create a file resource URI.
272    #[must_use]
273    pub(crate) fn file(path: impl Into<String>) -> Self {
274        Self {
275            scheme: "file".to_string(),
276            server: None,
277            tool: None,
278            path: Some(path.into()),
279        }
280    }
281
282    /// Convert back to a URI string.
283    #[must_use]
284    pub(crate) fn to_uri(&self) -> String {
285        match (&self.server, &self.tool, &self.path) {
286            (Some(server), Some(tool), _) => format!("{}://{}:{}", self.scheme, server, tool),
287            (Some(server), None, _) => format!("{}://{}", self.scheme, server),
288            (_, _, Some(path)) => format!("{}://{}", self.scheme, path),
289            _ => format!("{}://", self.scheme),
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_exact_match() {
300        let pattern = ResourcePattern::exact("mcp://filesystem:read_file").unwrap();
301        assert!(pattern.matches("mcp://filesystem:read_file"));
302        assert!(!pattern.matches("mcp://filesystem:write_file"));
303    }
304
305    #[test]
306    fn test_glob_single_wildcard() {
307        let pattern = ResourcePattern::new("mcp://filesystem:*").unwrap();
308        assert!(pattern.matches("mcp://filesystem:read_file"));
309        assert!(pattern.matches("mcp://filesystem:write_file"));
310        assert!(!pattern.matches("mcp://memory:read"));
311    }
312
313    #[test]
314    fn test_glob_double_wildcard() {
315        let pattern = ResourcePattern::new("file:///home/user/**").unwrap();
316        assert!(pattern.matches("file:///home/user/file.txt"));
317        assert!(pattern.matches("file:///home/user/deep/nested/file.txt"));
318        assert!(!pattern.matches("file:///etc/passwd"));
319    }
320
321    #[test]
322    fn test_glob_server_wildcard() {
323        let pattern = ResourcePattern::new("mcp://*:read_*").unwrap();
324        assert!(pattern.matches("mcp://filesystem:read_file"));
325        assert!(pattern.matches("mcp://memory:read_graph"));
326        assert!(!pattern.matches("mcp://filesystem:write_file"));
327    }
328
329    #[test]
330    fn test_parse_mcp_uri() {
331        let uri = ResourcePattern::parse_uri("mcp://filesystem:read_file").unwrap();
332        assert_eq!(uri.scheme, "mcp");
333        assert_eq!(uri.server, Some("filesystem".to_string()));
334        assert_eq!(uri.tool, Some("read_file".to_string()));
335    }
336
337    #[test]
338    fn test_parse_file_uri() {
339        let uri = ResourcePattern::parse_uri("file:///home/user/file.txt").unwrap();
340        assert_eq!(uri.scheme, "file");
341        assert_eq!(uri.path, Some("/home/user/file.txt".to_string()));
342    }
343
344    #[test]
345    fn test_resource_uri_round_trip() {
346        let uri = ResourceUri::mcp("filesystem", "read_file");
347        assert_eq!(uri.to_uri(), "mcp://filesystem:read_file");
348
349        let uri = ResourceUri::file("/home/user/file.txt");
350        assert_eq!(uri.to_uri(), "file:///home/user/file.txt");
351    }
352
353    #[test]
354    fn test_invalid_pattern() {
355        let result = ResourcePattern::new("mcp://[invalid");
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn test_pattern_serialization() {
361        let pattern = ResourcePattern::new("mcp://filesystem:*").unwrap();
362        let json = serde_json::to_string(&pattern).unwrap();
363        let decoded: ResourcePattern = serde_json::from_str(&json).unwrap();
364        assert_eq!(pattern, decoded);
365    }
366
367    // --- Helper constructor tests ---
368
369    #[test]
370    fn test_file_dir() {
371        let pattern = ResourcePattern::file_dir("/home/user").unwrap();
372        assert!(pattern.matches("file:///home/user/file.txt"));
373        assert!(pattern.matches("file:///home/user/deep/nested/file.txt"));
374        assert!(!pattern.matches("file:///etc/passwd"));
375    }
376
377    #[test]
378    fn test_file_exact() {
379        let pattern = ResourcePattern::file_exact("/home/user/file.txt").unwrap();
380        assert!(pattern.matches("file:///home/user/file.txt"));
381        assert!(!pattern.matches("file:///home/user/other.txt"));
382    }
383
384    #[test]
385    fn test_mcp_tool() {
386        let pattern = ResourcePattern::mcp_tool("filesystem", "read_file").unwrap();
387        assert!(pattern.matches("mcp://filesystem:read_file"));
388        assert!(!pattern.matches("mcp://filesystem:write_file"));
389        assert!(!pattern.matches("mcp://other:read_file"));
390    }
391
392    #[test]
393    fn test_mcp_server() {
394        let pattern = ResourcePattern::mcp_server("filesystem").unwrap();
395        assert!(pattern.matches("mcp://filesystem:read_file"));
396        assert!(pattern.matches("mcp://filesystem:write_file"));
397        assert!(!pattern.matches("mcp://memory:read"));
398    }
399
400    // --- Path traversal security tests ---
401
402    #[test]
403    fn test_reject_path_traversal_in_pattern() {
404        // Direct traversal
405        assert!(ResourcePattern::new("file:///home/user/../../../etc/passwd").is_err());
406        // Traversal at end
407        assert!(ResourcePattern::new("file:///home/user/..").is_err());
408        // Traversal at start of path
409        assert!(ResourcePattern::new("file://../etc/passwd").is_err());
410        // Traversal with glob
411        assert!(ResourcePattern::new("file:///home/user/../../**").is_err());
412    }
413
414    #[test]
415    fn test_reject_path_traversal_in_exact() {
416        assert!(ResourcePattern::exact("file:///home/user/../../../etc/passwd").is_err());
417        assert!(ResourcePattern::exact("file:///home/user/..").is_err());
418        assert!(ResourcePattern::exact("file://../etc/passwd").is_err());
419    }
420
421    #[test]
422    fn test_reject_path_traversal_in_resource_match() {
423        let pattern = ResourcePattern::new("file:///home/user/**").unwrap();
424
425        // Traversal in the resource should be rejected even if glob would match
426        assert!(!pattern.matches("file:///home/user/../../../etc/passwd"));
427        assert!(!pattern.matches("file:///home/user/subdir/../../etc/shadow"));
428        assert!(!pattern.matches("file:///home/user/.."));
429    }
430
431    #[test]
432    fn test_reject_path_traversal_exact_match() {
433        let pattern = ResourcePattern::exact("mcp://filesystem:read_file").unwrap();
434
435        // Even exact patterns reject traversal in the matched resource
436        assert!(!pattern.matches("mcp://filesystem:read_file/../../../etc/passwd"));
437    }
438
439    #[test]
440    fn test_allow_double_dots_in_non_segment() {
441        // Double dots inside a filename (not a path segment) should be fine
442        let pattern = ResourcePattern::new("file:///home/user/**").unwrap();
443        assert!(pattern.matches("file:///home/user/file..txt"));
444        assert!(pattern.matches("file:///home/user/a...b"));
445
446        // Pattern with dots in filename is valid
447        let pattern = ResourcePattern::exact("file:///home/user/file..bak").unwrap();
448        assert!(pattern.matches("file:///home/user/file..bak"));
449    }
450
451    #[test]
452    fn test_reject_path_traversal_in_file_dir() {
453        assert!(ResourcePattern::file_dir("/home/user/../../etc").is_err());
454    }
455
456    #[test]
457    fn test_reject_path_traversal_in_file_exact() {
458        assert!(ResourcePattern::file_exact("/home/../etc/passwd").is_err());
459        assert!(ResourcePattern::file_exact("/../etc/shadow").is_err());
460    }
461}