1use std::path::{Path, PathBuf};
25
26use fastmcp_core::{McpContext, McpError, McpOutcome, McpResult, Outcome};
27use fastmcp_protocol::{Resource, ResourceContent, ResourceTemplate};
28
29use crate::handler::{BoxFuture, ResourceHandler, UriParams};
30
31const DEFAULT_MAX_SIZE: usize = 10 * 1024 * 1024;
33
34#[derive(Debug, Clone)]
36pub enum FilesystemProviderError {
37 PathTraversal { requested: String },
39 TooLarge { path: String, size: u64, max: usize },
41 SymlinkDenied { path: String },
43 SymlinkEscapesRoot { path: String },
45 Io { message: String },
47 NotFound { path: String },
49}
50
51impl std::fmt::Display for FilesystemProviderError {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::PathTraversal { requested } => {
55 write!(f, "Path traversal attempt blocked: {requested}")
56 }
57 Self::TooLarge { path, size, max } => {
58 write!(f, "File too large: {path} ({size} bytes, max {max} bytes)")
59 }
60 Self::SymlinkDenied { path } => {
61 write!(f, "Symlink access denied: {path}")
62 }
63 Self::SymlinkEscapesRoot { path } => {
64 write!(f, "Symlink target escapes root directory: {path}")
65 }
66 Self::Io { message } => write!(f, "IO error: {message}"),
67 Self::NotFound { path } => write!(f, "File not found: {path}"),
68 }
69 }
70}
71
72impl std::error::Error for FilesystemProviderError {}
73
74impl From<FilesystemProviderError> for McpError {
75 fn from(err: FilesystemProviderError) -> Self {
76 match err {
77 FilesystemProviderError::PathTraversal { .. } => {
78 McpError::invalid_request(err.to_string())
80 }
81 FilesystemProviderError::TooLarge { .. } => McpError::invalid_request(err.to_string()),
82 FilesystemProviderError::SymlinkDenied { .. }
83 | FilesystemProviderError::SymlinkEscapesRoot { .. } => {
84 McpError::invalid_request(err.to_string())
86 }
87 FilesystemProviderError::Io { .. } => McpError::internal_error(err.to_string()),
88 FilesystemProviderError::NotFound { path } => McpError::resource_not_found(&path),
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
116pub struct FilesystemProvider {
117 root: PathBuf,
119 prefix: Option<String>,
121 include_patterns: Vec<String>,
123 exclude_patterns: Vec<String>,
125 recursive: bool,
127 max_file_size: usize,
129 follow_symlinks: bool,
131 description: Option<String>,
133}
134
135impl FilesystemProvider {
136 #[must_use]
148 pub fn new(root: impl AsRef<Path>) -> Self {
149 Self {
150 root: root.as_ref().to_path_buf(),
151 prefix: None,
152 include_patterns: Vec::new(),
153 exclude_patterns: vec![".*".to_string()], recursive: false,
155 max_file_size: DEFAULT_MAX_SIZE,
156 follow_symlinks: false,
157 description: None,
158 }
159 }
160
161 #[must_use]
173 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
174 self.prefix = Some(prefix.into());
175 self
176 }
177
178 #[must_use]
190 pub fn with_patterns(mut self, patterns: &[&str]) -> Self {
191 self.include_patterns = patterns.iter().map(|s| (*s).to_string()).collect();
192 self
193 }
194
195 #[must_use]
207 pub fn with_exclude(mut self, patterns: &[&str]) -> Self {
208 self.exclude_patterns = patterns.iter().map(|s| (*s).to_string()).collect();
209 self
210 }
211
212 #[must_use]
223 pub fn with_recursive(mut self, enabled: bool) -> Self {
224 self.recursive = enabled;
225 self
226 }
227
228 #[must_use]
240 pub fn with_max_size(mut self, bytes: usize) -> Self {
241 self.max_file_size = bytes;
242 self
243 }
244
245 #[must_use]
257 pub fn with_follow_symlinks(mut self, enabled: bool) -> Self {
258 self.follow_symlinks = enabled;
259 self
260 }
261
262 #[must_use]
271 pub fn with_description(mut self, description: impl Into<String>) -> Self {
272 self.description = Some(description.into());
273 self
274 }
275
276 #[must_use]
291 pub fn build(self) -> FilesystemResourceHandler {
292 FilesystemResourceHandler::new(self)
293 }
294
295 fn validate_path(&self, requested: &str) -> Result<PathBuf, FilesystemProviderError> {
302 let requested_path = Path::new(requested);
303
304 if requested_path.is_absolute() {
306 return Err(FilesystemProviderError::PathTraversal {
307 requested: requested.to_string(),
308 });
309 }
310
311 let full_path = self.root.join(requested_path);
313
314 let canonical = full_path.canonicalize().map_err(|e| {
317 if e.kind() == std::io::ErrorKind::NotFound {
318 FilesystemProviderError::NotFound {
319 path: requested.to_string(),
320 }
321 } else {
322 FilesystemProviderError::Io {
323 message: e.to_string(),
324 }
325 }
326 })?;
327
328 let canonical_root = self
330 .root
331 .canonicalize()
332 .map_err(|e| FilesystemProviderError::Io {
333 message: format!("Cannot canonicalize root: {e}"),
334 })?;
335
336 if !canonical.starts_with(&canonical_root) {
338 return Err(FilesystemProviderError::PathTraversal {
339 requested: requested.to_string(),
340 });
341 }
342
343 if full_path.is_symlink() {
345 self.check_symlink(&full_path, &canonical_root)?;
346 }
347
348 Ok(canonical)
349 }
350
351 fn check_symlink(
353 &self,
354 path: &Path,
355 canonical_root: &Path,
356 ) -> Result<(), FilesystemProviderError> {
357 if !self.follow_symlinks {
358 return Err(FilesystemProviderError::SymlinkDenied {
359 path: path.display().to_string(),
360 });
361 }
362
363 let target = std::fs::read_link(path).map_err(|e| FilesystemProviderError::Io {
365 message: e.to_string(),
366 })?;
367
368 let resolved = if target.is_absolute() {
369 target
370 } else {
371 path.parent().unwrap_or(Path::new("")).join(&target)
372 };
373
374 let canonical_target =
375 resolved
376 .canonicalize()
377 .map_err(|e| FilesystemProviderError::Io {
378 message: e.to_string(),
379 })?;
380
381 if !canonical_target.starts_with(canonical_root) {
382 return Err(FilesystemProviderError::SymlinkEscapesRoot {
383 path: path.display().to_string(),
384 });
385 }
386
387 Ok(())
388 }
389
390 fn matches_patterns(&self, relative_path: &str) -> bool {
392 for pattern in &self.exclude_patterns {
394 if glob_match(pattern, relative_path) {
395 return false;
396 }
397 }
398
399 if self.include_patterns.is_empty() {
401 return true;
402 }
403
404 for pattern in &self.include_patterns {
406 if glob_match(pattern, relative_path) {
407 return true;
408 }
409 }
410
411 false
412 }
413
414 fn list_files(&self) -> Result<Vec<FileEntry>, FilesystemProviderError> {
416 let canonical_root = self
417 .root
418 .canonicalize()
419 .map_err(|e| FilesystemProviderError::Io {
420 message: format!("Cannot canonicalize root: {e}"),
421 })?;
422
423 let mut entries = Vec::new();
424 self.walk_directory(&canonical_root, &canonical_root, &mut entries)?;
425 Ok(entries)
426 }
427
428 fn walk_directory(
430 &self,
431 current: &Path,
432 root: &Path,
433 entries: &mut Vec<FileEntry>,
434 ) -> Result<(), FilesystemProviderError> {
435 let read_dir = std::fs::read_dir(current).map_err(|e| FilesystemProviderError::Io {
436 message: e.to_string(),
437 })?;
438
439 for entry_result in read_dir {
440 let entry = entry_result.map_err(|e| FilesystemProviderError::Io {
441 message: e.to_string(),
442 })?;
443
444 let path = entry.path();
445 let file_type = entry.file_type().map_err(|e| FilesystemProviderError::Io {
446 message: e.to_string(),
447 })?;
448
449 if file_type.is_symlink() && !self.follow_symlinks {
451 continue;
452 }
453
454 let relative = path
456 .strip_prefix(root)
457 .map_err(|e| FilesystemProviderError::Io {
458 message: e.to_string(),
459 })?;
460 let relative_str = relative.to_string_lossy().replace('\\', "/");
461
462 if file_type.is_dir() || (file_type.is_symlink() && path.is_dir()) {
463 if self.recursive {
464 self.walk_directory(&path, root, entries)?;
465 }
466 } else if file_type.is_file() || (file_type.is_symlink() && path.is_file()) {
467 if self.matches_patterns(&relative_str) {
469 let metadata = std::fs::metadata(&path).ok();
470 entries.push(FileEntry {
471 path: path.clone(),
472 relative_path: relative_str,
473 size: metadata.as_ref().map(|m| m.len()),
474 mime_type: detect_mime_type(&path),
475 });
476 }
477 }
478 }
479
480 Ok(())
481 }
482
483 fn file_uri(&self, relative_path: &str) -> String {
485 match &self.prefix {
486 Some(prefix) => format!("file://{prefix}/{relative_path}"),
487 None => format!("file://{relative_path}"),
488 }
489 }
490
491 fn uri_template(&self) -> String {
493 match &self.prefix {
494 Some(prefix) => format!("file://{prefix}/{{path}}"),
495 None => "file://{path}".to_string(),
496 }
497 }
498
499 fn path_from_uri(&self, uri: &str) -> Option<String> {
501 let expected_prefix = match &self.prefix {
502 Some(p) => format!("file://{p}/"),
503 None => "file://".to_string(),
504 };
505
506 if uri.starts_with(&expected_prefix) {
507 Some(uri[expected_prefix.len()..].to_string())
508 } else {
509 None
510 }
511 }
512
513 fn read_file(&self, relative_path: &str) -> Result<FileContent, FilesystemProviderError> {
515 let path = self.validate_path(relative_path)?;
517
518 let metadata = std::fs::metadata(&path).map_err(|e| {
520 if e.kind() == std::io::ErrorKind::NotFound {
521 FilesystemProviderError::NotFound {
522 path: relative_path.to_string(),
523 }
524 } else {
525 FilesystemProviderError::Io {
526 message: e.to_string(),
527 }
528 }
529 })?;
530
531 if metadata.len() > self.max_file_size as u64 {
532 return Err(FilesystemProviderError::TooLarge {
533 path: relative_path.to_string(),
534 size: metadata.len(),
535 max: self.max_file_size,
536 });
537 }
538
539 let mime_type = detect_mime_type(&path);
541
542 let content = if is_binary_mime_type(&mime_type) {
544 let bytes = std::fs::read(&path).map_err(|e| FilesystemProviderError::Io {
545 message: e.to_string(),
546 })?;
547 FileContent::Binary(bytes)
548 } else {
549 let text = std::fs::read_to_string(&path).map_err(|e| FilesystemProviderError::Io {
550 message: e.to_string(),
551 })?;
552 FileContent::Text(text)
553 };
554
555 Ok(content)
556 }
557}
558
559#[derive(Debug)]
561struct FileEntry {
562 #[allow(dead_code)]
563 path: PathBuf,
564 relative_path: String,
565 #[allow(dead_code)]
566 size: Option<u64>,
567 mime_type: String,
568}
569
570enum FileContent {
572 Text(String),
573 Binary(Vec<u8>),
574}
575
576pub struct FilesystemResourceHandler {
578 provider: FilesystemProvider,
579 cached_resources: Vec<Resource>,
581}
582
583impl FilesystemResourceHandler {
584 fn new(provider: FilesystemProvider) -> Self {
586 let cached_resources = match provider.list_files() {
588 Ok(entries) => entries
589 .into_iter()
590 .map(|entry| Resource {
591 uri: provider.file_uri(&entry.relative_path),
592 name: entry.relative_path.clone(),
593 description: None,
594 mime_type: Some(entry.mime_type),
595 icon: None,
596 version: None,
597 tags: vec![],
598 })
599 .collect(),
600 Err(_) => Vec::new(),
601 };
602
603 Self {
604 provider,
605 cached_resources,
606 }
607 }
608}
609
610impl ResourceHandler for FilesystemResourceHandler {
611 fn definition(&self) -> Resource {
612 Resource {
614 uri: self.provider.uri_template(),
615 name: self
616 .provider
617 .prefix
618 .clone()
619 .unwrap_or_else(|| "files".to_string()),
620 description: self.provider.description.clone(),
621 mime_type: None,
622 icon: None,
623 version: None,
624 tags: vec![],
625 }
626 }
627
628 fn template(&self) -> Option<ResourceTemplate> {
629 Some(ResourceTemplate {
630 uri_template: self.provider.uri_template(),
631 name: self
632 .provider
633 .prefix
634 .clone()
635 .unwrap_or_else(|| "files".to_string()),
636 description: self.provider.description.clone(),
637 mime_type: None,
638 icon: None,
639 version: None,
640 tags: vec![],
641 })
642 }
643
644 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
645 let files = self.provider.list_files()?;
647
648 let listing = files
649 .iter()
650 .map(|f| format!("{}: {}", f.relative_path, f.mime_type))
651 .collect::<Vec<_>>()
652 .join("\n");
653
654 Ok(vec![ResourceContent {
655 uri: self.provider.uri_template(),
656 mime_type: Some("text/plain".to_string()),
657 text: Some(listing),
658 blob: None,
659 }])
660 }
661
662 fn read_with_uri(
663 &self,
664 _ctx: &McpContext,
665 uri: &str,
666 params: &UriParams,
667 ) -> McpResult<Vec<ResourceContent>> {
668 let relative_path = if let Some(path) = params.get("path") {
670 path.clone()
671 } else if let Some(path) = self.provider.path_from_uri(uri) {
672 path
673 } else {
674 return Err(McpError::invalid_params("Missing path parameter"));
675 };
676
677 let content = self.provider.read_file(&relative_path)?;
678
679 let resource_content = match content {
680 FileContent::Text(text) => ResourceContent {
681 uri: uri.to_string(),
682 mime_type: Some(detect_mime_type(Path::new(&relative_path))),
683 text: Some(text),
684 blob: None,
685 },
686 FileContent::Binary(bytes) => {
687 let base64_str = base64_encode(&bytes);
688
689 ResourceContent {
690 uri: uri.to_string(),
691 mime_type: Some(detect_mime_type(Path::new(&relative_path))),
692 text: None,
693 blob: Some(base64_str),
694 }
695 }
696 };
697
698 Ok(vec![resource_content])
699 }
700
701 fn read_async_with_uri<'a>(
702 &'a self,
703 ctx: &'a McpContext,
704 uri: &'a str,
705 params: &'a UriParams,
706 ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
707 Box::pin(async move {
708 match self.read_with_uri(ctx, uri, params) {
709 Ok(v) => Outcome::Ok(v),
710 Err(e) => Outcome::Err(e),
711 }
712 })
713 }
714}
715
716impl std::fmt::Debug for FilesystemResourceHandler {
717 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
718 f.debug_struct("FilesystemResourceHandler")
719 .field("provider", &self.provider)
720 .field("cached_resources", &self.cached_resources.len())
721 .finish()
722 }
723}
724
725fn detect_mime_type(path: &Path) -> String {
727 let extension = path
728 .extension()
729 .and_then(|e| e.to_str())
730 .map(str::to_lowercase);
731
732 match extension.as_deref() {
733 Some("txt") => "text/plain",
735 Some("md" | "markdown") => "text/markdown",
736 Some("html" | "htm") => "text/html",
737 Some("css") => "text/css",
738 Some("csv") => "text/csv",
739 Some("xml") => "application/xml",
740
741 Some("rs") => "text/x-rust",
743 Some("py") => "text/x-python",
744 Some("js" | "mjs") => "text/javascript",
745 Some("ts" | "mts") => "text/typescript",
746 Some("json") => "application/json",
747 Some("yaml" | "yml") => "application/yaml",
748 Some("toml") => "application/toml",
749 Some("sh" | "bash") => "text/x-shellscript",
750 Some("c") => "text/x-c",
751 Some("cpp" | "cc" | "cxx") => "text/x-c++",
752 Some("h" | "hpp") => "text/x-c-header",
753 Some("java") => "text/x-java",
754 Some("go") => "text/x-go",
755 Some("rb") => "text/x-ruby",
756 Some("php") => "text/x-php",
757 Some("swift") => "text/x-swift",
758 Some("kt" | "kts") => "text/x-kotlin",
759 Some("sql") => "text/x-sql",
760
761 Some("png") => "image/png",
763 Some("jpg" | "jpeg") => "image/jpeg",
764 Some("gif") => "image/gif",
765 Some("svg") => "image/svg+xml",
766 Some("webp") => "image/webp",
767 Some("ico") => "image/x-icon",
768 Some("bmp") => "image/bmp",
769
770 Some("pdf") => "application/pdf",
772 Some("zip") => "application/zip",
773 Some("gz" | "gzip") => "application/gzip",
774 Some("tar") => "application/x-tar",
775 Some("wasm") => "application/wasm",
776 Some("exe") => "application/octet-stream",
777 Some("dll") => "application/octet-stream",
778 Some("so") => "application/octet-stream",
779 Some("bin") => "application/octet-stream",
780
781 _ => "application/octet-stream",
783 }
784 .to_string()
785}
786
787fn is_binary_mime_type(mime_type: &str) -> bool {
789 mime_type.starts_with("image/")
790 || mime_type.starts_with("audio/")
791 || mime_type.starts_with("video/")
792 || mime_type == "application/octet-stream"
793 || mime_type == "application/pdf"
794 || mime_type == "application/zip"
795 || mime_type == "application/gzip"
796 || mime_type == "application/x-tar"
797 || mime_type == "application/wasm"
798}
799
800const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
802
803fn base64_encode(data: &[u8]) -> String {
805 let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
806
807 for chunk in data.chunks(3) {
808 let b0 = chunk[0] as usize;
809 let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
810 let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
811
812 let combined = (b0 << 16) | (b1 << 8) | b2;
813
814 result.push(BASE64_CHARS[(combined >> 18) & 0x3F] as char);
815 result.push(BASE64_CHARS[(combined >> 12) & 0x3F] as char);
816
817 if chunk.len() > 1 {
818 result.push(BASE64_CHARS[(combined >> 6) & 0x3F] as char);
819 } else {
820 result.push('=');
821 }
822
823 if chunk.len() > 2 {
824 result.push(BASE64_CHARS[combined & 0x3F] as char);
825 } else {
826 result.push('=');
827 }
828 }
829
830 result
831}
832
833fn glob_match(pattern: &str, path: &str) -> bool {
840 glob_match_recursive(pattern, path)
841}
842
843fn glob_match_recursive(pattern: &str, path: &str) -> bool {
845 let mut pattern_chars = pattern.chars().peekable();
846 let mut path_chars = path.chars().peekable();
847
848 while let Some(p) = pattern_chars.next() {
849 match p {
850 '*' => {
851 if pattern_chars.peek() == Some(&'*') {
853 pattern_chars.next(); if pattern_chars.peek() == Some(&'/') {
857 pattern_chars.next();
858 }
859
860 let remaining_pattern: String = pattern_chars.collect();
861
862 let remaining_path: String = path_chars.collect();
865
866 if glob_match_recursive(&remaining_pattern, &remaining_path) {
868 return true;
869 }
870
871 for i in 0..=remaining_path.len() {
873 if glob_match_recursive(&remaining_pattern, &remaining_path[i..]) {
874 return true;
875 }
876 }
877 return false;
878 }
879
880 let remaining_pattern: String = pattern_chars.collect();
882 let remaining_path: String = path_chars.collect();
883
884 for i in 0..=remaining_path.len() {
886 if remaining_path[..i].contains('/') {
888 break;
889 }
890 if glob_match_recursive(&remaining_pattern, &remaining_path[i..]) {
891 return true;
892 }
893 }
894 return false;
895 }
896 '?' => {
897 if path_chars.next().is_none() {
899 return false;
900 }
901 }
902 c => {
903 if path_chars.next() != Some(c) {
905 return false;
906 }
907 }
908 }
909 }
910
911 path_chars.next().is_none()
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918 use std::collections::HashMap;
919 use std::path::Path;
920 use std::sync::atomic::{AtomicU64, Ordering};
921 use std::time::{SystemTime, UNIX_EPOCH};
922
923 static TEST_DIR_SEQ: AtomicU64 = AtomicU64::new(1);
924
925 struct TestDir {
926 path: PathBuf,
927 }
928
929 impl TestDir {
930 fn new(label: &str) -> Self {
931 let mut path = std::env::temp_dir();
932 let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::SeqCst);
933 let nanos = SystemTime::now()
934 .duration_since(UNIX_EPOCH)
935 .expect("system clock before epoch")
936 .as_nanos();
937 path.push(format!(
938 "fastmcp-fs-tests-{label}-{}-{seq}-{nanos}",
939 std::process::id()
940 ));
941 std::fs::create_dir_all(&path).expect("create temp test dir");
942 Self { path }
943 }
944
945 fn join(&self, relative: &str) -> PathBuf {
946 self.path.join(relative)
947 }
948
949 fn path(&self) -> &Path {
950 &self.path
951 }
952 }
953
954 impl Drop for TestDir {
955 fn drop(&mut self) {
956 let _ = std::fs::remove_dir_all(&self.path);
957 }
958 }
959
960 fn write_text(path: &Path, content: &str) {
961 if let Some(parent) = path.parent() {
962 std::fs::create_dir_all(parent).expect("create parent dir");
963 }
964 std::fs::write(path, content).expect("write text file");
965 }
966
967 fn write_bytes(path: &Path, bytes: &[u8]) {
968 if let Some(parent) = path.parent() {
969 std::fs::create_dir_all(parent).expect("create parent dir");
970 }
971 std::fs::write(path, bytes).expect("write binary file");
972 }
973
974 #[test]
975 fn test_glob_match_star() {
976 assert!(glob_match("*.md", "readme.md"));
977 assert!(glob_match("*.md", "CHANGELOG.md"));
978 assert!(!glob_match("*.md", "readme.txt"));
979 assert!(!glob_match("*.md", "dir/readme.md")); }
981
982 #[test]
983 fn test_glob_match_double_star() {
984 assert!(glob_match("**/*.md", "readme.md"));
985 assert!(glob_match("**/*.md", "docs/readme.md"));
986 assert!(glob_match("**/*.md", "docs/api/readme.md"));
987 assert!(!glob_match("**/*.md", "readme.txt"));
988 }
989
990 #[test]
991 fn test_glob_match_question() {
992 assert!(glob_match("file?.txt", "file1.txt"));
993 assert!(glob_match("file?.txt", "fileA.txt"));
994 assert!(!glob_match("file?.txt", "file12.txt"));
995 }
996
997 #[test]
998 fn test_glob_match_hidden() {
999 assert!(glob_match(".*", ".hidden"));
1000 assert!(glob_match(".*", ".gitignore"));
1001 assert!(!glob_match(".*", "visible"));
1002 }
1003
1004 #[test]
1005 fn test_detect_mime_type() {
1006 assert_eq!(detect_mime_type(Path::new("file.md")), "text/markdown");
1007 assert_eq!(detect_mime_type(Path::new("file.json")), "application/json");
1008 assert_eq!(detect_mime_type(Path::new("file.rs")), "text/x-rust");
1009 assert_eq!(detect_mime_type(Path::new("file.png")), "image/png");
1010 assert_eq!(
1011 detect_mime_type(Path::new("file.unknown")),
1012 "application/octet-stream"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_is_binary_mime_type() {
1018 assert!(is_binary_mime_type("image/png"));
1019 assert!(is_binary_mime_type("application/pdf"));
1020 assert!(!is_binary_mime_type("text/plain"));
1021 assert!(!is_binary_mime_type("application/json"));
1022 }
1023
1024 #[test]
1025 fn test_provider_list_files_respects_patterns_and_recursion() {
1026 let root = TestDir::new("list-recursive");
1027 write_text(&root.join("README.md"), "# readme");
1028 write_text(&root.join("notes.txt"), "notes");
1029 write_text(&root.join("nested/info.md"), "# nested");
1030 write_text(&root.join("nested/code.rs"), "fn main() {}");
1031
1032 let provider = FilesystemProvider::new(root.path())
1033 .with_patterns(&["**/*.md", "**/*.txt"])
1034 .with_recursive(true);
1035
1036 let files = provider.list_files().expect("list files");
1037 let mut relative_paths = files
1038 .iter()
1039 .map(|entry| entry.relative_path.as_str())
1040 .collect::<Vec<_>>();
1041 relative_paths.sort_unstable();
1042
1043 assert_eq!(
1044 relative_paths,
1045 vec!["README.md", "nested/info.md", "notes.txt"]
1046 );
1047 }
1048
1049 #[test]
1050 fn test_provider_list_files_non_recursive_skips_subdirectories() {
1051 let root = TestDir::new("list-flat");
1052 write_text(&root.join("root.md"), "root");
1053 write_text(&root.join("nested/child.md"), "child");
1054
1055 let provider = FilesystemProvider::new(root.path())
1056 .with_patterns(&["**/*.md"])
1057 .with_recursive(false);
1058
1059 let files = provider.list_files().expect("list files");
1060 let relative_paths = files
1061 .iter()
1062 .map(|entry| entry.relative_path.as_str())
1063 .collect::<Vec<_>>();
1064 assert_eq!(relative_paths, vec!["root.md"]);
1065 }
1066
1067 #[test]
1068 fn test_validate_path_rejects_absolute_and_parent_escape() {
1069 let root = TestDir::new("validate-path");
1070 write_text(&root.join("safe.txt"), "safe");
1071
1072 let outside_file = root
1073 .path()
1074 .parent()
1075 .expect("temp dir has parent")
1076 .join("outside-fastmcp-provider-test.txt");
1077 write_text(&outside_file, "outside");
1078
1079 let provider = FilesystemProvider::new(root.path());
1080
1081 let absolute_input = if cfg!(windows) {
1086 r"C:\Windows\System32\absolute.txt"
1087 } else {
1088 "/tmp/absolute.txt"
1089 };
1090 let absolute = provider.validate_path(absolute_input);
1091 assert!(matches!(
1092 absolute,
1093 Err(FilesystemProviderError::PathTraversal { .. })
1094 ));
1095
1096 let escape = provider.validate_path("../outside-fastmcp-provider-test.txt");
1097 assert!(matches!(
1098 escape,
1099 Err(FilesystemProviderError::PathTraversal { .. })
1100 ));
1101
1102 let ok = provider.validate_path("safe.txt").expect("safe path");
1103 assert!(ok.ends_with("safe.txt"));
1104 }
1105
1106 #[test]
1107 fn test_read_file_text_binary_and_size_limit() {
1108 let root = TestDir::new("read-file");
1109 write_text(&root.join("doc.txt"), "hello world");
1110 write_bytes(&root.join("blob.bin"), &[0x00, 0x7F, 0xAA, 0x55]);
1111 write_bytes(&root.join("large.bin"), &[0u8; 8]);
1112
1113 let provider = FilesystemProvider::new(root.path()).with_max_size(32);
1114
1115 let text = provider.read_file("doc.txt").expect("read text");
1116 assert!(matches!(text, FileContent::Text(ref t) if t == "hello world"));
1117
1118 let binary = provider.read_file("blob.bin").expect("read binary");
1119 assert!(matches!(binary, FileContent::Binary(ref b) if b == &[0x00, 0x7F, 0xAA, 0x55]));
1120
1121 let size_limited = FilesystemProvider::new(root.path()).with_max_size(4);
1122 let too_large = size_limited.read_file("large.bin");
1123 assert!(matches!(
1124 too_large,
1125 Err(FilesystemProviderError::TooLarge { path, size: 8, max: 4 })
1126 if path == "large.bin"
1127 ));
1128 }
1129
1130 #[test]
1131 fn test_handler_read_listing_and_read_with_uri() {
1132 let root = TestDir::new("handler-read");
1133 write_text(&root.join("docs/readme.md"), "# docs");
1134
1135 let handler = FilesystemProvider::new(root.path())
1136 .with_prefix("docs")
1137 .with_patterns(&["**/*.md"])
1138 .with_recursive(true)
1139 .with_description("Documentation")
1140 .build();
1141
1142 let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
1143
1144 let definition = handler.definition();
1145 assert_eq!(definition.uri, "file://docs/{path}");
1146 assert_eq!(definition.name, "docs");
1147 assert_eq!(definition.description.as_deref(), Some("Documentation"));
1148
1149 let template = handler.template().expect("resource template");
1150 assert_eq!(template.uri_template, "file://docs/{path}");
1151
1152 let listing = handler.read(&ctx).expect("read listing");
1153 let listing_text = listing[0].text.as_deref().expect("listing text");
1154 assert!(listing_text.contains("docs/readme.md: text/markdown"));
1155
1156 let mut params = HashMap::new();
1157 params.insert("path".to_string(), "docs/readme.md".to_string());
1158 let content = handler
1159 .read_with_uri(&ctx, "file://docs/docs/readme.md", ¶ms)
1160 .expect("read with params");
1161 assert_eq!(content[0].text.as_deref(), Some("# docs"));
1162
1163 let empty_params = HashMap::new();
1164 let content_from_uri = handler
1165 .read_with_uri(&ctx, "file://docs/docs/readme.md", &empty_params)
1166 .expect("read using uri path");
1167 assert_eq!(content_from_uri[0].text.as_deref(), Some("# docs"));
1168
1169 let invalid = handler.read_with_uri(&ctx, "file://wrong-prefix/readme.md", &empty_params);
1170 assert!(invalid.is_err());
1171 }
1172
1173 #[test]
1174 fn test_handler_read_async_with_uri() {
1175 let root = TestDir::new("handler-async");
1176 write_text(&root.join("notes.md"), "async content");
1177
1178 let handler = FilesystemProvider::new(root.path())
1179 .with_patterns(&["*.md"])
1180 .build();
1181 let ctx = McpContext::new(asupersync::Cx::for_testing(), 9);
1182
1183 let mut params = HashMap::new();
1184 params.insert("path".to_string(), "notes.md".to_string());
1185 let outcome =
1186 fastmcp_core::block_on(handler.read_async_with_uri(&ctx, "file://notes.md", ¶ms));
1187 match outcome {
1188 Outcome::Ok(content) => {
1189 assert_eq!(content.len(), 1);
1190 assert_eq!(content[0].text.as_deref(), Some("async content"));
1191 }
1192 other => panic!("unexpected async outcome: {other:?}"),
1193 }
1194 }
1195
1196 #[test]
1197 fn test_base64_encode_padding_variants() {
1198 assert_eq!(base64_encode(b""), "");
1199 assert_eq!(base64_encode(b"f"), "Zg==");
1200 assert_eq!(base64_encode(b"fo"), "Zm8=");
1201 assert_eq!(base64_encode(b"foo"), "Zm9v");
1202 }
1203
1204 #[cfg(unix)]
1205 #[test]
1206 fn test_symlink_validation_denied_and_escape() {
1207 use std::os::unix::fs::symlink;
1208
1209 let root = TestDir::new("symlink-root");
1210 let outside = TestDir::new("symlink-outside");
1211
1212 write_text(&root.join("inside.txt"), "inside");
1213 write_text(&outside.join("outside.txt"), "outside");
1214
1215 let inside_link = root.join("inside-link.txt");
1216 let escape_link = root.join("escape-link.txt");
1217 symlink(root.join("inside.txt"), &inside_link).expect("create inside symlink");
1218 symlink(outside.join("outside.txt"), &escape_link).expect("create escape symlink");
1219
1220 let deny_provider = FilesystemProvider::new(root.path()).with_follow_symlinks(false);
1221 let denied = deny_provider.validate_path("inside-link.txt");
1222 assert!(matches!(
1223 denied,
1224 Err(FilesystemProviderError::SymlinkDenied { .. })
1225 ));
1226
1227 let allow_provider = FilesystemProvider::new(root.path()).with_follow_symlinks(true);
1228 let canonical_root = root.path().canonicalize().expect("canonical root");
1229 let escaped = allow_provider.check_symlink(&escape_link, &canonical_root);
1230 assert!(matches!(
1231 escaped,
1232 Err(FilesystemProviderError::SymlinkEscapesRoot { .. })
1233 ));
1234 }
1235
1236 #[test]
1239 fn error_path_traversal_display() {
1240 let err = FilesystemProviderError::PathTraversal {
1241 requested: "../etc/passwd".to_string(),
1242 };
1243 let msg = err.to_string();
1244 assert!(msg.contains("Path traversal attempt blocked"));
1245 assert!(msg.contains("../etc/passwd"));
1246 }
1247
1248 #[test]
1249 fn error_too_large_display() {
1250 let err = FilesystemProviderError::TooLarge {
1251 path: "big.bin".to_string(),
1252 size: 50_000_000,
1253 max: 10_000_000,
1254 };
1255 let msg = err.to_string();
1256 assert!(msg.contains("File too large"));
1257 assert!(msg.contains("big.bin"));
1258 assert!(msg.contains("50000000"));
1259 assert!(msg.contains("10000000"));
1260 }
1261
1262 #[test]
1263 fn error_symlink_denied_display() {
1264 let err = FilesystemProviderError::SymlinkDenied {
1265 path: "link.txt".to_string(),
1266 };
1267 assert!(err.to_string().contains("Symlink access denied"));
1268 }
1269
1270 #[test]
1271 fn error_symlink_escapes_root_display() {
1272 let err = FilesystemProviderError::SymlinkEscapesRoot {
1273 path: "evil-link".to_string(),
1274 };
1275 assert!(err.to_string().contains("Symlink target escapes root"));
1276 }
1277
1278 #[test]
1279 fn error_io_display() {
1280 let err = FilesystemProviderError::Io {
1281 message: "permission denied".to_string(),
1282 };
1283 assert!(err.to_string().contains("IO error"));
1284 assert!(err.to_string().contains("permission denied"));
1285 }
1286
1287 #[test]
1288 fn error_not_found_display() {
1289 let err = FilesystemProviderError::NotFound {
1290 path: "missing.txt".to_string(),
1291 };
1292 assert!(err.to_string().contains("File not found"));
1293 assert!(err.to_string().contains("missing.txt"));
1294 }
1295
1296 #[test]
1297 fn error_debug() {
1298 let err = FilesystemProviderError::PathTraversal {
1299 requested: "x".to_string(),
1300 };
1301 let debug = format!("{:?}", err);
1302 assert!(debug.contains("PathTraversal"));
1303 }
1304
1305 #[test]
1306 fn error_clone() {
1307 let err = FilesystemProviderError::NotFound {
1308 path: "a.txt".to_string(),
1309 };
1310 let cloned = err.clone();
1311 assert!(cloned.to_string().contains("a.txt"));
1312 }
1313
1314 #[test]
1315 fn error_std_error() {
1316 let err = FilesystemProviderError::Io {
1317 message: "oops".to_string(),
1318 };
1319 let std_err: &dyn std::error::Error = &err;
1320 assert!(std_err.to_string().contains("oops"));
1321 }
1322
1323 #[test]
1326 fn error_into_mcp_error_path_traversal() {
1327 let err = FilesystemProviderError::PathTraversal {
1328 requested: "x".to_string(),
1329 };
1330 let mcp: McpError = err.into();
1331 assert!(mcp.message.contains("Path traversal"));
1332 }
1333
1334 #[test]
1335 fn error_into_mcp_error_too_large() {
1336 let err = FilesystemProviderError::TooLarge {
1337 path: "x".to_string(),
1338 size: 100,
1339 max: 10,
1340 };
1341 let mcp: McpError = err.into();
1342 assert!(mcp.message.contains("File too large"));
1343 }
1344
1345 #[test]
1346 fn error_into_mcp_error_symlink_denied() {
1347 let err = FilesystemProviderError::SymlinkDenied {
1348 path: "x".to_string(),
1349 };
1350 let mcp: McpError = err.into();
1351 assert!(mcp.message.contains("Symlink access denied"));
1352 }
1353
1354 #[test]
1355 fn error_into_mcp_error_symlink_escapes() {
1356 let err = FilesystemProviderError::SymlinkEscapesRoot {
1357 path: "x".to_string(),
1358 };
1359 let mcp: McpError = err.into();
1360 assert!(mcp.message.contains("Symlink target escapes"));
1361 }
1362
1363 #[test]
1364 fn error_into_mcp_error_io() {
1365 let err = FilesystemProviderError::Io {
1366 message: "disk fail".to_string(),
1367 };
1368 let mcp: McpError = err.into();
1369 assert!(mcp.message.contains("IO error"));
1370 }
1371
1372 #[test]
1373 fn error_into_mcp_error_not_found() {
1374 let err = FilesystemProviderError::NotFound {
1375 path: "gone.txt".to_string(),
1376 };
1377 let mcp: McpError = err.into();
1378 assert!(mcp.message.contains("gone.txt"));
1379 }
1380
1381 #[test]
1384 fn provider_new_defaults() {
1385 let root = TestDir::new("defaults");
1386 let provider = FilesystemProvider::new(root.path());
1387 assert_eq!(provider.root, root.path().to_path_buf());
1388 assert!(provider.prefix.is_none());
1389 assert!(provider.include_patterns.is_empty());
1390 assert_eq!(provider.exclude_patterns, vec![".*".to_string()]);
1391 assert!(!provider.recursive);
1392 assert_eq!(provider.max_file_size, DEFAULT_MAX_SIZE);
1393 assert!(!provider.follow_symlinks);
1394 assert!(provider.description.is_none());
1395 }
1396
1397 #[test]
1398 fn provider_with_prefix() {
1399 let provider = FilesystemProvider::new("/tmp").with_prefix("myprefix");
1400 assert_eq!(provider.prefix, Some("myprefix".to_string()));
1401 }
1402
1403 #[test]
1404 fn provider_with_patterns() {
1405 let provider = FilesystemProvider::new("/tmp").with_patterns(&["*.md", "*.txt"]);
1406 assert_eq!(provider.include_patterns, vec!["*.md", "*.txt"]);
1407 }
1408
1409 #[test]
1410 fn provider_with_exclude() {
1411 let provider = FilesystemProvider::new("/tmp").with_exclude(&["*.bak", "*.tmp"]);
1412 assert_eq!(provider.exclude_patterns, vec!["*.bak", "*.tmp"]);
1414 }
1415
1416 #[test]
1417 fn provider_with_recursive() {
1418 let provider = FilesystemProvider::new("/tmp").with_recursive(true);
1419 assert!(provider.recursive);
1420 }
1421
1422 #[test]
1423 fn provider_with_max_size() {
1424 let provider = FilesystemProvider::new("/tmp").with_max_size(1024);
1425 assert_eq!(provider.max_file_size, 1024);
1426 }
1427
1428 #[test]
1429 fn provider_with_follow_symlinks() {
1430 let provider = FilesystemProvider::new("/tmp").with_follow_symlinks(true);
1431 assert!(provider.follow_symlinks);
1432 }
1433
1434 #[test]
1435 fn provider_with_description() {
1436 let provider = FilesystemProvider::new("/tmp").with_description("My files");
1437 assert_eq!(provider.description, Some("My files".to_string()));
1438 }
1439
1440 #[test]
1441 fn provider_debug() {
1442 let provider = FilesystemProvider::new("/tmp").with_prefix("dbg");
1443 let debug = format!("{:?}", provider);
1444 assert!(debug.contains("FilesystemProvider"));
1445 assert!(debug.contains("dbg"));
1446 }
1447
1448 #[test]
1449 fn provider_clone() {
1450 let provider = FilesystemProvider::new("/tmp")
1451 .with_prefix("cloned")
1452 .with_recursive(true)
1453 .with_max_size(5000);
1454 let cloned = provider.clone();
1455 assert_eq!(cloned.prefix, Some("cloned".to_string()));
1456 assert!(cloned.recursive);
1457 assert_eq!(cloned.max_file_size, 5000);
1458 }
1459
1460 #[test]
1463 fn file_uri_with_prefix() {
1464 let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1465 assert_eq!(provider.file_uri("readme.md"), "file://docs/readme.md");
1466 }
1467
1468 #[test]
1469 fn file_uri_without_prefix() {
1470 let provider = FilesystemProvider::new("/tmp");
1471 assert_eq!(provider.file_uri("readme.md"), "file://readme.md");
1472 }
1473
1474 #[test]
1475 fn uri_template_with_prefix() {
1476 let provider = FilesystemProvider::new("/tmp").with_prefix("data");
1477 assert_eq!(provider.uri_template(), "file://data/{path}");
1478 }
1479
1480 #[test]
1481 fn uri_template_without_prefix() {
1482 let provider = FilesystemProvider::new("/tmp");
1483 assert_eq!(provider.uri_template(), "file://{path}");
1484 }
1485
1486 #[test]
1487 fn path_from_uri_with_prefix() {
1488 let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1489 assert_eq!(
1490 provider.path_from_uri("file://docs/readme.md"),
1491 Some("readme.md".to_string())
1492 );
1493 }
1494
1495 #[test]
1496 fn path_from_uri_without_prefix() {
1497 let provider = FilesystemProvider::new("/tmp");
1498 assert_eq!(
1499 provider.path_from_uri("file://readme.md"),
1500 Some("readme.md".to_string())
1501 );
1502 }
1503
1504 #[test]
1505 fn path_from_uri_wrong_prefix() {
1506 let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1507 assert_eq!(provider.path_from_uri("file://other/readme.md"), None);
1508 }
1509
1510 #[test]
1511 fn path_from_uri_completely_wrong() {
1512 let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1513 assert_eq!(provider.path_from_uri("http://example.com"), None);
1514 }
1515
1516 #[test]
1519 fn matches_patterns_no_includes_no_excludes() {
1520 let provider = FilesystemProvider::new("/tmp").with_exclude(&[]);
1521 assert!(provider.matches_patterns("anything.txt"));
1522 assert!(provider.matches_patterns(".hidden"));
1523 }
1524
1525 #[test]
1526 fn matches_patterns_excludes_only() {
1527 let provider = FilesystemProvider::new("/tmp"); assert!(provider.matches_patterns("visible.txt"));
1529 assert!(!provider.matches_patterns(".hidden"));
1530 }
1531
1532 #[test]
1533 fn matches_patterns_includes_only() {
1534 let provider = FilesystemProvider::new("/tmp")
1535 .with_exclude(&[])
1536 .with_patterns(&["*.md"]);
1537 assert!(provider.matches_patterns("readme.md"));
1538 assert!(!provider.matches_patterns("readme.txt"));
1539 }
1540
1541 #[test]
1542 fn matches_patterns_exclude_takes_priority() {
1543 let provider = FilesystemProvider::new("/tmp")
1544 .with_patterns(&["*.md"])
1545 .with_exclude(&["secret.md"]);
1546 assert!(provider.matches_patterns("readme.md"));
1547 assert!(!provider.matches_patterns("secret.md"));
1548 }
1549
1550 #[test]
1553 fn validate_path_not_found() {
1554 let root = TestDir::new("validate-notfound");
1555 let provider = FilesystemProvider::new(root.path());
1556 let result = provider.validate_path("nonexistent.txt");
1557 assert!(matches!(
1558 result,
1559 Err(FilesystemProviderError::NotFound { .. })
1560 ));
1561 }
1562
1563 #[test]
1566 fn read_file_not_found() {
1567 let root = TestDir::new("read-notfound");
1568 let provider = FilesystemProvider::new(root.path());
1569 let result = provider.read_file("missing.txt");
1570 assert!(matches!(
1571 result,
1572 Err(FilesystemProviderError::NotFound { .. })
1573 ));
1574 }
1575
1576 #[test]
1579 fn detect_mime_type_text_formats() {
1580 assert_eq!(detect_mime_type(Path::new("f.txt")), "text/plain");
1581 assert_eq!(detect_mime_type(Path::new("f.html")), "text/html");
1582 assert_eq!(detect_mime_type(Path::new("f.htm")), "text/html");
1583 assert_eq!(detect_mime_type(Path::new("f.css")), "text/css");
1584 assert_eq!(detect_mime_type(Path::new("f.csv")), "text/csv");
1585 assert_eq!(detect_mime_type(Path::new("f.xml")), "application/xml");
1586 assert_eq!(detect_mime_type(Path::new("f.markdown")), "text/markdown");
1587 }
1588
1589 #[test]
1590 fn detect_mime_type_programming_languages() {
1591 assert_eq!(detect_mime_type(Path::new("f.py")), "text/x-python");
1592 assert_eq!(detect_mime_type(Path::new("f.js")), "text/javascript");
1593 assert_eq!(detect_mime_type(Path::new("f.mjs")), "text/javascript");
1594 assert_eq!(detect_mime_type(Path::new("f.ts")), "text/typescript");
1595 assert_eq!(detect_mime_type(Path::new("f.mts")), "text/typescript");
1596 assert_eq!(detect_mime_type(Path::new("f.yaml")), "application/yaml");
1597 assert_eq!(detect_mime_type(Path::new("f.yml")), "application/yaml");
1598 assert_eq!(detect_mime_type(Path::new("f.toml")), "application/toml");
1599 assert_eq!(detect_mime_type(Path::new("f.sh")), "text/x-shellscript");
1600 assert_eq!(detect_mime_type(Path::new("f.bash")), "text/x-shellscript");
1601 assert_eq!(detect_mime_type(Path::new("f.c")), "text/x-c");
1602 assert_eq!(detect_mime_type(Path::new("f.cpp")), "text/x-c++");
1603 assert_eq!(detect_mime_type(Path::new("f.cc")), "text/x-c++");
1604 assert_eq!(detect_mime_type(Path::new("f.cxx")), "text/x-c++");
1605 assert_eq!(detect_mime_type(Path::new("f.h")), "text/x-c-header");
1606 assert_eq!(detect_mime_type(Path::new("f.hpp")), "text/x-c-header");
1607 assert_eq!(detect_mime_type(Path::new("f.java")), "text/x-java");
1608 assert_eq!(detect_mime_type(Path::new("f.go")), "text/x-go");
1609 assert_eq!(detect_mime_type(Path::new("f.rb")), "text/x-ruby");
1610 assert_eq!(detect_mime_type(Path::new("f.php")), "text/x-php");
1611 assert_eq!(detect_mime_type(Path::new("f.swift")), "text/x-swift");
1612 assert_eq!(detect_mime_type(Path::new("f.kt")), "text/x-kotlin");
1613 assert_eq!(detect_mime_type(Path::new("f.kts")), "text/x-kotlin");
1614 assert_eq!(detect_mime_type(Path::new("f.sql")), "text/x-sql");
1615 }
1616
1617 #[test]
1618 fn detect_mime_type_images() {
1619 assert_eq!(detect_mime_type(Path::new("f.jpg")), "image/jpeg");
1620 assert_eq!(detect_mime_type(Path::new("f.jpeg")), "image/jpeg");
1621 assert_eq!(detect_mime_type(Path::new("f.gif")), "image/gif");
1622 assert_eq!(detect_mime_type(Path::new("f.svg")), "image/svg+xml");
1623 assert_eq!(detect_mime_type(Path::new("f.webp")), "image/webp");
1624 assert_eq!(detect_mime_type(Path::new("f.ico")), "image/x-icon");
1625 assert_eq!(detect_mime_type(Path::new("f.bmp")), "image/bmp");
1626 }
1627
1628 #[test]
1629 fn detect_mime_type_binary() {
1630 assert_eq!(detect_mime_type(Path::new("f.pdf")), "application/pdf");
1631 assert_eq!(detect_mime_type(Path::new("f.zip")), "application/zip");
1632 assert_eq!(detect_mime_type(Path::new("f.gz")), "application/gzip");
1633 assert_eq!(detect_mime_type(Path::new("f.gzip")), "application/gzip");
1634 assert_eq!(detect_mime_type(Path::new("f.tar")), "application/x-tar");
1635 assert_eq!(detect_mime_type(Path::new("f.wasm")), "application/wasm");
1636 assert_eq!(
1637 detect_mime_type(Path::new("f.exe")),
1638 "application/octet-stream"
1639 );
1640 assert_eq!(
1641 detect_mime_type(Path::new("f.dll")),
1642 "application/octet-stream"
1643 );
1644 assert_eq!(
1645 detect_mime_type(Path::new("f.so")),
1646 "application/octet-stream"
1647 );
1648 assert_eq!(
1649 detect_mime_type(Path::new("f.bin")),
1650 "application/octet-stream"
1651 );
1652 }
1653
1654 #[test]
1655 fn detect_mime_type_no_extension() {
1656 assert_eq!(
1657 detect_mime_type(Path::new("Makefile")),
1658 "application/octet-stream"
1659 );
1660 }
1661
1662 #[test]
1665 fn is_binary_mime_type_audio_video() {
1666 assert!(is_binary_mime_type("audio/mpeg"));
1667 assert!(is_binary_mime_type("video/mp4"));
1668 }
1669
1670 #[test]
1671 fn is_binary_mime_type_archives() {
1672 assert!(is_binary_mime_type("application/zip"));
1673 assert!(is_binary_mime_type("application/gzip"));
1674 assert!(is_binary_mime_type("application/x-tar"));
1675 assert!(is_binary_mime_type("application/wasm"));
1676 assert!(is_binary_mime_type("application/octet-stream"));
1677 }
1678
1679 #[test]
1680 fn is_binary_mime_type_text_types_false() {
1681 assert!(!is_binary_mime_type("text/html"));
1682 assert!(!is_binary_mime_type("text/markdown"));
1683 assert!(!is_binary_mime_type("application/yaml"));
1684 assert!(!is_binary_mime_type("application/toml"));
1685 }
1686
1687 #[test]
1690 fn base64_encode_hello_world() {
1691 assert_eq!(base64_encode(b"Hello, World!"), "SGVsbG8sIFdvcmxkIQ==");
1692 }
1693
1694 #[test]
1695 fn base64_encode_binary_sequence() {
1696 assert_eq!(base64_encode(&[0, 1, 2]), "AAEC");
1698 }
1699
1700 #[test]
1703 fn glob_match_exact() {
1704 assert!(glob_match("readme.md", "readme.md"));
1705 assert!(!glob_match("readme.md", "other.md"));
1706 }
1707
1708 #[test]
1709 fn glob_match_empty_pattern_empty_path() {
1710 assert!(glob_match("", ""));
1711 }
1712
1713 #[test]
1714 fn glob_match_star_empty() {
1715 assert!(glob_match("*", ""));
1716 assert!(glob_match("*", "anything"));
1717 }
1718
1719 #[test]
1720 fn glob_match_double_star_alone() {
1721 assert!(glob_match("**", ""));
1722 assert!(glob_match("**", "a/b/c"));
1723 }
1724
1725 #[test]
1726 fn glob_match_mixed_pattern() {
1727 assert!(glob_match("src/*.rs", "src/main.rs"));
1728 assert!(!glob_match("src/*.rs", "src/sub/main.rs"));
1729 assert!(glob_match("src/**/*.rs", "src/sub/main.rs"));
1730 }
1731
1732 #[test]
1735 fn handler_debug() {
1736 let root = TestDir::new("handler-debug");
1737 write_text(&root.join("a.txt"), "hello");
1738 let handler = FilesystemProvider::new(root.path()).build();
1739 let debug = format!("{:?}", handler);
1740 assert!(debug.contains("FilesystemResourceHandler"));
1741 assert!(debug.contains("provider"));
1742 }
1743
1744 #[test]
1745 fn handler_definition_without_prefix() {
1746 let root = TestDir::new("handler-no-prefix");
1747 let handler = FilesystemProvider::new(root.path()).build();
1748 let def = handler.definition();
1749 assert_eq!(def.name, "files");
1750 assert_eq!(def.uri, "file://{path}");
1751 assert!(def.description.is_none());
1752 }
1753
1754 #[test]
1755 fn handler_template_without_prefix() {
1756 let root = TestDir::new("handler-tmpl-no-prefix");
1757 let handler = FilesystemProvider::new(root.path()).build();
1758 let tmpl = handler.template().unwrap();
1759 assert_eq!(tmpl.uri_template, "file://{path}");
1760 assert_eq!(tmpl.name, "files");
1761 }
1762
1763 #[test]
1764 fn handler_cached_resources_populated() {
1765 let root = TestDir::new("handler-cached");
1766 write_text(&root.join("one.txt"), "1");
1767 write_text(&root.join("two.md"), "2");
1768 let handler = FilesystemProvider::new(root.path())
1769 .with_exclude(&[])
1770 .build();
1771 assert!(handler.cached_resources.len() >= 2);
1773 }
1774
1775 #[test]
1776 fn handler_read_with_uri_missing_path_param() {
1777 let root = TestDir::new("handler-missing-param");
1778 let handler = FilesystemProvider::new(root.path())
1779 .with_prefix("p")
1780 .build();
1781 let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
1782 let empty_params = HashMap::new();
1783 let result = handler.read_with_uri(&ctx, "file://wrong/x", &empty_params);
1785 assert!(result.is_err());
1786 }
1787
1788 #[test]
1789 fn handler_read_binary_file_returns_blob() {
1790 let root = TestDir::new("handler-binary");
1791 write_bytes(&root.join("data.bin"), &[0xDE, 0xAD, 0xBE, 0xEF]);
1792
1793 let handler = FilesystemProvider::new(root.path())
1794 .with_exclude(&[])
1795 .build();
1796 let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
1797 let mut params = HashMap::new();
1798 params.insert("path".to_string(), "data.bin".to_string());
1799 let result = handler
1800 .read_with_uri(&ctx, "file://data.bin", ¶ms)
1801 .unwrap();
1802 assert!(result[0].text.is_none());
1803 assert!(result[0].blob.is_some());
1804 }
1805
1806 #[test]
1809 fn list_files_excludes_hidden_by_default() {
1810 let root = TestDir::new("list-hidden");
1811 write_text(&root.join("visible.txt"), "v");
1812 write_text(&root.join(".hidden"), "h");
1813
1814 let provider = FilesystemProvider::new(root.path());
1815 let files = provider.list_files().unwrap();
1816 let paths: Vec<&str> = files.iter().map(|e| e.relative_path.as_str()).collect();
1817 assert!(paths.contains(&"visible.txt"));
1818 assert!(!paths.contains(&".hidden"));
1819 }
1820
1821 #[test]
1822 fn list_files_no_patterns_includes_all() {
1823 let root = TestDir::new("list-all");
1824 write_text(&root.join("a.txt"), "a");
1825 write_text(&root.join("b.rs"), "b");
1826
1827 let provider = FilesystemProvider::new(root.path()).with_exclude(&[]);
1828 let files = provider.list_files().unwrap();
1829 assert!(files.len() >= 2);
1830 }
1831
1832 #[test]
1835 fn default_max_size_is_10mb() {
1836 assert_eq!(DEFAULT_MAX_SIZE, 10 * 1024 * 1024);
1837 }
1838
1839 #[test]
1842 fn file_entry_debug() {
1843 let entry = FileEntry {
1844 path: PathBuf::from("/tmp/test.txt"),
1845 relative_path: "test.txt".to_string(),
1846 size: Some(42),
1847 mime_type: "text/plain".to_string(),
1848 };
1849 let debug = format!("{:?}", entry);
1850 assert!(debug.contains("test.txt"));
1851 assert!(debug.contains("42"));
1852 }
1853
1854 #[test]
1857 fn provider_builder_chaining() {
1858 let root = TestDir::new("builder-chain");
1859 let provider = FilesystemProvider::new(root.path())
1860 .with_prefix("chain")
1861 .with_patterns(&["*.md"])
1862 .with_exclude(&["*.bak"])
1863 .with_recursive(true)
1864 .with_max_size(2048)
1865 .with_follow_symlinks(true)
1866 .with_description("Chain test");
1867
1868 assert_eq!(provider.prefix, Some("chain".to_string()));
1869 assert_eq!(provider.include_patterns, vec!["*.md"]);
1870 assert_eq!(provider.exclude_patterns, vec!["*.bak"]);
1871 assert!(provider.recursive);
1872 assert_eq!(provider.max_file_size, 2048);
1873 assert!(provider.follow_symlinks);
1874 assert_eq!(provider.description, Some("Chain test".to_string()));
1875 }
1876
1877 #[test]
1880 fn detect_mime_type_case_insensitive() {
1881 assert_eq!(detect_mime_type(Path::new("README.MD")), "text/markdown");
1882 assert_eq!(detect_mime_type(Path::new("photo.JPG")), "image/jpeg");
1883 assert_eq!(detect_mime_type(Path::new("data.JSON")), "application/json");
1884 }
1885
1886 #[test]
1887 fn glob_match_question_mark_at_end_fails_when_no_char() {
1888 assert!(!glob_match("file?", "file"));
1889 assert!(glob_match("file?", "fileA"));
1890 }
1891
1892 #[test]
1893 fn base64_encode_round_trips_with_std_decoder() {
1894 use base64::Engine as _;
1895 let data = b"The quick brown fox jumps over the lazy dog";
1896 let encoded = base64_encode(data);
1897 let decoded = base64::engine::general_purpose::STANDARD
1898 .decode(&encoded)
1899 .expect("valid base64");
1900 assert_eq!(decoded, data);
1901 }
1902
1903 #[test]
1904 fn handler_empty_root_has_no_cached_resources() {
1905 let root = TestDir::new("handler-empty");
1906 let handler = FilesystemProvider::new(root.path()).build();
1907 assert!(handler.cached_resources.is_empty());
1908 }
1909
1910 #[test]
1911 fn list_files_nonexistent_root_returns_error() {
1912 let provider = FilesystemProvider::new("/nonexistent-fastmcp-test-dir-xyz");
1913 let result = provider.list_files();
1914 assert!(result.is_err());
1915 }
1916
1917 #[test]
1918 fn read_file_path_traversal_blocked() {
1919 let root = TestDir::new("read-traversal");
1920 write_text(&root.join("safe.txt"), "ok");
1921 let provider = FilesystemProvider::new(root.path());
1922 let result = provider.read_file("../../../etc/passwd");
1923 assert!(matches!(
1924 result,
1925 Err(FilesystemProviderError::PathTraversal { .. }
1926 | FilesystemProviderError::NotFound { .. })
1927 ));
1928 }
1929}