1use globset::{Glob, GlobMatcher};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{CapabilityError, CapabilityResult};
13
14#[derive(Debug, Clone)]
18pub struct ResourcePattern {
19 pattern: String,
21 matcher: Option<GlobMatcher>,
23}
24
25impl ResourcePattern {
26 pub fn new(pattern: impl Into<String>) -> CapabilityResult<Self> {
33 let pattern = pattern.into();
34
35 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 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 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 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 pub fn file_exact(path: impl Into<String>) -> CapabilityResult<Self> {
102 let path = path.into();
103 Self::exact(format!("file://{path}"))
104 }
105
106 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 pub fn mcp_server(server: impl Into<String>) -> CapabilityResult<Self> {
127 Self::new(format!("mcp://{}:*", server.into()))
128 }
129
130 #[must_use]
134 pub fn matches(&self, resource: &str) -> bool {
135 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 fn contains_path_traversal(s: &str) -> bool {
150 let path = s.split_once("://").map_or(s, |(_, rest)| rest);
152
153 path.split('/').any(|segment| segment == "..")
154 }
155
156 #[must_use]
158 pub fn as_str(&self) -> &str {
159 &self.pattern
160 }
161
162 #[must_use]
164 pub fn is_glob(&self) -> bool {
165 self.matcher.is_some()
166 }
167
168 #[cfg(test)]
172 #[must_use]
173 pub(crate) fn parse_uri(resource: &str) -> Option<ResourceUri> {
174 let (scheme, rest) = resource.split_once("://")?;
175
176 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 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#[cfg(test)]
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub(crate) struct ResourceUri {
248 pub(crate) scheme: String,
250 pub(crate) server: Option<String>,
252 pub(crate) tool: Option<String>,
254 pub(crate) path: Option<String>,
256}
257
258#[cfg(test)]
259impl ResourceUri {
260 #[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 #[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 #[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 #[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 #[test]
403 fn test_reject_path_traversal_in_pattern() {
404 assert!(ResourcePattern::new("file:///home/user/../../../etc/passwd").is_err());
406 assert!(ResourcePattern::new("file:///home/user/..").is_err());
408 assert!(ResourcePattern::new("file://../etc/passwd").is_err());
410 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 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 assert!(!pattern.matches("mcp://filesystem:read_file/../../../etc/passwd"));
437 }
438
439 #[test]
440 fn test_allow_double_dots_in_non_segment() {
441 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 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}